Repository: MetaMorphic-Digital/draw-steel
Pull request #282: Token Ruler labels and Free Strikes
Author: login: krbz999
Labels: name: canvas
description: Canvas objects, layers, and graphics
Created: 2025-03-08T18:32:35Z | Merged: 2025-03-13T02:25:25Z

Adds `TokenDocument#getHostileTokensFromPoints` which returns token documents (different from the token) that are within 1 grid space of any of the given points.

Adjusts the label when actively measuring to display the number of free strikes that might happen.

Adds system subclasses of `TokenRuler`, `Token`, and `TokenDocument`.

TODO:
- [ ] Take shifting into account.
- [ ] Adjust ruler labels when the token is actively moving (user is done measuring).
- [ ] Adjust ruler labels for the movement history.
- [ ] Add a prompt (?) and/or apply damage to the moving token's actor if there are any free strikes.
- [x] The changes to the label should not apply to "passed" segments.
- [x] Increase strike count at each waypoint.

I'm not sold on the above way of handling free strikes; might also segmentize movement for each point where the token would move into range of an enemy, pause it, resolve the damage, and continue.


Diff:
diff --git a/draw-steel.mjs b/draw-steel.mjs
index 378ae9824..257a2b2c1 100644
--- a/draw-steel.mjs
+++ b/draw-steel.mjs
@@ -1,3 +1,4 @@
+import * as canvas from "./src/module/canvas/_module.mjs";
 import * as documents from "./src/module/documents/_module.mjs";
 import * as applications from "./src/module/apps/_module.mjs";
 import * as helpers from "./src/module/helpers/_module.mjs";
