Skip to content

Commit

Permalink
templates: Added square rotation correction
Browse files Browse the repository at this point in the history
Resolves #327
  • Loading branch information
flamewave000 committed Mar 8, 2022
1 parent da75b03 commit 2074690
Show file tree
Hide file tree
Showing 12 changed files with 149 additions and 60 deletions.
Binary file added .assets/df-templates/grid-intersect-snapping.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .assets/df-templates/square-rotate-core.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .assets/df-templates/square-rotate-corrected.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions df-templates/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# DragonFlagon Template Enhancements

## Release 1.1.0 (2022-03-08)
- **NEW #327:** Square Templates do not keep their shape when rotating. This will correct the rotation so that a square template maintains its shape while rotating around the template's origin point.

## Release 1.0.1 (2022-03-07)
- **FIX #325:** Token targeting will no longer be affected by token image scale.
- **FIX #323:** Intersection snapping will no longer bypass setting when placing a spell template in D&D5e.
Expand Down
20 changes: 19 additions & 1 deletion df-templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Various enhancements to the FoundryVTT Templates layer. This brings different fo

## Auto-Target Tokens with Template

If enabled, templates will automatically mark tokens that are inside the template area of effect as "Targetted". This is very useful for quickly placing spell templates and be able to apply the spells affect to the targeted tokens.
If enabled, templates will automatically mark tokens that are inside the template area of effect as "Targetted". This is very useful for quickly placing spell templates and be able to apply the spells affect to the targeted tokens. Auto-targeting also works on Gridless scenes by generating a series of points over each token and detecting if any of the token's points are within the template area. The number of points used per grid square is configurable in the settings.

![Auto-Target Tokens](../.assets/df-templates/auto-target.gif)

Expand All @@ -20,6 +20,24 @@ If enabled, templates will now actively update the grid highlighting and auto-ta

![Template Preview](../.assets/df-templates/template-preview.gif)

## Snap to Grid Intersections

If enabled, template origins will snap to grid intersections. Some rules systems (such as D&D 5e) state that spell templates should originate from a grid intersection.

![Grid Intersection Snapping](../.assets/df-templates/grid-intersect-snapping.gif)

## Custom Angle Snaps

FoundryVTT defaults to 24 macro snap points and 72 micro snap points when rotating a template. These can sometimes bog down combat when some players may spend too much time trying to rotate the template perfectly. This feature allows you to adjust the number of macro snap points available when rotating a template with the mouse wheel, as well as the multiplier used to generate the micro snap points. I recommend using 8 macro snap points with a micro multiplier of 2 (16 micro snaps) for simplest rotation while not limiting players too much.

## Square Template Rotation Correction

The core Square Template is hard fixed to the X,Y axes and cannot be rotated. Rotating the template simply changes the shape of the template, which is unnecessary since the Ray template can provide an easier way to create a rectangle. If enabled, square templates will stay perfectly square and rotate that square around the origin point.

|FoundryVTT Core Behaviour|Corrected Behaviour|Corrected Behaviour<br>for D&D 5e|
|:-:|:-:|:-:|
|![Core Behaviour](../.assets/df-templates/square-rotate-core.gif)|![Corrected Behaviour](../.assets/df-templates/square-rotate-corrected.gif)|![Corrected Behaviour for D&D 5e](../.assets/df-templates/square-rotate-corrected-5e.gif)|

## D&D 5e Style Templates

