diff --git a/README.md b/README.md
index 43a0c99..5a4bec8 100644
--- a/README.md
+++ b/README.md
@@ -33,12 +33,14 @@ No more will you suffer the lethargic slog of transporting tokens across the can
## Credits
+* Kerrec Snowmane for his implementation of the hooks
* League of Extraordinary FoundryVTT Developers
* [Easing Functions Cheat Sheet](https://easings.net/) ([GitHub](https://github.com/ai/easings.net)) - Copyright © 2020 Andrey Sitnik and Ivan Solovev
## Download Here:
-`https://github.com/FantasyCalendar/FoundryVTT-TokenEase/releases/latest/download/module.json`
+`https://github.com/fantasycalendar/FoundryVTT-TokenEase/releases/latest/download/module.json`
+
## Module Settings
@@ -80,9 +82,15 @@ If enabled, this will make moving tokens with the movement keys (arrow keys, etc
We do not recommend enabling this, as the movement distance is so short when using movement keys.
+## Token Settings
+
+Each token can override the module's default movement animation settings.
+
+![Token settings](images/token_settings.png)
+
## API
-### Changes to `Token.Document.Update`
+### Changes to `TokenDocument#update`
This accepts an additional optional parameter in its second parameter:
@@ -148,4 +156,180 @@ function linear(x) {
* `easeInOutElastic`
* `easeInBounce`
* `easeOutBounce`
- * `easeInOutBounce`
\ No newline at end of file
+ * `easeInOutBounce`
+
+## Hooks
+
+### preTokenAnimate
+
+Called before a token has started animating.
+
+You can `return false` to interrupt the movement.
+
+| Param | Type |
+| --- | --- |
+| context | Token
|
+| data | object
|
+
+
+
+Example hook
+
+This hook injects more code into the animate callback (ontick).
+The injected code adds a PIXI Graphics circle to the canvas every 100 or so pixels.
+
+```js
+Hooks.once('preTokenAnimate', (token, data) => {
+ const tokenCenter = token.w/2;
+ let lastPos = {
+ x: token.data.x,
+ y: token.data.y
+ }
+ data.ontick = (dt, anim) => {
+ token._onMovementFrame(dt, anim, data.config);
+ const currLoc = {
+ x: token.data.x + tokenCenter,
+ y: token.data.y + tokenCenter,
+ }
+ if ( Math.abs(lastPos.x - token.data.x) > 100 ||
+ Math.abs(lastPos.y - token.data.y) > 100
+ ) {
+ const poopDot = new PIXI.Graphics();
+ poopDot.beginFill(0xe74c3c);
+ poopDot.drawCircle(currLoc.x, currLoc.y, 10);
+ poopDot.endFill();
+ canvas.background.addChild(poopDot);
+ lastPos = {
+ x: token.data.x,
+ y: token.data.y
+ }
+ }
+ }
+});
+```
+
+This was a test to inject a function into the tokenAnimate callback that is added to the canvas ticker.
+
+It will just generate a console log message on every tick.
+
+```js
+Hooks.once('preTokenAnimate', (token, data) => {
+ const myNewVar = "YES!";
+ data.ontick = (dt, anim) => {
+ token._onMovementFrame(dt, anim, data.config);
+ console.log("Have I added this console.log to the ontick function? ", myNewVar);
+ }
+ console.log("preTokenAnimate hook has fired!");
+});
+```
+
+This hook will multiply the movement speed by 3. It does this by dividing the duration by 3.
+
+```js
+Hooks.once('preTokenAnimate', (token, data) => {
+ // This hook will make the token faster than normal.
+ data.duration /= 3;
+})
+```
+
+
+
+### preTokenChainMove
+
+Called before a token has started animating along several waypoints.
+
+You can `return false` to interrupt the movement.
+
+| Param | Type |
+| --- | --- |
+| token | Token
|
+| ruler | Ruler
|
+
+
+
+Example hook
+
+This hook alters the waypoints of a token movement. The intent was to have the token move between waypoints that I would enter in a zig-zag pattern.
+
+The logic here is very wrong, but it still demonstrates that the waypoints can be changed via the hook.
+
+```js
+Hooks.once('preTokenChainMove', (token, ruler) => {
+ for (let i=1; i < Ruler.waypoints.length - 1; i++) {
+ ruler.waypoints[i].x = (ruler.waypoints[i].x + ruler.waypoints[i+1].x) / 2;
+ ruler.waypoints[i].y = (ruler.waypoints[i].y + ruler.waypoints[i+1].y) / 2;
+ }
+})
+```
+
+This hook will cause token movement to be cancelled before it begins. This applies to movements entered via waypoints (hold ctrl and click path). The token will emote text in a chat bubble.
+
+```js
+Hooks.once('preTokenChainMove', (token) => {
+ const bubble = new ChatLog;
+ bubble.processMessage("Whoa! Do you think I can remember all those waypoints!?");
+ return false;
+})
+```
+
+
+
+### tokenAnimationComplete
+
+Called when a token's animation completed playing entirely.
+
+| Param | Type |
+| --- | --- |
+| token | Token
|
+
+
+
+Example hook
+
+This hook will emote text in a chat bubble, when an animation is complete.
+
+```js
+Hooks.once('tokenAnimationComplete', (token) => {
+ const bubble = new ChatLog;
+ bubble.processMessage("If you didn't drag me down into these places, I wouldn't have to strain myself running in full armor to get past these ridiculous obstacles you can't seem to avoid...")
+})
+```
+
+
+
+### tokenAnimationTerminated
+
+Called when a token's animation was terminated early, without having finished.
+
+| Param | Type |
+| --- | --- |
+| attributes | array
|
+
+
+
+Example hook
+
+This hook will spit out animation data at time of termination to the console.
+
+It will also set the token's position at the spot where the animation was terminated.
+
+Core sets the token position before animation starts, so terminating an animation in core sends the token to the end point.
+
+```js
+Hooks.once('tokenAnimationTerminated', (data) => {
+ console.log("Token animation terminated early: ", data);
+ ui.notifications.info("Notification triggered off the new 'tokenAnimationTerminated' hook. See console for animation data!");
+ const token = data[0].parent;
+ const isToX = data.filter(d => d.attribute === "x");
+ const isToY = data.filter(d => d.attribute === "y");
+
+ const wasX = isToX.length ? (isToX[0].to - (isToX[0].delta - isToX[0].done)) : token.data.x;
+ const wasY = isToY.length ? (isToY[0].to - (isToY[0].delta - isToY[0].done)) : token.data.y;
+
+ token.position.set(wasX, wasY);
+
+ token.document.update({_id: token.id, x: wasX, y: wasY}, {animate: false});
+})
+```
+
+
\ No newline at end of file
diff --git a/images/token_settings.png b/images/token_settings.png
new file mode 100644
index 0000000..e4c6b70
Binary files /dev/null and b/images/token_settings.png differ
diff --git a/languages/en.json b/languages/en.json
index 3f64fc8..db87168 100644
--- a/languages/en.json
+++ b/languages/en.json
@@ -1,14 +1,16 @@
{
"TOKEN-EASE.speed-title": "Token Movement Speed",
- "TOKEN-EASE.speed-description": "Sets the default animation speed of moving tokens, in spaces per second (10 is Foundry default).",
+ "TOKEN-EASE.speed-description": "Sets the default animation speed, in spaces per second (10 is Foundry default).",
"TOKEN-EASE.duration-title": "Token Movement Duration",
- "TOKEN-EASE.duration-description": "Sets the default animation duration for token movement (in milliseconds). This overrides Token Speed, and causes tokens to reach their destination in a set amount of time, regardless of distance.",
+ "TOKEN-EASE.duration-description": "Sets the default animation duration for movement (in milliseconds). This overrides Token Speed, and reaches the destination in a set amount of time, regardless of distance. Setting this to 0 will utilize speed instead.",
"TOKEN-EASE.ease-title": "Token Movement Ease",
- "TOKEN-EASE.ease-description": "Sets the type of ease used by the movement animation on tokens.",
+ "TOKEN-EASE.ease-description": "Sets the type of ease used by the movement animation.",
"TOKEN-EASE.ease-type-title": "Ease In/Out",
"TOKEN-EASE.ease-type-description": "Sets the type of easing to use at the start/end of the animation. If Token Movement Ease is set to Linear, this has no impact.",
"TOKEN-EASE.movement-keys-title": "Play animation on keypad movement",
"TOKEN-EASE.movement-keys-description": "If enabled, this will make moving tokens with the movement keys (arrow keys, etc) to play the animations configured above. We do not recommend enabling this, as the movement distance is so short when using movement keys.",
- "TOKEN-EASE.InstantMovement": "Instant Movement Hotkey (hold)"
+ "TOKEN-EASE.override": "Override Token-Ease default settings for this token",
+
+ "TOKEN-EASE.instant-movement": "Instant Movement Hotkey (hold)"
}
\ No newline at end of file
diff --git a/module.json b/module.json
index 5d15998..eccc1cd 100644
--- a/module.json
+++ b/module.json
@@ -1,38 +1,48 @@
{
- "name": "token-ease",
- "title": "Token Ease",
- "description": "This module extends native Foundry behavior to introduce easing, custom movement speed, and movement duration to tokens.",
- "author": "Wasp (Wasp#2005)",
- "authors": [],
- "url": "https://github.com/fantasycalendar/FoundryVTT-TokenEase",
- "flags": {},
- "version": "This is auto replaced",
- "minimumCoreVersion": "0.8.6",
- "compatibleCoreVersion": "9.000",
- "scripts": [],
- "esmodules": [
- "/scripts/module.js"
- ],
- "styles": [],
- "languages": [
- {
- "lang": "en",
- "name": "English",
- "path": "/languages/en.json"
- }
- ],
- "packs": [],
- "system": [],
- "dependencies": [
- {
- "name": "lib-wrapper",
- "type": "module"
- }
- ],
- "socket": false,
- "manifest": "https://github.com/fantasycalendar/FoundryVTT-TokenEase/releases/latest/download/module.json",
- "download": "https://github.com/fantasycalendar/FoundryVTT-TokenEase/releases/download/1.0.4/module.zip",
- "protected": false,
- "coreTranslation": false,
- "library": "false"
+ "name": "token-ease",
+ "title": "Token Ease",
+ "description": "This module extends native Foundry behavior to introduce easing, custom movement speed, and movement duration to tokens.",
+ "authors": [
+ {
+ "name": "Wasp",
+ "url": "https://github.com/Haxxer",
+ "discord": "Wasp#2005"
+ },
+ {
+ "name": "Kerrec Snowmane",
+ "url": "https://github.com/ggagnon76",
+ "discord": "Kerrec Snowmane#5264"
+ }
+ ],
+ "url": "https://github.com/fantasycalendar/FoundryVTT-TokenEase",
+ "flags": {},
+ "version": "This is auto replaced",
+ "minimumCoreVersion": "0.8.6",
+ "compatibleCoreVersion": "9.000",
+ "scripts": [],
+ "esmodules": [
+ "/scripts/module.js"
+ ],
+ "styles": [],
+ "languages": [
+ {
+ "lang": "en",
+ "name": "English",
+ "path": "/languages/en.json"
+ }
+ ],
+ "packs": [],
+ "system": [],
+ "dependencies": [
+ {
+ "name": "lib-wrapper",
+ "type": "module"
+ }
+ ],
+ "socket": false,
+ "manifest": "https://github.com/fantasycalendar/FoundryVTT-TokenEase/releases/latest/download/module.json",
+ "download": "https://github.com/fantasycalendar/FoundryVTT-TokenEase/releases/download/1.0.4/module.zip",
+ "protected": false,
+ "coreTranslation": false,
+ "library": "false"
}
\ No newline at end of file
diff --git a/scripts/constants.js b/scripts/constants.js
new file mode 100644
index 0000000..c338e4e
--- /dev/null
+++ b/scripts/constants.js
@@ -0,0 +1,7 @@
+const CONSTANTS = {
+ MODULE_NAME: "token-ease",
+ MOVEMENT_FLAG: "movement",
+ ANIMATION_FLAG: "animation"
+}
+
+export default CONSTANTS;
\ No newline at end of file
diff --git a/scripts/lib/libWrapper/shim.js b/scripts/lib/libWrapper/shim.js
index edd5d3b..5d248e4 100644
--- a/scripts/lib/libWrapper/shim.js
+++ b/scripts/lib/libWrapper/shim.js
@@ -7,7 +7,7 @@
// A shim for the libWrapper library
export let libWrapper = undefined;
-export const VERSIONS = [1,11,0];
+export const VERSIONS = [1,12,1];
export const TGT_SPLIT_RE = new RegExp("([^.[]+|\\[('([^'\\\\]|\\\\.)+?'|\"([^\"\\\\]|\\\\.)+?\")\\])", 'g');
export const TGT_CLEANUP_RE = new RegExp("(^\\['|'\\]$|^\\[\"|\"\\]$)", 'g');
@@ -27,7 +27,7 @@ Hooks.once('init', () => {
static get MIXED() { return 'MIXED' };
static get OVERRIDE() { return 'OVERRIDE' };
- static register(package_id, target, fn, type="MIXED", {chain=undefined}={}) {
+ static register(package_id, target, fn, type="MIXED", {chain=undefined, bind=[]}={}) {
const is_setter = target.endsWith('#set');
target = !is_setter ? target : target.slice(0, -4);
const split = target.match(TGT_SPLIT_RE).map((x)=>x.replace(/\\(.)/g, '$1').replace(TGT_CLEANUP_RE,''));
@@ -51,10 +51,13 @@ Hooks.once('init', () => {
if(descriptor) break;
iObj = Object.getPrototypeOf(iObj);
}
- if(!descriptor || descriptor?.configurable === false) throw `libWrapper Shim: '${target}' does not exist, could not be found, or has a non-configurable descriptor.`;
+ if(!descriptor || descriptor?.configurable === false) throw new Error(`libWrapper Shim: '${target}' does not exist, could not be found, or has a non-configurable descriptor.`);
let original = null;
- const wrapper = (chain ?? (type.toUpperCase?.() != 'OVERRIDE' && type != 3)) ? function() { return fn.call(this, original.bind(this), ...arguments); } : function() { return fn.apply(this, arguments); };
+ const wrapper = (chain ?? (type.toUpperCase?.() != 'OVERRIDE' && type != 3)) ?
+ function(...args) { return fn.call(this, original.bind(this), ...bind, ...args); } :
+ function(...args) { return fn.call(this, ...bind, ...args); }
+ ;
if(!is_setter) {
if(descriptor.value) {
@@ -67,7 +70,7 @@ Hooks.once('init', () => {
}
}
else {
- if(!descriptor.set) throw `libWrapper Shim: '${target}' does not have a setter`;
+ if(!descriptor.set) throw new Error(`libWrapper Shim: '${target}' does not have a setter`);
original = descriptor.set;
descriptor.set = wrapper;
}
diff --git a/scripts/module.js b/scripts/module.js
index 927cc55..6c08a1c 100644
--- a/scripts/module.js
+++ b/scripts/module.js
@@ -1,229 +1,76 @@
import { libWrapper } from "./lib/libWrapper/shim.js";
import configure_settings, { configure_hotkeys, keyboardState } from "./settings.js";
+import * as Wrapper from "./wrappers.js"
+import CONSTANTS from "./constants.js";
+import TokenEaseConfig from "./token-ease-config-app.js";
Hooks.once('init', async function() {
- patch_TokenSetPosition();
- patch_TokenAnimateMovement();
- add_CanvasAnimation_Animate();
- patch_AnimatePromise();
- patch_AnimateFrame();
+
+ Wrapper.coreAnimateMovement();
+ Wrapper.coreAnimateFrame();
+ Wrapper.coreAnimatePromise();
+ Wrapper.coreTerminateAnimation();
+ Wrapper.coreTokenAnimateLinear();
+ Wrapper.coreRulerMoveToken();
+
+
console.log("Token Ease | Patched core functions");
configure_settings();
configure_hotkeys();
console.log("Token Ease | Ready to (pl)ease!");
});
-Hooks.once('ready', async function() {
-});
+Hooks.on('preUpdateToken', (token, changes, data) => {
-function patch_TokenSetPosition() {
- libWrapper.register(
- "token-ease",
- "Token.prototype.setPosition",
- async function setPosition(x, y, options) {
-
- let { animate, animation } = foundry.utils.mergeObject(options, {
- animate: true,
- animation: {}
- })
-
- if(this.owner) {
- if (keyboardState.instantMove) {
- animate = false;
- }
- }
+ // If position hasn't changed, or animate is false, don't change anything.
+ if(!(changes.x || changes.y) || data.animate === false) return;
- // Create a Ray for the requested movement
- let origin = this._movement ? this.position : this._validPosition,
- target = {x: x, y: y},
- isVisible = this.isVisible;
+ // If the owner of the token is holding down alt, the token will instantly move to the end point
+ if(token.isOwner && keyboardState.instantMove) {
- // Create the movement ray
- let ray = new Ray(origin, target);
+ data.animate = false;
- // Update the new valid position
- this._validPosition = target;
-
- // Record the Token's new velocity
- this._velocity = this._updateVelocity(ray);
-
- // Update visibility for a non-controlled token which may have moved into the controlled tokens FOV
- this.visible = isVisible;
-
- // Conceal the HUD if it targets this Token
- if ( this.hasActiveHUD ) this.layer.hud.clear();
-
- // Either animate movement to the destination position, or set it directly if animation is disabled
- if ( animate ) await this.animateMovement(new Ray(this.position, ray.B), animation);
- else this.position.set(x, y);
-
- // If the movement took a controlled token off-screen, re-center the view
- if (this._controlled && isVisible) {
- let pad = 50;
- let gp = this.getGlobalPosition();
- const sidebarWidth = $("#sidebar").width();
- if ((gp.x < pad) || (gp.x > window.innerWidth - pad - sidebarWidth) || (gp.y < pad) || (gp.y > window.innerHeight - pad)) {
- canvas.animatePan(this.center);
- }
- }
+ }else{
- return this;
-
- },
- "OVERRIDE"
- );
-}
-
-function patch_TokenAnimateMovement(){
-
- libWrapper.register(
- "token-ease",
- "Token.prototype.animateMovement",
- async function animateMovement(...args) {
-
- let [ray, animation] = args;
-
- let speed = animation?.speed || game.settings.get("token-ease", "default-speed");
- let duration = animation?.duration || game.settings.get("token-ease", "default-duration");
- let ease = animation?.ease || game.settings.get("token-ease", "default-ease");
-
- this._movement = ray;
-
- // Move distance is 10 spaces per second
- const s = canvas.dimensions.size;
- speed = s * speed;
- duration = duration ? duration : (ray.distance * 1000) / speed;
+ if(!game.settings.get("token-ease", "animation-on-movement-keys")) {
+ const ray = new Ray(
+ { x: token.data.x, y: token.data.y },
+ { x: changes?.x ?? token.data.x, y: changes?.y ?? token.data.y }
+ );
// If movement distance <= grid size, and play animation on movement keys isn't enabled, revert to foundry default
- let playAnimation = game.settings.get("token-ease", "animation-on-movement-keys");
let smallMovement = Math.max(Math.abs(ray.dx), Math.abs(ray.dy)) <= canvas.grid.size;
- if(smallMovement && !playAnimation && !animation?.speed && !animation?.duration){
- speed = s * 10;
- duration = (ray.distance * 1000) / speed;
- ease = "linear";
+ if (smallMovement && !data?.animation?.speed && !data?.animation?.duration) {
+ setProperty(data, `animation`, {
+ speed: 10,
+ duration: 0,
+ ease: "linear"
+ });
}
+ }
- // Define attributes
- const attributes = [
- { parent: this, attribute: 'x', to: ray.B.x },
- { parent: this, attribute: 'y', to: ray.B.y }
- ];
-
- // Determine what type of updates should be animated
- const emits = this.emitsLight;
- const config = {
- animate: game.settings.get("core", "visionAnimation"),
- source: this._isVisionSource() || emits,
- sound: this._controlled || this.observer,
- forceUpdateFog: emits && !this._controlled && (canvas.sight.sources.size > 0)
- }
+ // Get token default flags, but fall back to default if needed
+ const tokenFlags = token.getFlag(CONSTANTS.MODULE_NAME, CONSTANTS.MOVEMENT_FLAG);
- // Dispatch the animation function
- await CanvasAnimation.animate(attributes, {
- name: this.movementAnimationName,
- context: this,
- duration: duration,
- ontick: (dt, anim) => this._onMovementFrame(dt, anim, config),
- ease: ease
- });
-
- // Once animation is complete perform a final refresh
- if ( !config.animate ) this._animatePerceptionFrame({source: config.source, sound: config.sound});
- this._movement = null;
-
- },
- "OVERRIDE"
- );
-
-}
-
-function add_CanvasAnimation_Animate() {
- CanvasAnimation.prototype.constructor.animate = async function(attributes, {context, name, duration = 1000, ontick, ease} = {}) {
-
- // Prepare attributes
- attributes = attributes.map(a => {
- a.delta = a.to - a.parent[a.attribute];
- a.done = 0;
- a.remaining = Math.abs(a.delta);
- return a;
- }).filter(a => a.delta !== 0);
-
- // Register the request function and context
- context = context || canvas.stage;
-
- // Dispatch the animation request and return as a Promise
- return this._animatePromise(this._animateFrame, context, name, attributes, duration, ontick, ease);
+ // Set a temporary flag for this animation's data
+ setProperty(changes, `flags.${CONSTANTS.MODULE_NAME}.${CONSTANTS.ANIMATION_FLAG}`, {
+ speed: data?.animation?.speed ?? tokenFlags?.speed ?? game.settings.get("token-ease", "default-speed"),
+ duration: data?.animation?.duration ?? tokenFlags?.duration ?? game.settings.get("token-ease", "default-duration"),
+ ease: data?.animation?.ease ?? tokenFlags?.ease ?? game.settings.get("token-ease", "default-ease")
+ });
}
-}
-
-function patch_AnimatePromise() {
- libWrapper.register(
- "token-ease",
- "CanvasAnimation.prototype.constructor._animatePromise",
- async function _animatePromise(fn, context, name, attributes, duration, ontick, ease) {
- if (name) this.terminateAnimation(name);
- let animate;
-
- // Create the animation promise
- const promise = new Promise((resolve, reject) => {
- animate = dt => fn(dt, resolve, reject, attributes, duration, ontick, ease);
- this.ticker.add(animate, context);
- if (name) this.animations[name] = {fn: animate, context, resolve};
- })
- .catch(err => {
- console.error(err)
- })
- .finally(() => {
- this.ticker.remove(animate, context);
- const isCompleted = name && (this.animations[name]?.fn === animate);
- if ( isCompleted ) delete this.animations[name];
- });
+});
- // Store the promise
- if ( name in this.animations ) this.animations[name].promise = promise;
- return promise;
- },
- "OVERRIDE"
- );
-}
-
-function patch_AnimateFrame() {
- libWrapper.register(
- "token-ease",
- "CanvasAnimation.prototype.constructor._animateFrame",
- async function _animateFrame(deltaTime, resolve, reject, attributes, duration, ontick, ease) {
-
- ease = window.easeFunctions[ease] ?? window.easeFunctions["linear"];
-
- let complete = attributes.length === 0;
- let dt = (duration * PIXI.settings.TARGET_FPMS) / deltaTime;
-
- // Update each attribute
- try {
- for (let a of attributes) {
- let da = a.delta / dt;
- a.d = da;
- if (a.remaining < (Math.abs(da) * 1.25)) {
- a.parent[a.attribute] = a.to;
- a.done = a.delta;
- a.remaining = 0;
- complete = true;
- } else {
- a.parent[a.attribute] += da;
- a.done += da;
- a.remaining = Math.abs(a.delta) - Math.abs(a.done);
- a.parent[a.attribute] = (a.to - a.delta) + ease(a.done / a.delta) * a.delta;
- }
- }
- if (ontick) ontick(dt, attributes);
- } catch (err) {
- reject(err);
- }
- // Resolve the original promise once the animation is complete
- if (complete) resolve(true);
- },
- "OVERRIDE"
- );
-}
\ No newline at end of file
+Hooks.on('getTokenConfigHeaderButtons', (app, buttons) => {
+ if(!app.token.isOwner) return;
+ buttons.unshift({
+ class: "configure-token-ease",
+ icon: "fas fa-running",
+ label: "Token Ease",
+ onclick: (ev) => {
+ TokenEaseConfig.show(app.token);
+ }
+ });
+})
\ No newline at end of file
diff --git a/scripts/settings.js b/scripts/settings.js
index 1507e31..31e616c 100644
--- a/scripts/settings.js
+++ b/scripts/settings.js
@@ -86,7 +86,7 @@ export const keyboardState = {
export function configure_hotkeys(){
game.keybindings.register("token-ease", "instantMovement", {
- name: "TOKEN-EASE.InstantMovement",
+ name: "TOKEN-EASE.instant-movement",
editable: [
{ key: "AltLeft" }
],
diff --git a/scripts/token-ease-config-app.js b/scripts/token-ease-config-app.js
new file mode 100644
index 0000000..72a5279
--- /dev/null
+++ b/scripts/token-ease-config-app.js
@@ -0,0 +1,71 @@
+import CONSTANTS from "./constants.js";
+import { easeFunctions } from "./lib/ease.js";
+
+export default class TokenEaseConfig extends FormApplication {
+
+ /**
+ * @param {Object} token
+ */
+ constructor(token) {
+ super();
+ this.token = token;
+ this.data = token.getFlag(CONSTANTS.MODULE_NAME, CONSTANTS.MOVEMENT_FLAG);
+ if(!this.data){
+ this.data = {
+ speed: game.settings.get(CONSTANTS.MODULE_NAME, "default-speed"),
+ duration: game.settings.get(CONSTANTS.MODULE_NAME, "default-duration"),
+ configEase: game.settings.get(CONSTANTS.MODULE_NAME, "default-ease"),
+ configInOut: game.settings.get(CONSTANTS.MODULE_NAME, "ease-type")
+ }
+ }
+ }
+
+ static show(token) {
+ for (let app of Object.values(ui.windows)) {
+ if (app instanceof this && app?.token === token) {
+ return app.render(false, { focus: true });
+ }
+ }
+ return new this(token).render(true);
+ }
+
+ /** @inheritdoc */
+ static get defaultOptions() {
+ return foundry.utils.mergeObject(super.defaultOptions, {
+ title: "Token-Ease Token Overrides",
+ classes: ["sheet", "item-pile-filters-editor"],
+ template: `modules/token-ease/templates/token-ease-config.html`,
+ width: 400,
+ resizable: false
+ });
+ }
+
+ getData(options) {
+ const data = super.getData(options)
+
+ data.settings = this.data;
+
+ const easeChoices = Object.keys(easeFunctions).filter(ease => ease.indexOf("InOut") > -1).map((e) => e.replace("easeInOut", ""));
+ easeChoices.unshift("Linear")
+
+ data.easeChoices = easeChoices;
+
+ return data;
+ }
+
+ async _updateObject(event, formData) {
+
+ if(event.submitter.value === "0") return;
+
+ if(!formData.enabled) {
+ return this.token.unsetFlag(CONSTANTS.MODULE_NAME, CONSTANTS.MOVEMENT_FLAG);
+ }
+
+ formData.ease = formData["configEase"] === "Linear"
+ ? formData["configEase"]
+ : "ease" + formData["configInOut"] + formData["configEase"];
+
+ return this.token.setFlag(CONSTANTS.MODULE_NAME, CONSTANTS.MOVEMENT_FLAG, formData);
+ }
+
+}
\ No newline at end of file
diff --git a/scripts/wrappers.js b/scripts/wrappers.js
new file mode 100644
index 0000000..e976817
--- /dev/null
+++ b/scripts/wrappers.js
@@ -0,0 +1,165 @@
+import CONSTANTS from "./constants.js";
+import { libWrapper } from "./lib/libWrapper/shim.js";
+
+export function coreAnimateMovement(){
+
+ libWrapper.register(CONSTANTS.MODULE_NAME, "Token.prototype.animateMovement", async function animateMovement(ray) {
+
+ this._movement = ray;
+ let animation = this.document.getFlag(CONSTANTS.MODULE_NAME, CONSTANTS.ANIMATION_FLAG);
+
+ // Move distance is 10 spaces per second
+ const speed = canvas.dimensions.size * (animation?.speed || 10);
+ const duration = animation?.duration || (ray.distance * 1000) / speed;
+
+ // Define attributes
+ const attributes = [
+ { parent: this, attribute: 'x', to: ray.B.x },
+ { parent: this, attribute: 'y', to: ray.B.y }
+ ];
+
+ // Determine what type of updates should be animated
+ const emits = this.emitsLight;
+ const config = {
+ animate: game.settings.get("core", "visionAnimation"),
+ source: this._isVisionSource() || emits,
+ sound: this._controlled || this.observer,
+ forceUpdateFog: emits && !this._controlled && (canvas.sight.sources.size > 0)
+ }
+
+ // Dispatch the animation function
+ await CanvasAnimation.animateLinear(attributes, {
+ name: this.movementAnimationName,
+ context: this,
+ duration: duration,
+ ontick: (dt, anim) => this._onMovementFrame(dt, anim, config)
+ });
+
+ // Once animation is complete perform a final refresh
+ if ( !config.animate ) this._animatePerceptionFrame({source: config.source, sound: config.sound});
+ this._movement = null;
+ },
+ "OVERRIDE"
+ );
+}
+
+export function coreTerminateAnimation() {
+ libWrapper.register(CONSTANTS.MODULE_NAME, 'CanvasAnimation.terminateAnimation', function terminateAnimation(wrapper, ...args) {
+ const [name] = args;
+
+ if (!name.includes("Token")) return wrapper(...args)
+
+ let animation = this.animations[name];
+ if (animation) animation.terminate = true;
+ }, 'MIXED')
+}
+
+export function coreRulerMoveToken() {
+ libWrapper.register(CONSTANTS.MODULE_NAME, 'Ruler.prototype.moveToken', async function preRulerMove(wrapper) {
+ const token = this._getMovementToken();
+ if (!token) return wrapper();
+
+ const allowed = Hooks.call('preTokenChainMove', token, this);
+ if (!allowed) {
+ console.log("Token movement prevented by 'preTokenChainMove' hook.");
+ this.destination = null;
+ this._endMeasurement();
+ }
+
+ return wrapper();
+ }, 'WRAPPER');
+}
+
+
+export function coreTokenAnimateLinear() {
+ libWrapper.register(CONSTANTS.MODULE_NAME, 'CanvasAnimation.animateLinear', function preAnimateLinearHook(wrapper, ...args) {
+ const [attributes, fnData] = args;
+ let {context, name, duration, ontick} = fnData;
+
+ if (!(context instanceof Token)) return wrapper(...args);
+
+ let data = {
+ duration: duration,
+ config: {
+ animate: game.settings.get("core", "visionAnimation"),
+ source: context._isVisionSource() || context.emitsLight,
+ sound: context._controlled || context.observer,
+ forceUpdateFog: context.emitsLight && !context._controlled && (canvas.sight.sources.size > 0)
+ },
+ ontick: null
+ }
+ data.ontick = (dt, anim) => context._onMovementFrame(dt, anim, data.config)
+
+ Hooks.call('preTokenAnimate', context, data)
+
+ return wrapper(attributes, {
+ context: context,
+ name: name,
+ duration: data.duration,
+ ontick: data.ontick
+ });
+ }, 'WRAPPER');
+}
+
+export function coreAnimatePromise() {
+ libWrapper.register(CONSTANTS.MODULE_NAME, 'CanvasAnimation._animatePromise', async function animatePromise(wrapper, ...args) {
+ const attributes = args[3];
+ const token = attributes[0]?.parent;
+ await wrapper(...args);
+ if ((token instanceof Token)) Hooks.callAll('tokenAnimationComplete', token);
+ }, 'WRAPPER');
+}
+
+export function coreAnimateFrame() {
+ libWrapper.register(CONSTANTS.MODULE_NAME, 'CanvasAnimation._animateFrame', function animateFrame(...args) {
+
+ let [deltaTime, resolve, reject, attributes, duration, ontick] = args;
+
+ let ease = window.easeFunctions["linear"];
+
+ const target = attributes[0]?.parent;
+ if (target instanceof Token) {
+ const animationName = target.movementAnimationName;
+ if (CanvasAnimation.animations[animationName]?.terminate) {
+ Hooks.callAll('tokenAnimationTerminated', attributes);
+ return resolve(true);
+ }
+
+ if(target.document) {
+ let tokenEase = target.document.getFlag(CONSTANTS.MODULE_NAME, CONSTANTS.ANIMATION_FLAG)?.ease;
+ if(window.easeFunctions[tokenEase]){
+ ease = window.easeFunctions[tokenEase];
+ }
+ }
+ }
+
+ let complete = attributes.length === 0;
+ let dt = (duration * PIXI.settings.TARGET_FPMS) / deltaTime;
+
+ // Update each attribute
+ try {
+ for (let a of attributes) {
+ let da = a.delta / dt;
+ a.d = da;
+ if (a.remaining < (Math.abs(da) * 1.25)) {
+ a.parent[a.attribute] = a.to;
+ a.done = a.delta;
+ a.remaining = 0;
+ complete = true;
+ } else {
+ a.parent[a.attribute] += da;
+ a.done += da;
+ a.remaining = Math.abs(a.delta) - Math.abs(a.done);
+ a.parent[a.attribute] = (a.to - a.delta) + ease(a.done / a.delta) * a.delta;
+ }
+ }
+ if (ontick) ontick(dt, attributes);
+ } catch (err) {
+ reject(err);
+ }
+
+ // Resolve the original promise once the animation is complete
+ if (complete) resolve(true);
+
+ }, 'OVERRIDE');
+}
\ No newline at end of file
diff --git a/templates/token-ease-config.html b/templates/token-ease-config.html
new file mode 100644
index 0000000..be9eaee
--- /dev/null
+++ b/templates/token-ease-config.html
@@ -0,0 +1,85 @@
+
\ No newline at end of file