@@ -7,6 +8,7 @@ import {DRAW_STEEL} from "./src/module/config.mjs";
 import * as DS_CONST from "./src/module/constants.mjs";
 
 globalThis.ds = {
+  canvas,
   documents,
   applications,
   helpers,
@@ -39,6 +41,10 @@ Hooks.once("init", function () {
     }
   }
 
+  // Assign canvas-related classes
+  CONFIG.Token.objectClass = canvas.placeables.DrawSteelToken;
+  CONFIG.Token.rulerClass = canvas.placeables.tokens.DrawSteelTokenRuler;
+
   loadTemplates(templates);
 
   //Remove Status Effects Not Available in DrawSteel
diff --git a/src/module/canvas/_module.mjs b/src/module/canvas/_module.mjs
new file mode 100644
index 000000000..790da0f31
--- /dev/null
+++ b/src/module/canvas/_module.mjs
@@ -0,0 +1 @@
+export * as placeables from "./placeables/_module.mjs";
diff --git a/src/module/canvas/placeables/_module.mjs b/src/module/canvas/placeables/_module.mjs
new file mode 100644
index 000000000..e040d35cc
--- /dev/null
+++ b/src/module/canvas/placeables/_module.mjs
@@ -0,0 +1,2 @@
+export {default as DrawSteelToken} from "./token.mjs";
+export * as tokens from "./tokens/_module.mjs";
diff --git a/src/module/canvas/placeables/token.mjs b/src/module/canvas/placeables/token.mjs
new file mode 100644
index 000000000..cdb5d6109
--- /dev/null
+++ b/src/module/canvas/placeables/token.mjs
@@ -0,0 +1,30 @@
+export default class DrawSteelToken extends foundry.canvas.placeables.Token {
+  /**
+   * Does this token visibly show its ability to strike when another token is moving?
+   * This method does not take starting positions or distances into account.
+   * @param {DrawSteelToken|DrawSteelTokenDocument} token    The token that is moving.
+   * @returns {boolean}
+   */
+  canStrike(token) {
+    if (token.id === this.id) return false;
+    if (!this.document.visible || this.document.isSecret) return false;
+    return true;
+  }
+
+  /* -------------------------------------------------- */
+
+  /**
+   * The current ruler data path, segmentized.
+   * @returns {object[][]}    Waypoints split into segments.
+   */
+  get segmentizedFoundPath() {
+    const path = this._rulerData[game.user.id]?.foundPath ?? [];
+    const segments = path.reduce((acc, waypoint) => {
+      acc.at(-1).push(waypoint);
+      if (!waypoint.intermediate) acc.push([]);
+      return acc;
+    }, [[]]);
+    segments.pop();
+    return segments;
+  }
+}
diff --git a/src/module/canvas/placeables/tokens/_module.mjs b/src/module/canvas/placeables/tokens/_module.mjs
new file mode 100644
index 000000000..c89067dbb
--- /dev/null
+++ b/src/module/canvas/placeables/tokens/_module.mjs
@@ -0,0 +1 @@
+export {default as DrawSteelTokenRuler} from "./token-ruler.mjs";
diff --git a/src/module/canvas/placeables/tokens/token-ruler.mjs b/src/module/canvas/placeables/tokens/token-ruler.mjs
new file mode 100644
index 000000000..ac2f03f1b
--- /dev/null
+++ b/src/module/canvas/placeables/tokens/token-ruler.mjs
@@ -0,0 +1,62 @@
+export default class DrawSteelTokenRuler extends foundry.canvas.placeables.tokens.TokenRuler {
+  /** @inheritdoc */
+  _getWaypointLabel(waypoint) {
+    if ((waypoint.stage === "passed") || !waypoint.previous || (waypoint.previous.stage === "passed")) {
+      return super._getWaypointLabel(waypoint);
+    }
+
+    let {text, alpha, scale} = super._getWaypointLabel(waypoint);
+    const segments = this.token.segmentizedFoundPath;
+
+    for (const [i, segment] of segments.entries()) {
+      // Enemies you started nearby.
+      const startedNear = segments[i - 1]?.endpointEnemies ?? new Set();
+
+      // Enemies you ended near.
+      const endpointEnemies = new Set(this.token.document.getHostileTokensFromPoints([segment.at(-1)]));
+
+      // All tokens you passed by (and started near).
+      const passedBy = new Set(this.token.document.getHostileTokensFromPoints(segment)).union(startedNear);
+
+      // The number of strikes is equal to the number of tokens you passed by but did not end near.
+      const strikes = passedBy.difference(endpointEnemies).size;
+
+      Object.assign(segment, {
+        endpointEnemies, strikes,
+        count: strikes + (segments[i - 1]?.count ?? 0)
+      });
+    }
+
+    segments.shift(); // Dont care about the first singleton.
+
+    let index = segments.length - 1;
+    let next = waypoint;
+    while (next.next) {
+      next = next.next;
+      index--;
+    }
+
+    text = [
+      text,
+      segments[index]?.count ? `⚔ ${segments[index].count}` : null
+    ].filterJoin(" ");
+    return {text, alpha, scale};
+  }
+
+  /* -------------------------------------------------- */
+
+  /** @inheritdoc */
+  refresh(state) {
+    super.refresh(state);
+
+    const points = state.plannedMovement?.[game.user.id]?.foundPath ?? [];
+    const tokens = this.token.document.getHostileTokensFromPoints(points);
+
+    for (const {object: token} of tokens) {
+      // TODO: add a PIXI element near hostile tokens.
+      // const {x, y} = token.center;
+      // const circle = new PIXI.Circle(x, y, 10);
+      // this.token.layer._rulerPaths.addChild(circle);
+    }
+  }
+}
diff --git a/src/module/documents/_module.mjs b/src/module/documents/_module.mjs
index 746ae4404..a95a239fd 100644
--- a/src/module/documents/_module.mjs
+++ b/src/module/documents/_module.mjs
@@ -6,4 +6,5 @@ export {DrawSteelChatMessage} from "./chat-message.mjs";
 export {DrawSteelCombat} from "./combat.mjs";
 export {DrawSteelCombatant} from "./combatant.mjs";
 export {DrawSteelItem} from "./item.mjs";
+export {default as DrawSteelTokenDocument} from "./token.mjs";
 export {DrawSteelUser} from "./user.mjs";
diff --git a/src/module/documents/token.mjs b/src/module/documents/token.mjs
new file mode 100644
index 000000000..fd6a0fb21
--- /dev/null
+++ b/src/module/documents/token.mjs
@@ -0,0 +1,52 @@
+export default class DrawSteelTokenDocument extends TokenDocument {
+  /**
+   * Is the token's movement currently shifting?
+   * @type {boolean}
+   */
+  get isShifting() {
+    return false; // TODO
+  }
+
+  /* -------------------------------------------------- */
+
+  /**
+   * Get hostile tokens within range of movement.
+   * @param {Point[]} [points]              An array of points describing a segment of movement.
+   * @returns {DrawSteelTokenDocument[]}    Hostile tokens.
+   */
+  getHostileTokensFromPoints(points = []) {
+    if (!points.length) return [];
+    const tokens = new Set();
+
+    for (let point of points) {
+      point = canvas.grid.getCenterPoint(point);
+      const rect = new PIXI.Rectangle(
+        point.x - canvas.scene.grid.size * 1.5,
+        point.y - canvas.scene.grid.size * 1.5,
+        3 * canvas.scene.grid.size,
+        3 * canvas.scene.grid.size
+      );
+      const found = canvas.tokens.quadtree.getObjects(rect);
+      for (const token of found) {
+        if (!token.canStrike(this) || tokens.has(token.document)) continue;
+        const distance = canvas.grid.measurePath([point, {...token.center, elevation: token.document.elevation}]).distance;
+        if (distance <= 1) tokens.add(token.document);
+      }
+    }
+    return Array.from(tokens);
+  }
+
+  /* -------------------------------------------------- */
+
+  /** @inheritdoc */
+  async _preUpdateMovement(movement, operation = {}) {
+    const nextSegment = movement.passed;
+    const tokens = this.getHostileTokensFromPoints(nextSegment);
+
+    if (tokens.length) {
+      // TODO: Prompt to confirm the movement.
+      const allowed = true;
+      if (allowed === false) return false;
+    }
+  }
+}


Reviews:
user: login: JPMeehan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T08:08:15Z
user: login: krbz999
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-09T09:46:08Z
user: login: JPMeehan
body: Really nice work, would like to adjust the retreat/engage logic
![image](https://github.com/user-attachments/assets/e095275a-3f64-4670-8658-2a108a3e8f0e)

![image](https://github.com/user-attachments/assets/d1a09bd1-92ec-49a5-87e3-76d7ff0e692d)


state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T00:48:55Z
user: login: krbz999
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T00:51:09Z
user: login: krbz999
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T00:53:52Z
user: login: krbz999
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T00:57:35Z
user: login: JPMeehan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T01:04:24Z
user: login: JPMeehan
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T01:53:00Z

Review comments:
diff_hunk: @@ -6,4 +6,5 @@ export {DrawSteelChatMessage} from "./chat-message.mjs";
 export {DrawSteelCombat} from "./combat.mjs";
 export {DrawSteelCombatant} from "./combatant.mjs";
 export {DrawSteelItem} from "./item.mjs";
+export {default as DrawSteelTokenDocument} from "./token.mjs";
path: src/module/documents/_module.mjs
user: login: JPMeehan
body: Should we flip all the Documents to `export default`?
created_at: 2025-03-09T08:08:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,4 +6,5 @@ export {DrawSteelChatMessage} from "./chat-message.mjs";
 export {DrawSteelCombat} from "./combat.mjs";
 export {DrawSteelCombatant} from "./combatant.mjs";
 export {DrawSteelItem} from "./item.mjs";
+export {default as DrawSteelTokenDocument} from "./token.mjs";
path: src/module/documents/_module.mjs
user: login: krbz999
body: I prefer it, but not in this PR.
created_at: 2025-03-09T09:46:08Z
author_association: MEMBER
diff_hunk: @@ -39,6 +41,10 @@ Hooks.once("init", function () {
     }
   }
 
+  // Assign canvas-related classes
+  CONFIG.Token.objectClass = canvas.placeables.DrawSteelToken;
+  CONFIG.Token.rulerClass = canvas.placeables.tokens.DrawSteelTokenRuler;
path: draw-steel.mjs
user: login: JPMeehan
body: Should we just `Object.assign` to CONFIG.Token here? Or do you think that listing them sequentially like this is clearer?
created_at: 2025-03-10T21:52:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+export default class DrawSteelTokenDocument extends TokenDocument {
+  /**
+   * Is the token's movement currently shifting?
+   * @type {boolean}
+   */
+  get isShifting() {
+    return false; // TODO
+  }
+
+  /* -------------------------------------------------- */
+
+  /**
+   * Get hostile tokens within range of movement.
+   * @param {Point[]} [points]                An array of points describing a segment of movement.
+   * @param {object} [options]
+   * @param {boolean} [options.ignoreFirst]   Ignore any tokens that were in range of the first point.
+   * @returns {DrawSteelTokenDocument[]}      Hostile tokens.
+   */
+  getHostileTokensFromPoints(points = [], {ignoreFirst = false} = {}) {
path: src/module/documents/token.mjs
user: login: JPMeehan
body: I think some of the logic here is flipped - I'm getting a strike count for *approaching* a target but not for directly retreating.
created_at: 2025-03-11T00:46:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +41,10 @@ Hooks.once("init", function () {
     }
   }
 
+  // Assign canvas-related classes
+  CONFIG.Token.objectClass = canvas.placeables.DrawSteelToken;
+  CONFIG.Token.rulerClass = canvas.placeables.tokens.DrawSteelTokenRuler;
path: draw-steel.mjs
user: login: krbz999
body: I don't think there's much to be gained from trying to configure it in a way that `Object.assign` would be possible.
created_at: 2025-03-11T00:51:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+export default class DrawSteelTokenDocument extends TokenDocument {
+  /**
+   * Is the token's movement currently shifting?
+   * @type {boolean}
+   */
+  get isShifting() {
+    return false; // TODO
+  }
+
+  /* -------------------------------------------------- */
+
+  /**
+   * Get hostile tokens within range of movement.
+   * @param {Point[]} [points]                An array of points describing a segment of movement.
+   * @param {object} [options]
+   * @param {boolean} [options.ignoreFirst]   Ignore any tokens that were in range of the first point.
+   * @returns {DrawSteelTokenDocument[]}      Hostile tokens.
+   */
+  getHostileTokensFromPoints(points = [], {ignoreFirst = false} = {}) {
path: src/module/documents/token.mjs
user: login: krbz999
body: The logic should perhaps be tokens you pass by (as it is now) but whom you are not within 1 square of at the path's end point.
created_at: 2025-03-11T00:53:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+export default class DrawSteelTokenDocument extends TokenDocument {
+  /**
+   * Is the token's movement currently shifting?
+   * @type {boolean}
+   */
+  get isShifting() {
+    return false; // TODO
+  }
+
+  /* -------------------------------------------------- */
+
+  /**
+   * Get hostile tokens within range of movement.
+   * @param {Point[]} [points]                An array of points describing a segment of movement.
+   * @param {object} [options]
+   * @param {boolean} [options.ignoreFirst]   Ignore any tokens that were in range of the first point.
+   * @returns {DrawSteelTokenDocument[]}      Hostile tokens.
+   */
+  getHostileTokensFromPoints(points = [], {ignoreFirst = false} = {}) {
path: src/module/documents/token.mjs
user: login: krbz999
body: What do the rules state happens if you move in and out of range of an enemy repeatedly on a single turn?
created_at: 2025-03-11T00:57:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+export default class DrawSteelTokenDocument extends TokenDocument {
+  /**
+   * Is the token's movement currently shifting?
+   * @type {boolean}
+   */
+  get isShifting() {
+    return false; // TODO
+  }
+
+  /* -------------------------------------------------- */
+
+  /**
+   * Get hostile tokens within range of movement.
+   * @param {Point[]} [points]                An array of points describing a segment of movement.
+   * @param {object} [options]
+   * @param {boolean} [options.ignoreFirst]   Ignore any tokens that were in range of the first point.
+   * @returns {DrawSteelTokenDocument[]}      Hostile tokens.
+   */
+  getHostileTokensFromPoints(points = [], {ignoreFirst = false} = {}) {
path: src/module/documents/token.mjs
user: login: JPMeehan
body: Looks like you could take multiple free strikes if you move away, back in, then away again.
created_at: 2025-03-11T01:04:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: JPMeehan
created_at: 2025-03-08T21:01:50Z
body: _does_ data/canvas make sense as a folder structure? Feels like it should just directly be `canvas`.
author_association: CONTRIBUTOR
user: login: krbz999
created_at: 2025-03-08T21:49:57Z
body: > _does_ data/canvas make sense as a folder structure? Feels like it should just directly be `canvas`.

Not really, but most of everything else was there already. I've personally started mimicking Core's structure in all module exports and replicating it nearly 1-to-1 with the folder structure.
author_association: MEMBER
user: login: JPMeehan
created_at: 2025-03-08T21:52:16Z
body: Part of the reason I'm asking is I would not be surprised if we end up also doing custom measured templates. I agree on mirroring core's structure in the exports, we're fortunately unburdened by migrations.
author_association: CONTRIBUTOR
user: login: krbz999
created_at: 2025-03-10T19:52:45Z
body: I think this works well enough to get reviewed. The remaining points can be follow-ups.
author_association: MEMBER
user: login: krbz999
created_at: 2025-03-11T01:48:28Z
body: By golly I've done it.

![image](https://github.com/user-attachments/assets/21d8ee8b-ed24-4611-8191-eac7aa41d102)

author_association: MEMBER