The D&D 5e PHB states that an space touched by a spell's shape is affected, with the exception of Circular attacks (Sphere/Circle) in-which the square's center must be within the area of effect to targetted. Foundry by default instead uses requires the center of a square to be inside for ALL measure templates to be targetted.
Expand Down
4 changes: 3 additions & 1 deletion df-templates/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
"SnapIntersectName": "Snap templates to grid intersections",
"SnapIntersectHint": "Some rules systems (such as D&D 5e) state that spell templates should originate from a grid intersection.",
"GridlessPointResolutionName": "Auto-Target Gridless Resolution (Default: 3)",
"GridlessPointResolutionHint": "How many points along each axis to generate for auto-targetting in gridless scenes. A higher number might cause slower performance with lots of tokens in a scene.",
"GridlessPointResolutionHint": "How many points along each axis to generate for auto-targetting in gridless scenes (Example: for 3, there will be 3 x 3 = 9 points over each square). A higher number might cause slower performance with lots of tokens in a scene.",
"PreviewName": "Preview Template Highlight/Targeting",
"PreviewHint": "Show the grid highlight and perform auto-targeting when moving or creating a template.",
"SquareRotateName": "Fix Square Template Rotation",
"SquareRotateHint": "Square Templates do not keep their shape when rotating. This will correct the rotation so that a square template maintains its shape while rotating around the template's origin point.",
"DebugName": "[DEBUG Only] Display Auto-Target Point Grids and Test Grids",
"DebugHint": "For the purposes of debugging the auto-targetting feature, this option will display the points used for detecting token targeting in a gridless scene. Or will display the entire test zone for grid highlighting.",
"Patch5e_Name": "Use D&D 5e Style Templates",
Expand Down
2 changes: 1 addition & 1 deletion df-templates/module.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "df-templates",
"version": "1.0.1",
"version": "1.1.0",
"title": "DF Template Enhancements",
"description": "Enhanced templates for different types of grid targetting.",
"author": "flamewave000#0001",
Expand Down
34 changes: 17 additions & 17 deletions df-templates/src/LineToBoxCollision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,25 @@ enum OutCode {
* https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
*/
export default class LineToBoxCollision {
private static _computeOutCode(x: number, y: number, bounds: { xMin: number, xMax: number, yMin: number, yMax: number }): OutCode {
private static _computeOutCode(x: number, y: number, bounds: { left: number, right: number, top: number, bottom: number }): OutCode {
let code: OutCode;
code = OutCode.INSIDE; // initialised as being inside of [[clip window]]
if (x <= bounds.xMin) // to the left of clip window
if (x <= bounds.left) // to the left of clip window
code |= OutCode.LEFT;
else if (x >= bounds.xMax) // to the right of clip window
else if (x >= bounds.right) // to the right of clip window
code |= OutCode.RIGHT;
if (y <= bounds.yMin) // below the clip window
if (y <= bounds.top) // below the clip window
code |= OutCode.BOTTOM;
else if (y >= bounds.yMax) // above the clip window
else if (y >= bounds.bottom) // above the clip window
code |= OutCode.TOP;
return code;
}

// Cohen–Sutherland clipping algorithm clips a line from
// P0 = (x0, y0) to P1 = (x1, y1) against a rectangle with
// diagonal from (xmin, ymin) to (xmax, ymax).
// diagonal from (left, top) to (right, bottom).
static cohenSutherlandLineClipAndDraw(x0: number, y0: number, x1: number, y1: number,
bounds: { xMin: number, xMax: number, yMin: number, yMax: number }): boolean {
bounds: { left: number, right: number, top: number, bottom: number }): boolean {
// compute outcodes for P0, P1, and whatever point lies outside the clip rectangle
let outcode0: OutCode = this._computeOutCode(x0, y0, bounds);
let outcode1: OutCode = this._computeOutCode(x1, y1, bounds);
Expand All @@ -56,22 +56,22 @@ export default class LineToBoxCollision {
// Now find the intersection point;
// use formulas:
// slope = (y1 - y0) / (x1 - x0)
// x = x0 + (1 / slope) * (ym - y0), where ym is ymin or ymax
// y = y0 + slope * (xm - x0), where xm is xmin or xmax
// x = x0 + (1 / slope) * (ym - y0), where ym is top or bottom
// y = y0 + slope * (xm - x0), where xm is left or right
// No need to worry about divide-by-zero because, in each case, the
// outcode bit being tested guarantees the denominator is non-zero
if (outcodeOut & OutCode.TOP) { // point is above the clip window
x = x0 + (x1 - x0) * (bounds.yMax - y0) / (y1 - y0);
y = bounds.yMax - 1;
x = x0 + (x1 - x0) * (bounds.bottom - y0) / (y1 - y0);
y = bounds.bottom - 1;
} else if (outcodeOut & OutCode.BOTTOM) { // point is below the clip window
x = x0 + (x1 - x0) * (bounds.yMin - y0) / (y1 - y0);
y = bounds.yMin + 1;
x = x0 + (x1 - x0) * (bounds.top - y0) / (y1 - y0);
y = bounds.top + 1;
} else if (outcodeOut & OutCode.RIGHT) { // point is to the right of clip window
y = y0 + (y1 - y0) * (bounds.xMax - x0) / (x1 - x0);
x = bounds.xMax - 1;
y = y0 + (y1 - y0) * (bounds.right - x0) / (x1 - x0);
x = bounds.right - 1;
} else if (outcodeOut & OutCode.LEFT) { // point is to the left of clip window
y = y0 + (y1 - y0) * (bounds.xMin - x0) / (x1 - x0);
x = bounds.xMin + 1;
y = y0 + (y1 - y0) * (bounds.left - x0) / (x1 - x0);
x = bounds.left + 1;
}

// Now we move outside point to intersection point to clip
Expand Down
57 changes: 57 additions & 0 deletions df-templates/src/SquareTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SETTINGS from "../../common/Settings";

export default class SquareTemplate {
static readonly FIX_ROTATION_PREF = 'fix-square-rotation';

static init() {
SETTINGS.register(SquareTemplate.FIX_ROTATION_PREF, {
config: true,
scope: 'world',
name: 'DF_TEMPLATES.SquareRotateName',
hint: 'DF_TEMPLATES.SquareRotateHint',
type: Boolean,
default: true,
onChange: (toggled) => toggled ? SquareTemplate.patch() : SquareTemplate.unpatch()
});
if (SETTINGS.get(SquareTemplate.FIX_ROTATION_PREF))
SquareTemplate.patch();
}

private static patch() {
libWrapper.register(SETTINGS.MOD_NAME, 'MeasuredTemplate.prototype._getRectShape', SquareTemplate.MeasuredTemplate_getRectShape, 'OVERRIDE');
libWrapper.register(SETTINGS.MOD_NAME, 'MeasuredTemplate.prototype._refreshRulerText', SquareTemplate.MeasuredTemplate_refreshRulerText, 'WRAPPER');
}
private static unpatch() {
libWrapper.unregister(SETTINGS.MOD_NAME, 'MeasuredTemplate.prototype._getRectShape', false);
libWrapper.unregister(SETTINGS.MOD_NAME, 'MeasuredTemplate.prototype._refreshRulerText', false);
}

static MeasuredTemplate_getRectShape(this: MeasuredTemplate, direction: number, distance: number, adjustForRoundingError = false): PIXI.Polygon {
// Generate a rotation matrix to apply the rect against. The base rotation must be rotated
// CCW by 45° before applying the real direction rotation.
const matrix = PIXI.Matrix.IDENTITY.rotate((-45 * (Math.PI / 180)) + direction);
// If the shape will be used for collision, shrink the rectangle by a fixed EPSILON amount to account for rounding errors
const EPSILON = adjustForRoundingError ? 0.0001 : 0;
// Use simple Pythagoras to calculate the square's size from the diagonal "distance".
const size = Math.sqrt((distance * distance) / 2) - EPSILON;
// Create the square's 4 corners with origin being the Top-Left corner and apply the
// rotation matrix against each.
const topLeft = matrix.apply(new PIXI.Point(EPSILON, EPSILON));
const topRight = matrix.apply(new PIXI.Point(size, EPSILON));
const botLeft = matrix.apply(new PIXI.Point(EPSILON, size));
const botRight = matrix.apply(new PIXI.Point(size, size));
// Inject the vector data into a Polygon object to create a closed shape.
return new PIXI.Polygon([topLeft.x, topLeft.y, topRight.x, topRight.y, botRight.x, botRight.y, botLeft.x, botLeft.y, topLeft.x, topLeft.y]);
}

private static MeasuredTemplate_refreshRulerText(this: MeasuredTemplate, wrapped: () => void): void {
wrapped();
// Overwrite the text for the "rect" type
if (this.data.t === "rect") {
// Use simple Pythagoras to calculate the square's size from the diagonal "distance".
const size = Math.sqrt((this.data.distance * this.data.distance) / 2).toFixed(1);
const text = `${size}${canvas.scene.data.gridUnits}`;
(<any>this).hud.ruler.text = text;
}
}
}
87 changes: 47 additions & 40 deletions df-templates/src/TemplateTargeting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,8 @@ export default class TemplateTargeting {
}

// Get number of rows and columns
const nr = Math.ceil(((this.data.distance * 1.5) / d.distance) / (d.size / grid.h));
const nc = Math.ceil(((this.data.distance * 1.5) / d.distance) / (d.size / grid.w));
const rowCount = Math.ceil(((this.data.distance * 1.5) / d.distance) / (d.size / grid.h));
const colCount = Math.ceil(((this.data.distance * 1.5) / d.distance) / (d.size / grid.w));

// Get the offset of the template origin relative to the top-left grid space
const [tx, ty] = canvas.grid.getTopLeft(this.data.x, this.data.y);
Expand Down Expand Up @@ -363,8 +363,8 @@ export default class TemplateTargeting {
];
};
// Identify grid coordinates covered by the template Graphics
for (let r = -nr; r < nr; r++) {
for (let c = -nc; c < nc; c++) {
for (let r = -rowCount; r < rowCount; r++) {
for (let c = -colCount; c < colCount; c++) {
const [gx, gy] = canvas.grid.grid.getPixelsFromGridPosition(row0 + r, col0 + c);
const testX = gx + hx;
const testY = gy + hy;
Expand Down Expand Up @@ -392,32 +392,47 @@ export default class TemplateTargeting {
break;
}
case "rect": {
const rect = this._getRectShape(direction, distance);
rect.x += this.data.x;
rect.y += this.data.y;
// The normalized rectangle always adds 1 to the width and height
rect.width -= 1;
rect.height -= 1;
// Standard 2D Box Collision detection
contains = !(rect.left >= testRect.right || rect.right <= testRect.left
|| rect.top >= testRect.bottom || rect.bottom <= testRect.top);
const rect = (this as any)._getRectShape(direction, distance, true);
if (rect instanceof PIXI.Polygon) {
contains = ((r === 0) && (c === 0) && isCenter) || this.shape.contains(testX - this.data.x, testY - this.data.y);
if (contains) break;
/* Rectangle vertex data order
A1───▶B1
▲ │
│ ▼
A2◀───B2
*/
// Translate points to the position of the MeasuredTemplate and map the points to the dataset
[ax1, ay1, bx1, by1, bx2, by2, ax2, ay2] = rect.points.map((e, i) => e + (i % 2 ? this.data.y : this.data.x));
// check the top line
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax1, ay1, bx1, by1, testRect)
// check the right line
|| LineToBoxCollision.cohenSutherlandLineClipAndDraw(bx1, by1, bx2, by2, testRect)
// check the bottom line
|| LineToBoxCollision.cohenSutherlandLineClipAndDraw(bx2, by2, ax2, ay2, testRect)
// check the left line
|| LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax2, ay2, ax1, ay1, testRect);
} else {
rect.x += this.data.x;
rect.y += this.data.y;
// The normalized rectangle always adds 1 to the width and height
rect.width -= 1;
rect.height -= 1;
// Standard 2D Box Collision detection
contains = !(rect.left >= testRect.right || rect.right <= testRect.left
|| rect.top >= testRect.bottom || rect.bottom <= testRect.top);
}
break;
}
case "cone": {
contains = ((r === 0) && (c === 0) && isCenter) || this.shape.contains(testX - this.data.x, testY - this.data.y);
if (contains) break;
const bounds = {
xMin: testRect.left,
xMax: testRect.right,
yMin: testRect.top,
yMax: testRect.bottom
};
generateConeData();
// check the top line
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax1, ay1, bx1, by1, bounds);
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax1, ay1, bx1, by1, testRect);
if (contains) break;
// check the bottom line
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax2, ay2, bx2, by2, bounds);
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax2, ay2, bx2, by2, testRect);
if (contains) break;
// check the end-cap
if (isRound) {
Expand Down Expand Up @@ -461,39 +476,31 @@ export default class TemplateTargeting {
contains = testAngle();
}
} else
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(bx1, by1, bx2, by2, bounds);
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(bx1, by1, bx2, by2, testRect);
break;
}
case "ray": {
contains = ((r === 0) && (c === 0) && isCenter) || this.shape.contains(testX - this.data.x, testY - this.data.y);
if (contains) break;
const bounds = {
xMin: testRect.left,
xMax: testRect.right,
yMin: testRect.top,
yMax: testRect.bottom
};
generateRayData();
// check the top line
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax1, ay1, bx1, by1, bounds);
if (contains) break;
// check the bottom line
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax2, ay2, bx2, by2, bounds);
if (contains) break;
// check the left endcap line
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax1, ay1, ax2, ay2, bounds);
if (contains) break;
// check the right endcap line
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(bx1, by1, bx2, by2, bounds);
if (contains) break;
contains = LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax1, ay1, bx1, by1, testRect)
// check the bottom line
|| LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax2, ay2, bx2, by2, testRect)
// check the left endcap line
|| LineToBoxCollision.cohenSutherlandLineClipAndDraw(ax1, ay1, ax2, ay2, testRect)
// check the right endcap line
|| LineToBoxCollision.cohenSutherlandLineClipAndDraw(bx1, by1, bx2, by2, testRect);
break;
}
}

const DEBUG = SETTINGS.get('template-debug');
if (!DEBUG && !contains) continue;
if (DEBUG)
if (DEBUG) {
grid.grid.highlightGridPosition(hl, { x: gx, y: gy, border, color: contains ? 0x00FF00 : 0xFF0000 });
if (!contains) continue;
}
else
grid.grid.highlightGridPosition(hl, { x: gx, y: gy, border, color: <number>color });

Expand Down
2 changes: 2 additions & 0 deletions df-templates/src/df-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import SETTINGS from "../../common/Settings";
import AngleSnaps from "./AngleSnaps";
import DnD5eAbilityTemplateHandlers from "./DnD5eAbilityTemplateHandlers";
import SnapIntersect from "./SnapIntersect";
import SquareTemplate from "./SquareTemplate";
import TemplateTargeting from "./TemplateTargeting";

SETTINGS.init('df-templates');
Expand All @@ -10,6 +11,7 @@ Hooks.once('init', function () {
TemplateTargeting.init();
SnapIntersect.init();
AngleSnaps.init();
SquareTemplate.init();

// DEBUG SETTINGS
SETTINGS.register('template-debug', {
Expand Down

0 comments on commit 2074690

Please sign in to comment.