diff --git a/.gitignore b/.gitignore index 44d646d..18f2b36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +coverage +dist node_modules -dist/ diff --git a/README.md b/README.md index 74838c3..44ccfb7 100644 --- a/README.md +++ b/README.md @@ -215,9 +215,9 @@ See the following table for default `TimingMode` options. | **Bounce** | `easeInOutBounce` | `easeInBounce` | `easeOutBounce` | Bouncy effect at the start or end, with multiple rebounds. | | **Elastic** | `easeInOutElastic` | `easeInElastic` | `easeOutElastic` | Stretchy motion with overshoot and multiple oscillations. | -### Custom actions +### Custom Actions -Actions are reusable, so you can create complex animations once, and then run them on many display objects. +Actions are stateless and reusable, so you can create complex animations once, and then run them on many display objects. ```ts /** A nice gentle rock back and forth. */ @@ -340,15 +340,15 @@ mySprite.parent.speed = 1 / 4; // The entire action should now take 10 seconds. ``` -> Note: Since actions are designed to be mostly immutable, the `speed` property is captured when the action runs for the first time. +> Note: Since actions are designed to be stateless, the `speed` property is captured when the action runs. Any changes to `speed` or `timingMode` will not affect animations that have already been run. ## Creating Custom Actions -Beyond combining the built-ins with chaining actions like `sequence()`, `group()`, `repeat()` and `repeatForever()`, you can provide code that implements your own action. +Beyond combining chaining actions like `sequence()`, `group()`, `repeat()` and `repeatForever()`, you can provide code that implements your own action. -### Basic - Custom Action +### Action.customAction() -You can also use the built-in `Action.customAction(duration, stepHandler)` to provide a custom actions: +You can use the built-in `Action.customAction(duration, stepHandler)` to provide custom actions: ```ts const rainbowColors = Action.customAction(5.0, (target, t, dt) => { @@ -376,58 +376,3 @@ mySprite.removeAction('rainbow'); > _Note: `t` can be outside of 0 and 1 in timing mode functions which overshoot, such as `TimingMode.easeInOutBack`._ This function will be called as many times as the renderer asks over the course of its duration. - -### Advanced - Custom Subclass Action - -For more control, you can provide a custom subclass Action which can capture and manipulate state on the underlying action ticker. - -```ts -class MyTintAction extends Action { - constructor( - protected readonly color: 'red' | 'blue', - duration: number, - ) { - super(duration); - this.timingMode = TimingMode.easeInOutSine; - } - - /** (Optional) Setup any initial state here. */ - _setupTicker(target: PIXI.DisplayObject): any { - // If your action has any target-specific state, it should go here. - // Anything you return in this function will be availabler as `ticker.data`. - return { - startColor: new PIXI.Color(target.tint), - endColor: new PIXI.Color(this.color === 'red' ? 0xFF0000 : 0x0000FF), - }; - } - - /** Stepping function. Update the target here. */ - updateAction( - target: PIXI.DisplayObject, - progress: number, // Progress from 0 to 1 after timing mode - progressDelta: number, // Change in progress - ticker: any, // Use `ticker.data` to access any ticker state. - deltaTime: number, // The amount of time elapsed (scaled by `speed`). - ): void { - const start = ticker.data.startColor; - const end = ticker.data.endColor; - - const color = new PIXI.Color().setValue([ - start.red + (end.red - start.red) * progress, - start.green + (end.green - start.green) * progress, - start.blue + (end.blue - start.blue) * progress - ]); - - target.tint = color; - } - - /** Provide a function that reverses the current action. */ - reversed(): Action { - const oppositeColor = this.color === 'red' ? 'blue' : 'red'; - - return new MyTintAction(oppositeColor, this.duration) - .setTimingMode(this.timingMode) - .setSpeed(this.speed); - } -} -``` diff --git a/package-lock.json b/package-lock.json index 655cc0c..3499b3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixijs-actions", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pixijs-actions", - "version": "1.1.2", + "version": "1.1.3", "license": "MIT", "devDependencies": { "@types/jest": "^29.2.6", diff --git a/package.json b/package.json index 1c271c7..a1d239d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pixijs-actions", - "version": "1.1.2", + "version": "1.1.3", "author": "Reece Como ", "authors": [ "Reece Como ", diff --git a/src/Action.ts b/src/Action.ts index 3a76f70..5a58753 100644 --- a/src/Action.ts +++ b/src/Action.ts @@ -1,53 +1,44 @@ -import * as PIXI from 'pixi.js'; +import { Action } from './lib/Action'; +import { ActionTicker } from './lib/ActionTicker'; +import { + CustomAction, + DelayAction, + FadeByAction, + FadeInAction, + FadeOutAction, + FadeToAction, + FollowPathAction, + GroupAction, + MoveByAction, + MoveToAction, + RemoveFromParentAction, + RepeatAction, + RepeatForeverAction, + RotateByAction, + RotateToAction, + RunBlockAction, + RunOnChildWithNameAction, + ScaleByAction, + ScaleToAction, + ScaleToSizeAction, + SequenceAction, + SetVisibleAction, + SpeedByAction, + SpeedToAction +} from './actions'; +import { TimingModeFn } from './TimingMode'; -import { TimingMode, TimingModeFn } from './TimingMode'; -import { getIsPaused, getSpeed } from './util'; - -const EPSILON = 0.0000000001; -const EPSILON_ONE = 1 - EPSILON; const DEG_TO_RAD = Math.PI / 180; -const HALF_PI = Math.PI / 2; - -/** Time measured in seconds. */ -type TimeInterval = number; - -/** Targeted display node. */ -type TargetNode = PIXI.DisplayObject; - -/** Targeted display node that has a width/height. */ -type SizedTargetNode = TargetNode & SizeLike; - -/** A vector (e.g. PIXI.Point, or any node). */ -interface VectorLike { - x: number; - y: number; -} - -/** Any object with a width and height. */ -interface SizeLike { - width: number; - height: number; -} - -/** Any object containing an array of points. */ -interface PathLike { - points: VectorLike[]; -} - -// -// ----- Action: ----- -// /** - * Action is an animation that is executed by a display object in the scene. - * Actions are used to change a display object in some way (like move its position over time). + * Create, configure, and run actions in PixiJS. * - * Trigger @see {Action.tick(...)} to update actions. + * An action is an animation that is executed by a DisplayObject in the canvas. * - * Optionally set Action.categoryMask to allow different action categories to run independently - * (i.e. UI and Game World). + * ### Setup: + * Bind `Action.tick(deltaTimeMs)` to your renderer/shared ticker to activate actions. */ -export abstract class Action { +export abstract class _ extends Action { // // ----------------- Global Settings: ----------------- @@ -60,7 +51,12 @@ export abstract class Action { * * @see TimingMode.easeInSine - Default value. */ - public static DefaultTimingModeEaseIn = TimingMode.easeInSine; + public static get DefaultTimingModeEaseIn(): TimingModeFn { + return Action._defaultTimingModeEaseIn; + } + public static set DefaultTimingModeEaseIn(value: TimingModeFn) { + Action._defaultTimingModeEaseIn = value; + } /** * Default timing mode used for ease-out pacing. @@ -69,7 +65,12 @@ export abstract class Action { * * @see TimingMode.easeOutSine - Default value. */ - public static DefaultTimingModeEaseOut = TimingMode.easeOutSine; + public static get DefaultTimingModeEaseOut(): TimingModeFn { + return Action._defaultTimingModeEaseOut; + } + public static set DefaultTimingModeEaseOut(value: TimingModeFn) { + Action._defaultTimingModeEaseOut = value; + } /** * Default timing mode used for ease-in, ease-out pacing. @@ -78,10 +79,27 @@ export abstract class Action { * * @see TimingMode.easeInOutSine - Default value. */ - public static DefaultTimingModeEaseInOut = TimingMode.easeInOutSine; + public static get DefaultTimingModeEaseInOut(): TimingModeFn { + return Action._defaultTimingModeEaseInOut; + } + public static set DefaultTimingModeEaseInOut(value: TimingModeFn) { + Action._defaultTimingModeEaseInOut = value; + } + + // + // ----------------- Global Methods: ----------------- + // - /** All currently running actions. */ - protected static readonly _actions: Action[] = []; + /** + * Tick all actions forward. + * + * @param deltaTimeMs Delta time in milliseconds. + * @param categoryMask (Optional) Bitmask to filter which categories of actions to update. + * @param onErrorHandler (Optional) Handler errors from each action's tick. + */ + public static tick(deltaTimeMs: number, categoryMask: number | undefined = undefined, onErrorHandler?: (error: any) => void): void { + ActionTicker.tickAll(deltaTimeMs, categoryMask, onErrorHandler); + } // // ----------------- Chaining Actions: ----------------- @@ -195,7 +213,7 @@ export abstract class Action { * This action is reversible. */ public static moveByX(x: number, duration: TimeInterval): Action { - return Action.moveBy(x, 0, duration); + return this.moveBy(x, 0, duration); } /** @@ -204,7 +222,7 @@ export abstract class Action { * This action is reversible. */ public static moveByY(y: number, duration: TimeInterval): Action { - return Action.moveBy(0, y, duration); + return this.moveBy(0, y, duration); } /** @@ -258,7 +276,7 @@ export abstract class Action { * @param fixedSpeed (Default: true) When true, the node's speed is consistent for each segment. */ public static follow( - path: PathLike | VectorLike[], + path: PathObjectLike | VectorLike[], duration: number, asOffset: boolean = true, orientToPath: boolean = true, @@ -281,7 +299,7 @@ export abstract class Action { * @param orientToPath (Default: true) When true, the node’s rotation turns to follow the path. */ public static followAtSpeed( - path: PathLike | VectorLike[], + path: PathObjectLike | VectorLike[], speed: number, asOffset: boolean = true, orientToPath: boolean = true, @@ -310,7 +328,7 @@ export abstract class Action { * This action is reversible. */ public static rotateByDegrees(degrees: number, duration: TimeInterval): Action { - return Action.rotateBy(degrees * DEG_TO_RAD, duration); + return this.rotateBy(degrees * DEG_TO_RAD, duration); } /** @@ -330,7 +348,7 @@ export abstract class Action { * change anything. */ public static rotateToDegrees(degrees: number, duration: TimeInterval): Action { - return Action.rotateTo(degrees * DEG_TO_RAD, duration); + return this.rotateTo(degrees * DEG_TO_RAD, duration); } @@ -383,7 +401,7 @@ export abstract class Action { * This action is reversible. */ public static scaleByX(x: number, duration: TimeInterval): Action { - return Action.scaleBy(x, 0.0, duration); + return this.scaleBy(x, 0.0, duration); } /** @@ -392,7 +410,7 @@ export abstract class Action { * This action is reversible. */ public static scaleByY(y: number, duration: TimeInterval): Action { - return Action.scaleBy(0.0, y, duration); + return this.scaleBy(0.0, y, duration); } /** @@ -558,1203 +576,9 @@ export abstract class Action { return new CustomAction(duration, stepFn); } - // - // ----------------- Global Methods: ----------------- - // - - /** - * Tick all actions forward. - * - * @param deltaTimeMs Delta time in milliseconds. - * @param categoryMask (Optional) Bitmask to filter which categories of actions to update. - * @param onErrorHandler (Optional) Handler errors from each action's tick. - */ - public static tick(deltaTimeMs: number, categoryMask: number | undefined = undefined, onErrorHandler?: (error: any) => void): void { - ActionTicker.stepAllActionsForward(deltaTimeMs, categoryMask, onErrorHandler); - } - - public constructor( - /** The duration required to complete an action. */ - public readonly duration: TimeInterval, - /** A speed factor that modifies how fast an action runs. */ - public speed: number = 1.0, - /** A setting that controls the speed curve of an animation. */ - public timingMode: TimingModeFn = TimingMode.linear, - /** @deprecated A global category bitmask which can be used to group actions. */ - public categoryMask: number = 0x1, - ) {} - - // - // ----------------- Action Instance Methods: ----------------- - // - - /** - * Update function for the action. - * - * @param target The affected display object. - * @param progress The elapsed progress of the action, with the timing mode function applied. Generally a scalar number between 0.0 and 1.0. - * @param progressDelta Relative change in progress since the previous animation change. Use this for relative actions. - * @param actionTicker The actual ticker running this update. - * @param deltaTime The amount of time elapsed in this tick. This number is scaled by both speed of target and any parent actions. - */ - public abstract updateAction( - target: TargetNode, - progress: number, - progressDelta: number, - actionTicker: ActionTicker, - deltaTime: number - ): void; - - /** Duration of the action after the speed scalar is applied. */ - public get scaledDuration(): number { - return this.duration / this.speed; - } - - /** - * Creates an action that reverses the behavior of another action. - * - * This method always returns an action object; however, not all actions are reversible. - * When reversed, some actions return an object that either does nothing or that performs the same action as the original action. - */ - public abstract reversed(): Action; - - /** - * @deprecated To be removed soon. Modify node and action speed directly instead. - * - * Set a category mask for this action. - * Use this to tick different categories of actions separately (e.g. separate different UI). - */ - public setCategory(categoryMask: number): this { - this.categoryMask = categoryMask; - return this; - } - - /** - * Set the action's speed scale. Default: `1.0`. - */ - public setSpeed(speed: number): this { - this.speed = speed; - return this; - } - - /** - * Adjust the speed curve of an animation. Default: `TimingMode.linear`. - * - * @see {TimingMode} - */ - public setTimingMode(timingMode: TimingModeFn): this { - this.timingMode = timingMode; - return this; - } - - /** - * Sets the speed curve of the action to linear pacing (the default). Linear pacing causes an - * animation to occur evenly over its duration. - * - * @see {TimingMode.linear} - */ - public linear(): this { - return this.setTimingMode(TimingMode.linear); - } - - /** - * Sets the speed curve of the action to the default ease-in pacing. Ease-in pacing causes the - * animation to begin slowly and then speed up as it progresses. - * - * @see {Action.DefaultTimingModeEaseIn} - */ - public easeIn(): this { - return this.setTimingMode(Action.DefaultTimingModeEaseIn); - } - - /** - * Sets the speed curve of the action to the default ease-out pacing. Ease-out pacing causes the - * animation to begin quickly and then slow as it completes. - * - * @see {Action.DefaultTimingModeEaseOut} - */ - public easeOut(): this { - return this.setTimingMode(Action.DefaultTimingModeEaseOut); - } - - /** - * Sets the speed curve of the action to the default ease-in, ease-out pacing. Ease-in, ease-out - * pacing causes the animation to begin slowly, accelerate through the middle of its duration, - * and then slow again before completing. - * - * @see {Action.DefaultTimingModeEaseInOut} - */ - public easeInOut(): this { - return this.setTimingMode(Action.DefaultTimingModeEaseInOut); - } - - /** - * Do first time setup here. - * - * Anything you return here will be available as `ticker.data`. - */ - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - return undefined; - } - - /** - * Do resetting ticker stuff here. - * - * Anything you return here will be available as `ticker.data`. - */ - protected _onDidReset(ticker: ActionTicker): any { - return undefined; - } -} - -// -// ----------------- Built-in Actions: ----------------- -// - -class GroupAction extends Action { - protected index: number = 0; - protected actions: Action[]; - - public constructor(actions: Action[]) { - super( - // Max duration: - Math.max(...actions.map(action => action.scaledDuration)) - ); - - this.actions = actions; - } - - public updateAction( - target: TargetNode, - progress: number, - progressDelta: number, - ticker: ActionTicker, - ): void { - const relativeTimeDelta = progressDelta * ticker.scaledDuration; - let allDone = true; - - for (const childTicker of ticker.data.childTickers as ActionTicker[]) { - if (!childTicker.isDone) { - allDone = false; - childTicker.stepActionForward(relativeTimeDelta); - } - } - - if (allDone) { - ticker.isDone = true; - } - } - - public reversed(): Action { - return new GroupAction(this.actions.map(action => action.reversed())); - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - ticker.autoComplete = false; - - return { - childTickers: this.actions.map(action => new ActionTicker(undefined, target, action)) - }; - } - - protected _onDidReset(ticker: ActionTicker): any { - ticker.data.childTickers.forEach((ticker: ActionTicker) => ticker.reset()); - } -} - -class SequenceAction extends Action { - protected actions: Action[]; - - public constructor(actions: Action[]) { - super( - // Total duration: - actions.reduce((total, action) => total + action.scaledDuration, 0) - ); - this.actions = actions; - } - - public updateAction( - target: TargetNode, - progress: number, - progressDelta: number, - ticker: ActionTicker, - ): void { - let allDone = true; - let remainingTimeDelta = progressDelta * ticker.scaledDuration; - - for (const childTicker of ticker.data.childTickers as ActionTicker[]) { - if (!childTicker.isDone) { - - if (remainingTimeDelta > 0 || childTicker.scaledDuration === 0) { - remainingTimeDelta = childTicker.stepActionForward(remainingTimeDelta); - } - else { - allDone = false; - break; - } - - if (remainingTimeDelta < 0) { - allDone = false; - break; - } - } - } - - if (allDone) { - ticker.isDone = true; - } - } - - public reversed(): Action { - return new SequenceAction(this.actions.map(action => action.reversed()).reverse()) - .setTimingMode(this.timingMode) - .setSpeed(this.speed); - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - ticker.autoComplete = false; - - return { - childTickers: this.actions.map(action => new ActionTicker(undefined, target, action)) - }; - } - - protected _onDidReset(ticker: ActionTicker): any { - ticker.data.childTickers.forEach((ticker: ActionTicker) => ticker.reset()); - } -} - -class RepeatAction extends Action { - public constructor( - protected readonly action: Action, - protected readonly repeats: number, - ) { - super( - // Duration: - action.scaledDuration * repeats - ); - - if (Math.round(repeats) !== repeats || repeats < 0) { - throw new Error('Repeats must be a positive integer.'); - } - } - - public reversed(): Action { - return new RepeatAction(this.action.reversed(), this.repeats); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker, timeDelta: number): void { - let childTicker: ActionTicker = ticker.data.childTicker; - let remainingTimeDelta = timeDelta * this.speed; - - remainingTimeDelta = childTicker.stepActionForward(remainingTimeDelta); - - if (remainingTimeDelta > 0 || childTicker.scaledDuration === 0) { - if (++ticker.data.n >= this.repeats) { - ticker.isDone = true; - return; - } - - childTicker.reset(); - remainingTimeDelta = childTicker.stepActionForward(remainingTimeDelta); - } - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - ticker.autoComplete = false; - - const childTicker = new ActionTicker(undefined, target, this.action); - childTicker.timingMode = (x) => ticker.timingMode(childTicker.timingMode(x)); - - return { - childTicker, - n: 0, - }; - } - - protected _onDidReset(ticker: ActionTicker): any { - ticker.data.childTicker.reset(); - ticker.data.n = 0; - } -} - -class RepeatForeverAction extends Action { - public constructor( - protected readonly action: Action - ) { - super(Infinity); - - if (action.duration <= 0) { - throw new Error('The action to be repeated must have a non-instantaneous duration.'); - } - } - - public reversed(): Action { - return new RepeatForeverAction(this.action.reversed()); - } - - public updateAction( - target: TargetNode, - progress: number, - progressDelta: number, - ticker: ActionTicker, - timeDelta: number - ): void { - const childTicker: ActionTicker = ticker.data.childTicker; - let remainingTimeDelta = timeDelta * ticker.speed; - - remainingTimeDelta = childTicker.stepActionForward(remainingTimeDelta); - - if (remainingTimeDelta > 0) { - childTicker.reset(); - remainingTimeDelta = childTicker.stepActionForward(remainingTimeDelta); - } - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - const childTicker = new ActionTicker(undefined, target, this.action); - childTicker.timingMode = (x) => ticker.timingMode(childTicker.timingMode(x)); - - return { - childTicker - }; - } - - protected _onDidReset(ticker: ActionTicker): any { - ticker.data.childTicker.reset(); - } -} - -class ScaleToSizeAction extends Action { - public constructor( - protected readonly width: number, - protected readonly height: number, - duration: TimeInterval, - ) { - super(duration); - } - - protected _setupTicker(target: SizedTargetNode): any { - if (target.width === undefined) { - throw new Error('Action can only be run against a target with a width & height.'); - } - - return { - sW: target.width, - sH: target.height, - }; - } - - public updateAction(target: SizedTargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.width = ticker.data.sW + (this.width - ticker.data.sW) * progress; - target.height = ticker.data.sH + (this.height - ticker.data.sH) * progress; - } - - public reversed(): Action { - return new DelayAction(this.scaledDuration); - } -} - -class ScaleToAction extends Action { - public constructor( - protected readonly x: number | undefined, - protected readonly y: number | undefined, - duration: TimeInterval, - protected asSize: boolean = false, - ) { - super(duration); - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - return { - startX: target.scale.x, - startY: target.scale.y - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.scale.set( - this.x === undefined ? target.scale.x : ticker.data.startX + (this.x - ticker.data.startX) * progress, - this.y === undefined ? target.scale.y : ticker.data.startY + (this.y - ticker.data.startY) * progress - ); - } - - public reversed(): Action { - return new DelayAction(this.scaledDuration); - } -} - -class ScaleByAction extends Action { - public constructor( - protected readonly x: number, - protected readonly y: number, - duration: TimeInterval, - ) { - super(duration); - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - return { - dx: target.scale.x * this.x - target.scale.x, - dy: target.scale.y * this.y - target.scale.y - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.scale.set( - target.scale.x + ticker.data.dx * progressDelta, - target.scale.y + ticker.data.dy * progressDelta, - ); - } - - public reversed(): Action { - return new ScaleByAction(-this.x, -this.y, this.duration) - .setSpeed(this.speed) - .setTimingMode(this.timingMode); - } -} - -class SetVisibleAction extends Action { - public constructor( - protected readonly visible: boolean, - ) { - super(0); - } - - public updateAction(target: TargetNode): void { - target.visible = this.visible; - } - - public reversed(): Action { - return new SetVisibleAction(!this.visible); - } -} - -class RemoveFromParentAction extends Action { - public constructor() { - super(0); - } - - public updateAction(target: TargetNode): void { - target.parent?.removeChild(target); - } - - public reversed(): Action { - return this; - } -} - -class CustomAction extends Action { - public constructor( - duration: TimeInterval, - protected stepFn: (target: TargetNode, t: number, dt: number) => void - ) { - super(duration); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number): void { - this.stepFn(target, progress, progressDelta); - } - - public reversed(): Action { - return this; - } -} - -class RunOnChildWithNameAction extends Action { - public constructor( - protected action: Action, - protected name: string, - ) { - super(0); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number): void { - if (!('children' in target) || !Array.isArray(target.children)) { - return; - } - - const child: TargetNode | undefined = target.children.find(c => c.name === this.name); - child?.run(this.action); - } - - public reversed(): Action { - return new RunOnChildWithNameAction(this.action.reversed(), this.name); - } -} - -class RunBlockAction extends Action { - public constructor( - protected block: () => void - ) { - super(0); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number): void { - this.block(); - } - - public reversed(): Action { - return this; - } -} - -class SpeedToAction extends Action { - public constructor( - protected readonly _speed: number, - duration: TimeInterval, - ) { - super(duration); - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - return { - startSpeed: target.speed - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.rotation = ticker.data.startRotation + (this._speed - ticker.data.startSpeed) * progress; - } - - public reversed(): Action { - return new DelayAction(this.scaledDuration); - } -} - -class SpeedByAction extends Action { - public constructor( - protected readonly _speed: number, - duration: TimeInterval, - ) { - super(duration); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.rotation += this._speed * progressDelta; - } - - public reversed(): Action { - return new SpeedByAction(-this._speed, this.duration); - } -} - -class FollowPathAction extends Action { - protected readonly path: VectorLike[]; - protected readonly lastIndex: number; - protected readonly segmentLengths!: number[]; - protected readonly segmentWeights!: number[]; - - public constructor( - path: VectorLike[], - duration: number, - protected readonly asOffset: boolean, - protected readonly orientToPath: boolean, - protected readonly fixedSpeed: boolean, - ) { - super(duration); - this.path = path; - this.lastIndex = path.length - 1; - - // Precalculate segment lengths, if needed. - if (orientToPath || fixedSpeed) { - const [totalDist, segmentLengths] = FollowPathAction.getLength(path); - this.segmentLengths = segmentLengths; - if (fixedSpeed) { - this.segmentWeights = segmentLengths.map(v => v / (totalDist || 1)); - } - } - } - - // ----- Static helpers: ----- - - public static getPath(path: VectorLike[] | { points: VectorLike[] }): VectorLike[] { - return Array.isArray(path) ? [...path] : [...path.points]; - } - - public static getLength(path: VectorLike[]): [length: number, segmentLengths: number[]] { - let totalLength = 0; - const segmentLengths: number[] = []; - - for (let i = 0; i < path.length - 1; i++) { - const directionX = path[i + 1]!.x - path[i]!.x; - const directionY = path[i + 1]!.y - path[i]!.y; - const length = Math.sqrt(directionX * directionX + directionY * directionY); - - segmentLengths.push(length); - totalLength += length; - } - - return [totalLength, segmentLengths]; - } - - // ----- Methods: ----- - - public updateAction(target: any, progress: number, progressDelta: number, ticker: any): void { - if (this.lastIndex < 0) { - return; // Empty path. - } - - const [index, t] = this.fixedSpeed - ? this._getFixedSpeedProgress(progress) - : this._getDynamicSpeedProgress(progress); - - const startPoint = this.path[index]!; - const endPoint = this.path[index + 1] ?? startPoint; - - target.position.set( - ticker.data.x + startPoint.x + (endPoint.x - startPoint.x) * t, - ticker.data.y + startPoint.y + (endPoint.y - startPoint.y) * t - ); - - if (this.orientToPath) { - const length = this.segmentLengths[index]! || 1; - const ndx = (endPoint.x - startPoint.x) / length; - const ndy = (endPoint.y - startPoint.y) / length; - const rotation = HALF_PI + Math.atan2(ndy, ndx); - - target.rotation = rotation; - } - } - - public reversed(): Action { - return new FollowPathAction( - this._reversePath(), - this.duration, - this.asOffset, - this.orientToPath, - this.fixedSpeed, - ) - .setTimingMode(this.timingMode) - .setSpeed(this.speed); - } - - protected _setupTicker(target: any): any { - return { - x: this.asOffset ? target.x : 0, - y: this.asOffset ? target.y : 0, - }; - } - - protected _reversePath(): VectorLike[] { - if (this.asOffset && this.path.length > 0) { - // Calculate the relative delta offset when first and last are flipped. - const first = this.path[0]!, last = this.path[this.path.length - 1]!; - const dx = last.x + first.x, dy = last.y + first.y; - - return this.path.map(({x, y}) => ({ x: x - dx, y: y - dy })).reverse(); - } - - // Absolute path is the path backwards. - return [...this.path].reverse(); - } - - protected _getDynamicSpeedProgress(progress: number): [index: number, t: number] { - const index = Math.max(Math.min(Math.floor(progress * this.lastIndex), this.lastIndex - 1), 0); - const lastIndexNonZero = this.lastIndex || 1; - const t = (progress - index / lastIndexNonZero) * lastIndexNonZero; - - return [index, t]; - } - - protected _getFixedSpeedProgress(progress: number): [index: number, t: number] { - let remainingProgress = progress; - let index = 0; - let t = 0; - - for (let i = 0; i < this.lastIndex; i++) { - const segmentWeight = this.segmentWeights[i]!; - - if (segmentWeight! > remainingProgress || i === this.lastIndex - 1) { - t = remainingProgress / segmentWeight || 1; - break; - } - - remainingProgress -= segmentWeight; - index++; - } - - return [index, t]; - } -} - -class RotateToAction extends Action { - public constructor( - protected readonly rotation: number, - duration: TimeInterval, - ) { - super(duration); - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - return { - startRotation: target.rotation - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.rotation = ticker.data.startRotation + (this.rotation - ticker.data.startRotation) * progress; - } - - public reversed(): Action { - return new DelayAction(this.scaledDuration); - } -} - -class RotateByAction extends Action { - public constructor( - protected readonly rotation: number, - duration: TimeInterval, - ) { - super(duration); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number): void { - target.rotation += this.rotation * progressDelta; - } - - public reversed(): Action { - return new RotateByAction(-this.rotation, this.duration) - .setSpeed(this.speed) - .setTimingMode(this.timingMode); - } -} - -class MoveToAction extends Action { - public constructor( - protected readonly x: number | undefined, - protected readonly y: number | undefined, - duration: TimeInterval, - ) { - super(duration); - } - - protected _setupTicker(target: TargetNode, ticker: ActionTicker): any { - return { - startX: target.x, - startY: target.y - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.position.set( - this.x === undefined ? target.position.x : ticker.data.startX + (this.x - ticker.data.startX) * progress, - this.y === undefined ? target.position.y : ticker.data.startY + (this.y - ticker.data.startY) * progress - ); - } - - public reversed(): Action { - return new DelayAction(this.scaledDuration); - } -} - -class MoveByAction extends Action { - public constructor( - protected readonly x: number, - protected readonly y: number, - duration: number, - ) { - super(duration); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number): void { - target.position.x += this.x * progressDelta; - target.position.y += this.y * progressDelta; - } - - public reversed(): Action { - return new MoveByAction(-this.x, -this.y, this.duration) - .setSpeed(this.speed) - .setTimingMode(this.timingMode); - } -} - -class FadeToAction extends Action { - public constructor( - protected readonly alpha: number, - duration: TimeInterval - ) { - super(duration); - } - - protected _setupTicker(target: PIXI.DisplayObject, ticker: ActionTicker): any { - return { - startAlpha: target.alpha - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.alpha = ticker.data.startAlpha + (this.alpha - ticker.data.startAlpha) * progress; - } - - public reversed(): Action { - return new DelayAction(this.scaledDuration); - } -} - -class FadeInAction extends Action { - protected _setupTicker(target: PIXI.DisplayObject, ticker: ActionTicker): any { - return { - startAlpha: target.alpha - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.alpha = ticker.data.startAlpha + (1.0 - ticker.data.startAlpha) * progress; - } - - public reversed(): Action { - return new FadeOutAction(this.duration) - .setSpeed(this.speed) - .setTimingMode(this.timingMode); - } -} - -class FadeOutAction extends Action { - protected _setupTicker(target: PIXI.DisplayObject, ticker: ActionTicker): any { - return { - startAlpha: target.alpha - }; - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number, ticker: ActionTicker): void { - target.alpha = ticker.data.startAlpha + (0.0 - ticker.data.startAlpha) * progress; - } - - public reversed(): Action { - return new FadeInAction(this.duration) - .setSpeed(this.speed) - .setTimingMode(this.timingMode); - } -} - -class FadeByAction extends Action { - public constructor( - protected readonly alpha: number, - duration: TimeInterval, - ) { - super(duration); - } - - public updateAction(target: TargetNode, progress: number, progressDelta: number): void { - target.alpha += this.alpha * progressDelta; - } - - public reversed(): Action { - return new FadeByAction(-this.alpha, this.duration) - .setSpeed(this.speed) - .setTimingMode(this.timingMode); - } -} - -class DelayAction extends Action { - public updateAction(): void { - // Idle - } - - public reversed(): Action { - return this; - } -} - -// -// ----- Action Ticker: ----- -// - -class ActionTicker { - protected static _running: ActionTicker[] = []; - - public static runAction( - key: string | undefined, - target: TargetNode, - action: Action, - ): void { - if (key !== undefined) { - const existingAction = this._running - .find(a => a.target === target && a.key === key); - - if (existingAction !== undefined) { - ActionTicker.removeAction(existingAction); - } - } - - this._running.push(new ActionTicker(key, target, action)); - } - - public reset(): void { - this.elapsed = 0.0; - this.isDone = false; - (this.action as any)._onDidReset(this); - } - - public static removeAction(actionTicker: ActionTicker): ActionTicker { - const index = ActionTicker._running.indexOf(actionTicker); - if (index >= 0) { - ActionTicker._running.splice(index, 1); - } - return actionTicker; - } - - public static hasTargetActions(target: TargetNode): boolean { - return ActionTicker._running.find(at => at.target === target) !== undefined; - } - - public static getTargetActionTickerForKey( - target: TargetNode, - key: string - ): ActionTicker | undefined { - return ActionTicker._running.find(at => at.target === target && at.key === key); - } - - public static getTargetActionForKey(target: TargetNode, key: string): Action | undefined { - return this.getTargetActionTickerForKey(target, key)?.action; - } - - public static removeTargetActionForKey(target: TargetNode, key: string): void { - const actionTicker = this.getTargetActionTickerForKey(target, key); - - if (!actionTicker) { - return; - } - - ActionTicker.removeAction(actionTicker); - } - - public static removeAllTargetActions(target: TargetNode): void { - for (let i = ActionTicker._running.length - 1; i >= 0; i--) { - const actionTicker = ActionTicker._running[i]; - - if (actionTicker.target === target) { - ActionTicker.removeAction(actionTicker); - } - } - } - - /** - * Tick all actions forward. - * - * @param deltaTimeMs Delta time given in milliseconds. - * @param categoryMask (Optional) Bitmask to filter which categories of actions to update. - * @param onErrorHandler (Optional) Handler errors from each action's tick. - */ - public static stepAllActionsForward( - deltaTimeMs: number, - categoryMask: number | undefined = undefined, - onErrorHandler?: (error: any) => void - ): void { - const deltaTime = deltaTimeMs * 0.001; - - for (let i = ActionTicker._running.length - 1; i >= 0; i--) { - const actionTicker = ActionTicker._running[i]; - - if (categoryMask !== undefined && (categoryMask & actionTicker.action.categoryMask) === 0) { - continue; - } - - if (getIsPaused(actionTicker.target)) { - continue; - } - - try { - actionTicker.stepActionForward(deltaTime * getSpeed(actionTicker.target)); - } - catch (error) { - // Isolate individual action errors. - if (onErrorHandler === undefined) { - console.error('Action failed with error: ', error); - } - else { - onErrorHandler(error); - } - - // Remove offending ticker. - ActionTicker.removeAction(actionTicker); - } - } - } - - /** Any instance data that will live for the duration of the ticker. */ - public data: any; + // ----------------- Implementation: ----------------- - /** Time elapsed in the action. */ - public elapsed: number = 0.0; - - /** Whether the action ticker has been setup. This is triggered on the first iteration. */ - public isSetup = false; - - /** Whether the action has completed. */ - public isDone: boolean = false; - - /** Whether the action ticker will mark the action as done when time elapsed >= duration. */ - public autoComplete: boolean = true; - - /** - * Relative speed of the action ticker. - * - * Copies the action's speed when the action is run. - */ - public speed: number; - - /** - * Relative speed of the action ticker. - * - * Copies the action's timingMode when the action is run. - */ - public timingMode: TimingModeFn; - - /** - * Expected duration of the action ticker. - * - * Copies the action's scaledDuration when the action is run. - */ - public scaledDuration: number; - - public constructor( - public key: string | undefined, - public target: TargetNode, - public action: Action, - ) { - this.speed = action.speed; - this.scaledDuration = action.scaledDuration; - this.timingMode = action.timingMode; - } - - /** The relative time elapsed between 0 and 1. */ - public get timeDistance(): number { - return this.scaledDuration === 0 ? 1 : Math.min(1, this.elapsed / this.scaledDuration); - } - - /** - * The relative time elapsed between 0 and 1, eased by the timing mode function. - * - * Can be a value beyond 0 or 1 depending on the timing mode function. - */ - protected get easedTimeDistance(): number { - return this.timingMode(this.timeDistance); - } - - /** @returns Any unused time delta. Negative value means action is still in progress. */ - public stepActionForward(timeDelta: number): number { - if (!this.isSetup) { - // Copy action attributes: - this.speed = this.action.speed; - this.scaledDuration = this.action.duration; - this.timingMode = this.action.timingMode; - - // Perform first time setup: - this.data = (this.action as any)._setupTicker(this.target, this); - this.isSetup = true; - } - - const target = this.target; - const action = this.action; - - // If action no longer valid, or target not on the stage - // we garbage collect its actions. - if ( - target == null - || target.destroyed - || target.parent === undefined - ) { - ActionTicker.removeAction(this); - - return; - } - - const scaledTimeDelta = timeDelta * this.speed /* target speed is applied at the root */; - - if (this.scaledDuration === 0) { - // Instantaneous action. - action.updateAction(this.target, 1.0, 1.0, this, scaledTimeDelta); - this.isDone = true; - - // Remove completed action. - ActionTicker.removeAction(this); - - return timeDelta; // relinquish the full time. - } - - if (timeDelta === 0) { - return -1; // Early exit, no progress. - } - - const beforeProgress = this.easedTimeDistance; - this.elapsed += scaledTimeDelta; - const progress = this.easedTimeDistance; - const progressDelta = progress - beforeProgress; - - action.updateAction(this.target, progress, progressDelta, this, scaledTimeDelta); - - if (this.isDone || (this.autoComplete && this.timeDistance >= EPSILON_ONE)) { - this.isDone = true; - - // Remove completed action. - ActionTicker.removeAction(this); - - return this.elapsed > this.scaledDuration ? this.elapsed - this.scaledDuration : 0; - } - - return -1; // relinquish no time + private constructor() { + super(-1); } } - -// -// ----- Global Mixin: ----- -// - -/** - * Register the global mixins for PIXI.DisplayObject. - * - * @param displayObject A reference to `PIXI.DisplayObject`. - */ -export function registerGlobalMixin(displayObject: any): void { - const _prototype = displayObject.prototype; - - // - Properties: - - _prototype.speed = 1.0; - _prototype.isPaused = false; - - // - Methods: - - _prototype.run = function (_action: Action, completion?: () => void): void { - const action = completion ? Action.sequence([_action, Action.run(completion)]) : _action; - ActionTicker.runAction(undefined, this, action); - }; - - _prototype.runWithKey = function (action: Action, key: string): void { - ActionTicker.runAction(key, this, action); - }; - - _prototype.runAsPromise = function ( - action: Action, - timeoutBufferMs: number = 100 - ): Promise { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const node = this; - return new Promise(function (resolve, reject) { - const timeLimitMs = timeoutBufferMs + (getSpeed(node) * action.duration * 1_000); - const timeoutCheck = setTimeout(() => reject('Took too long to complete.'), timeLimitMs); - node.run(action, () => { - clearTimeout(timeoutCheck); - resolve(); - }); - }); - }; - - _prototype.action = function (forKey: string): Action | undefined { - return ActionTicker.getTargetActionForKey(this, forKey); - }; - - _prototype.hasActions = function (): boolean { - return ActionTicker.hasTargetActions(this); - }; - - _prototype.removeAllActions = function (): void { - ActionTicker.removeAllTargetActions(this); - }; - - _prototype.removeAction = function (forKey: string): void { - ActionTicker.removeTargetActionForKey(this, forKey); - }; -} diff --git a/src/test/Action.test.ts b/src/__tests__/Action.test.ts similarity index 84% rename from src/test/Action.test.ts rename to src/__tests__/Action.test.ts index af84a35..797035c 100644 --- a/src/test/Action.test.ts +++ b/src/__tests__/Action.test.ts @@ -1,5 +1,5 @@ import { Container, Sprite } from 'pixi.js'; -import { Action } from '../index'; +import { Action, TimingMode } from '../index'; function simulateTime(seconds: number, steps: number = 100): void { const tickMs = seconds / steps * 1_000; @@ -10,6 +10,50 @@ function simulateTime(seconds: number, steps: number = 100): void { } } +/** Load the global mixin first. */ +// beforeAll(() => registerGlobalMixin(DisplayObject)); + +describe('DefaultTimingMode static properties', () => { + it('should reflect the DefaultTimingModeEaseInOut on the root Action type', () => { + expect(Action.DefaultTimingModeEaseInOut).toBe(TimingMode.easeInOutSine); + expect(Action.fadeIn(0.3).easeInOut().timingMode).toBe(TimingMode.easeInOutSine); + expect(Action.fadeIn(0.3).easeInOut().timingMode).not.toBe(TimingMode.easeInCubic); + + // Update to any other function. + Action.DefaultTimingModeEaseInOut = TimingMode.easeInCubic; + + expect(Action.DefaultTimingModeEaseInOut).toBe(TimingMode.easeInCubic); + expect(Action.fadeIn(0.3).easeInOut().timingMode).toBe(TimingMode.easeInCubic); + expect(Action.fadeIn(0.3).easeInOut().timingMode).not.toBe(TimingMode.easeInOutSine); + }); + + it('should reflect the DefaultTimingModeEaseIn on the root Action type', () => { + expect(Action.DefaultTimingModeEaseIn).toBe(TimingMode.easeInSine); + expect(Action.fadeIn(0.3).easeIn().timingMode).toBe(TimingMode.easeInSine); + expect(Action.fadeIn(0.3).easeIn().timingMode).not.toBe(TimingMode.easeInCubic); + + // Update to any other function. + Action.DefaultTimingModeEaseIn = TimingMode.easeInCubic; + + expect(Action.DefaultTimingModeEaseIn).toBe(TimingMode.easeInCubic); + expect(Action.fadeIn(0.3).easeIn().timingMode).toBe(TimingMode.easeInCubic); + expect(Action.fadeIn(0.3).easeIn().timingMode).not.toBe(TimingMode.easeInSine); + }); + + it('should reflect the DefaultTimingModeEaseOut on the root Action type', () => { + expect(Action.DefaultTimingModeEaseOut).toBe(TimingMode.easeOutSine); + expect(Action.fadeIn(0.3).easeOut().timingMode).toBe(TimingMode.easeOutSine); + expect(Action.fadeIn(0.3).easeOut().timingMode).not.toBe(TimingMode.easeInCubic); + + // Update to any other function. + Action.DefaultTimingModeEaseOut = TimingMode.easeInCubic; + + expect(Action.DefaultTimingModeEaseOut).toBe(TimingMode.easeInCubic); + expect(Action.fadeIn(0.3).easeOut().timingMode).toBe(TimingMode.easeInCubic); + expect(Action.fadeIn(0.3).easeOut().timingMode).not.toBe(TimingMode.easeInSine); + }); +}); + describe('Action Chaining', () => { describe('sequence()', () => { it('complete all steps in order', () => { diff --git a/src/actions/chainable/GroupAction.ts b/src/actions/chainable/GroupAction.ts new file mode 100644 index 0000000..4272509 --- /dev/null +++ b/src/actions/chainable/GroupAction.ts @@ -0,0 +1,55 @@ +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { ActionTicker } from '../../lib/ActionTicker'; + +export class GroupAction extends Action { + public constructor( + protected readonly actions: Action[] + ) { + super( + // Max duration: + Math.max(...actions.map(action => action.scaledDuration)) + ); + + this.actions = actions; + } + + public reversed(): Action { + return new GroupAction(this.actions.map(action => action.reversed())) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: TargetNode, ticker: IActionTicker): any { + ticker.autoComplete = false; + + return { + childTickers: this.actions.map(action => new ActionTicker(undefined, target, action)) + }; + } + + protected onTick( + target: TargetNode, + t: number, + dt: number, + ticker: IActionTicker, + ): void { + const relativeTimeDelta = dt * ticker.scaledDuration; + let allDone = true; + + for (const childTicker of ticker.data.childTickers as IActionTicker[]) { + if (!childTicker.isDone) { + allDone = false; + childTicker.tick(relativeTimeDelta); + } + } + + if (allDone) { + ticker.isDone = true; + } + } + + protected onTickerDidReset(ticker: IActionTicker): any { + ticker.data.childTickers.forEach((ticker: IActionTicker) => ticker.reset()); + } +} diff --git a/src/actions/chainable/RepeatAction.ts b/src/actions/chainable/RepeatAction.ts new file mode 100644 index 0000000..04456c5 --- /dev/null +++ b/src/actions/chainable/RepeatAction.ts @@ -0,0 +1,60 @@ + +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { ActionTicker } from '../../lib/ActionTicker'; + +export class RepeatAction extends Action { + public constructor( + protected readonly action: Action, + protected readonly repeats: number, + ) { + super( + // Duration: + action.scaledDuration * repeats + ); + + if (Math.round(repeats) !== repeats || repeats < 0) { + throw new Error('Repeats must be a positive integer.'); + } + } + + public reversed(): Action { + return new RepeatAction(this.action.reversed(), this.repeats) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: TargetNode, ticker: IActionTicker): any { + ticker.autoComplete = false; + + const childTicker = new ActionTicker(undefined, target, this.action); + childTicker.timingMode = (x: number) => ticker.timingMode(childTicker.timingMode(x)); + + return { + childTicker, + n: 0, + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker, deltaTime: number): void { + let childTicker: IActionTicker = ticker.data.childTicker; + let remainingTimeDelta = deltaTime * this.speed; + + remainingTimeDelta = childTicker.tick(remainingTimeDelta); + + if (remainingTimeDelta > 0 || childTicker.scaledDuration === 0) { + if (++ticker.data.n >= this.repeats) { + ticker.isDone = true; + return; + } + + childTicker.reset(); + remainingTimeDelta = childTicker.tick(remainingTimeDelta); + } + } + + protected onTickerDidReset(ticker: IActionTicker): any { + ticker.data.childTicker.reset(); + ticker.data.n = 0; + } +} diff --git a/src/actions/chainable/RepeatForeverAction.ts b/src/actions/chainable/RepeatForeverAction.ts new file mode 100644 index 0000000..6d7762c --- /dev/null +++ b/src/actions/chainable/RepeatForeverAction.ts @@ -0,0 +1,53 @@ + +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { ActionTicker } from '../../lib/ActionTicker'; + +export class RepeatForeverAction extends Action { + public constructor( + protected readonly action: Action + ) { + super(Infinity); + + if (action.duration <= 0) { + throw new Error('The action to be repeated must have a non-instantaneous duration.'); + } + } + + public reversed(): Action { + return new RepeatForeverAction(this.action.reversed()) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: TargetNode, ticker: IActionTicker): any { + const childTicker = new ActionTicker(undefined, target, this.action); + childTicker.timingMode = (x: number) => ticker.timingMode(childTicker.timingMode(x)); + + return { + childTicker + }; + } + + protected onTick( + target: TargetNode, + t: number, + dt: number, + ticker: IActionTicker, + deltaTime: number + ): void { + const childTicker: IActionTicker = ticker.data.childTicker; + let remainingTimeDelta = deltaTime * ticker.speed; + + remainingTimeDelta = childTicker.tick(remainingTimeDelta); + + if (remainingTimeDelta > 0) { + childTicker.reset(); + remainingTimeDelta = childTicker.tick(remainingTimeDelta); + } + } + + protected onTickerDidReset(ticker: IActionTicker): any { + ticker.data.childTicker.reset(); + } +} diff --git a/src/actions/chainable/SequenceAction.ts b/src/actions/chainable/SequenceAction.ts new file mode 100644 index 0000000..b9272a4 --- /dev/null +++ b/src/actions/chainable/SequenceAction.ts @@ -0,0 +1,65 @@ +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { ActionTicker } from '../../lib/ActionTicker'; + +export class SequenceAction extends Action { + public constructor( + protected readonly actions: Action[] + ) { + super( + // Total duration: + actions.reduce((total, action) => total + action.scaledDuration, 0) + ); + this.actions = actions; + } + + public reversed(): Action { + return new SequenceAction(this.actions.map(action => action.reversed()).reverse()) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: TargetNode, ticker: IActionTicker): any { + ticker.autoComplete = false; + + return { + childTickers: this.actions.map(action => new ActionTicker(undefined, target, action)) + }; + } + + protected onTick( + target: TargetNode, + t: number, + dt: number, + ticker: IActionTicker, + ): void { + let allDone = true; + let remainingTimeDelta = dt * ticker.scaledDuration; + + for (const childTicker of ticker.data.childTickers as IActionTicker[]) { + if (!childTicker.isDone) { + + if (remainingTimeDelta > 0 || childTicker.scaledDuration === 0) { + remainingTimeDelta = childTicker.tick(remainingTimeDelta); + } + else { + allDone = false; + break; + } + + if (remainingTimeDelta < 0) { + allDone = false; + break; + } + } + } + + if (allDone) { + ticker.isDone = true; + } + } + + protected onTickerDidReset(ticker: IActionTicker): any { + ticker.data.childTickers.forEach((ticker: IActionTicker) => ticker.reset()); + } +} diff --git a/src/actions/chainable/index.ts b/src/actions/chainable/index.ts new file mode 100644 index 0000000..7126b78 --- /dev/null +++ b/src/actions/chainable/index.ts @@ -0,0 +1,4 @@ +export * from './GroupAction'; +export * from './RepeatAction'; +export * from './RepeatForeverAction'; +export * from './SequenceAction'; diff --git a/src/actions/custom/CustomAction.ts b/src/actions/custom/CustomAction.ts new file mode 100644 index 0000000..e1fc8e5 --- /dev/null +++ b/src/actions/custom/CustomAction.ts @@ -0,0 +1,19 @@ + +import { Action } from '../../lib/Action'; + +export class CustomAction extends Action { + public constructor( + duration: TimeInterval, + protected readonly stepFn: (target: TargetNode, t: number, dt: number) => void + ) { + super(duration); + } + + protected onTick(target: TargetNode, t: number, dt: number): void { + this.stepFn(target, t, dt); + } + + public reversed(): Action { + return this; + } +} diff --git a/src/actions/custom/RunBlockAction.ts b/src/actions/custom/RunBlockAction.ts new file mode 100644 index 0000000..ab7c9c8 --- /dev/null +++ b/src/actions/custom/RunBlockAction.ts @@ -0,0 +1,18 @@ + +import { Action } from '../../lib/Action'; + +export class RunBlockAction extends Action { + public constructor( + protected readonly block: () => void + ) { + super(0); + } + + public reversed(): Action { + return this; + } + + protected onTick(): void { + this.block(); + } +} diff --git a/src/actions/custom/index.ts b/src/actions/custom/index.ts new file mode 100644 index 0000000..cd288bf --- /dev/null +++ b/src/actions/custom/index.ts @@ -0,0 +1,2 @@ +export * from './CustomAction'; +export * from './RunBlockAction'; diff --git a/src/actions/delay/DelayAction.ts b/src/actions/delay/DelayAction.ts new file mode 100644 index 0000000..ee29203 --- /dev/null +++ b/src/actions/delay/DelayAction.ts @@ -0,0 +1,11 @@ + +import { Action } from '../../lib/Action'; + +export class DelayAction extends Action { + protected onTick(): void { + } + + public reversed(): Action { + return this; + } +} diff --git a/src/actions/delay/index.ts b/src/actions/delay/index.ts new file mode 100644 index 0000000..237c8e8 --- /dev/null +++ b/src/actions/delay/index.ts @@ -0,0 +1 @@ +export * from './DelayAction'; diff --git a/src/actions/display-object/RemoveFromParentAction.ts b/src/actions/display-object/RemoveFromParentAction.ts new file mode 100644 index 0000000..a81a571 --- /dev/null +++ b/src/actions/display-object/RemoveFromParentAction.ts @@ -0,0 +1,15 @@ +import { Action } from '../../lib/Action'; + +export class RemoveFromParentAction extends Action { + public constructor() { + super(0); + } + + public reversed(): Action { + return this; + } + + protected onTick(target: TargetNode): void { + target.parent?.removeChild(target); + } +} diff --git a/src/actions/display-object/RunOnChildWithNameAction.ts b/src/actions/display-object/RunOnChildWithNameAction.ts new file mode 100644 index 0000000..25d6e7e --- /dev/null +++ b/src/actions/display-object/RunOnChildWithNameAction.ts @@ -0,0 +1,25 @@ +import { Action } from '../../lib/Action'; + +export class RunOnChildWithNameAction extends Action { + public constructor( + protected readonly action: Action, + protected readonly name: string, + ) { + super(0); + } + + public reversed(): Action { + return new RunOnChildWithNameAction(this.action.reversed(), this.name) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onTick(target: TargetNode): void { + if (!('children' in target) || !Array.isArray(target.children)) { + return; + } + + const child: TargetNode | undefined = target.children.find((c: any) => c.name === this.name); + child?.run(this.action); + } +} diff --git a/src/actions/display-object/SetVisibleAction.ts b/src/actions/display-object/SetVisibleAction.ts new file mode 100644 index 0000000..612dc56 --- /dev/null +++ b/src/actions/display-object/SetVisibleAction.ts @@ -0,0 +1,19 @@ +import { Action } from '../../lib/Action'; + +export class SetVisibleAction extends Action { + public constructor( + protected readonly visible: boolean, + ) { + super(0); + } + + public reversed(): Action { + return new SetVisibleAction(!this.visible) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onTick(target: TargetNode): void { + target.visible = this.visible; + } +} diff --git a/src/actions/display-object/index.ts b/src/actions/display-object/index.ts new file mode 100644 index 0000000..01a2a0e --- /dev/null +++ b/src/actions/display-object/index.ts @@ -0,0 +1,3 @@ +export * from './RemoveFromParentAction'; +export * from './RunOnChildWithNameAction'; +export * from './SetVisibleAction'; diff --git a/src/actions/follow-path/FollowPathAction.ts b/src/actions/follow-path/FollowPathAction.ts new file mode 100644 index 0000000..c13f91f --- /dev/null +++ b/src/actions/follow-path/FollowPathAction.ts @@ -0,0 +1,146 @@ + +import { IActionTicker } from 'src/lib/IActionTicker'; +import { Action } from '../../lib/Action'; + +const HALF_PI = Math.PI / 2; + +export class FollowPathAction extends Action { + protected readonly path: VectorLike[]; + protected readonly lastIndex: number; + protected readonly segmentLengths!: number[]; + protected readonly segmentWeights!: number[]; + + public constructor( + path: VectorLike[], + duration: number, + protected readonly asOffset: boolean, + protected readonly orientToPath: boolean, + protected readonly fixedSpeed: boolean, + ) { + super(duration); + this.path = path; + this.lastIndex = path.length - 1; + + // Precalculate segment lengths, if needed. + if (orientToPath || fixedSpeed) { + const [totalDist, segmentLengths] = FollowPathAction.getLength(path); + this.segmentLengths = segmentLengths; + if (fixedSpeed) { + this.segmentWeights = segmentLengths.map(v => v / (totalDist || 1)); + } + } + } + + // ----- Static Helpers: ----- + + public static getPath(path: VectorLike[] | { points: VectorLike[] }): VectorLike[] { + return Array.isArray(path) ? [...path] : [...path.points]; + } + + public static getLength(path: VectorLike[]): [length: number, segmentLengths: number[]] { + let totalLength = 0; + const segmentLengths: number[] = []; + + for (let i = 0; i < path.length - 1; i++) { + const directionX = path[i + 1]!.x - path[i]!.x; + const directionY = path[i + 1]!.y - path[i]!.y; + const length = Math.sqrt(directionX * directionX + directionY * directionY); + + segmentLengths.push(length); + totalLength += length; + } + + return [totalLength, segmentLengths]; + } + + // ----- Methods: ----- + + public reversed(): Action { + return new FollowPathAction( + this._getReversedPath(), + this.duration, + this.asOffset, + this.orientToPath, + this.fixedSpeed, + ) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: any): any { + return { + x: this.asOffset ? target.x : 0, + y: this.asOffset ? target.y : 0, + }; + } + + protected onTick(target: any, t: number, dt: number, ticker: IActionTicker): void { + if (this.lastIndex < 0) { + return; // Empty path. + } + + const [index, st] = this.fixedSpeed + ? this._getFixedSpeedProgress(t) + : this._getDynamicSpeedProgress(t); + + const startPoint = this.path[index]!; + const endPoint = this.path[index + 1] ?? startPoint; + + target.position.set( + ticker.data.x + startPoint.x + (endPoint.x - startPoint.x) * st, + ticker.data.y + startPoint.y + (endPoint.y - startPoint.y) * st + ); + + if (this.orientToPath) { + const length = this.segmentLengths[index]! || 1; + const ndx = (endPoint.x - startPoint.x) / length; + const ndy = (endPoint.y - startPoint.y) / length; + const rotation = HALF_PI + Math.atan2(ndy, ndx); + + target.rotation = rotation; + } + } + + // ----- Internal: ----- + + protected _getReversedPath(): VectorLike[] { + if (this.asOffset && this.path.length > 0) { + // Calculate the relative delta offset when first and last are flipped. + const first = this.path[0]!, last = this.path[this.path.length - 1]!; + const dx = last.x + first.x, dy = last.y + first.y; + + return this.path.map(({x, y}) => ({ x: x - dx, y: y - dy })).reverse(); + } + + // Absolute path is the path backwards. + return [...this.path].reverse(); + } + + protected _getDynamicSpeedProgress(t: number): [index: number, segmentT: number] { + const index = Math.max(Math.min(Math.floor(t * this.lastIndex), this.lastIndex - 1), 0); + const lastIndexNonZero = this.lastIndex || 1; + const st = (t - index / lastIndexNonZero) * lastIndexNonZero; + + return [index, st]; + } + + protected _getFixedSpeedProgress(t: number): [index: number, segmentT: number] { + let remainingProgress = t; + let index = 0; + let st = 0; + + for (let i = 0; i < this.lastIndex; i++) { + const segmentWeight = this.segmentWeights[i]!; + + if (segmentWeight! > remainingProgress || i === this.lastIndex - 1) { + st = remainingProgress / segmentWeight || 1; + break; + } + + remainingProgress -= segmentWeight; + index++; + } + + return [index, st]; + } +} diff --git a/src/actions/follow-path/index.ts b/src/actions/follow-path/index.ts new file mode 100644 index 0000000..f9ce27c --- /dev/null +++ b/src/actions/follow-path/index.ts @@ -0,0 +1 @@ +export * from './FollowPathAction'; diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 0000000..4869a87 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,10 @@ +export * from './chainable'; +export * from './custom'; +export * from './delay'; +export * from './display-object'; +export * from './follow-path'; +export * from './move'; +export * from './rotate'; +export * from './scale'; +export * from './speed'; +export * from './transparency'; diff --git a/src/actions/move/MoveByAction.ts b/src/actions/move/MoveByAction.ts new file mode 100644 index 0000000..e3228f3 --- /dev/null +++ b/src/actions/move/MoveByAction.ts @@ -0,0 +1,23 @@ + +import { Action } from '../../lib/Action'; + +export class MoveByAction extends Action { + public constructor( + protected readonly x: number, + protected readonly y: number, + duration: number, + ) { + super(duration); + } + + public reversed(): Action { + return new MoveByAction(-this.x, -this.y, this.duration) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onTick(target: TargetNode, t: number, dt: number): void { + target.position.x += this.x * dt; + target.position.y += this.y * dt; + } +} diff --git a/src/actions/move/MoveToAction.ts b/src/actions/move/MoveToAction.ts new file mode 100644 index 0000000..78a8c7a --- /dev/null +++ b/src/actions/move/MoveToAction.ts @@ -0,0 +1,31 @@ +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { DelayAction } from '../delay'; + +export class MoveToAction extends Action { + public constructor( + protected readonly x: number | undefined, + protected readonly y: number | undefined, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new DelayAction(this.scaledDuration); + } + + protected onSetupTicker(target: TargetNode): any { + return { + startX: target.x, + startY: target.y + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.position.set( + this.x === undefined ? target.position.x : ticker.data.startX + (this.x - ticker.data.startX) * t, + this.y === undefined ? target.position.y : ticker.data.startY + (this.y - ticker.data.startY) * t + ); + } +} diff --git a/src/actions/move/index.ts b/src/actions/move/index.ts new file mode 100644 index 0000000..e2c5f85 --- /dev/null +++ b/src/actions/move/index.ts @@ -0,0 +1,2 @@ +export * from './MoveByAction'; +export * from './MoveToAction'; diff --git a/src/actions/rotate/RotateByAction.ts b/src/actions/rotate/RotateByAction.ts new file mode 100644 index 0000000..55ddb91 --- /dev/null +++ b/src/actions/rotate/RotateByAction.ts @@ -0,0 +1,20 @@ +import { Action } from '../../lib/Action'; + +export class RotateByAction extends Action { + public constructor( + protected readonly rotation: number, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new RotateByAction(-this.rotation, this.duration) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onTick(target: TargetNode, t: number, dt: number): void { + target.rotation += this.rotation * dt; + } +} diff --git a/src/actions/rotate/RotateToAction.ts b/src/actions/rotate/RotateToAction.ts new file mode 100644 index 0000000..cdfa044 --- /dev/null +++ b/src/actions/rotate/RotateToAction.ts @@ -0,0 +1,26 @@ +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { DelayAction } from '../delay'; + +export class RotateToAction extends Action { + public constructor( + protected readonly rotation: number, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new DelayAction(this.scaledDuration); + } + + protected onSetupTicker(target: TargetNode): any { + return { + startRotation: target.rotation + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.rotation = ticker.data.startRotation + (this.rotation - ticker.data.startRotation) * t; + } +} diff --git a/src/actions/rotate/index.ts b/src/actions/rotate/index.ts new file mode 100644 index 0000000..bf0f426 --- /dev/null +++ b/src/actions/rotate/index.ts @@ -0,0 +1,2 @@ +export * from './RotateByAction'; +export * from './RotateToAction'; diff --git a/src/actions/scale/ScaleByAction.ts b/src/actions/scale/ScaleByAction.ts new file mode 100644 index 0000000..533d219 --- /dev/null +++ b/src/actions/scale/ScaleByAction.ts @@ -0,0 +1,32 @@ +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; + +export class ScaleByAction extends Action { + public constructor( + protected readonly x: number, + protected readonly y: number, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new ScaleByAction(-this.x, -this.y, this.duration) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: TargetNode): any { + return { + dx: target.scale.x * this.x - target.scale.x, + dy: target.scale.y * this.y - target.scale.y + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.scale.set( + target.scale.x + ticker.data.dx * dt, + target.scale.y + ticker.data.dy * dt, + ); + } +} diff --git a/src/actions/scale/ScaleToAction.ts b/src/actions/scale/ScaleToAction.ts new file mode 100644 index 0000000..ff8fa10 --- /dev/null +++ b/src/actions/scale/ScaleToAction.ts @@ -0,0 +1,32 @@ + +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { DelayAction } from '../delay'; + +export class ScaleToAction extends Action { + public constructor( + protected readonly x: number | undefined, + protected readonly y: number | undefined, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new DelayAction(this.scaledDuration); + } + + protected onSetupTicker(target: TargetNode): any { + return { + startX: target.scale.x, + startY: target.scale.y + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.scale.set( + this.x === undefined ? target.scale.x : ticker.data.startX + (this.x - ticker.data.startX) * t, + this.y === undefined ? target.scale.y : ticker.data.startY + (this.y - ticker.data.startY) * t + ); + } +} diff --git a/src/actions/scale/ScaleToSizeAction.ts b/src/actions/scale/ScaleToSizeAction.ts new file mode 100644 index 0000000..a65e289 --- /dev/null +++ b/src/actions/scale/ScaleToSizeAction.ts @@ -0,0 +1,33 @@ +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { DelayAction } from '../delay'; + +export class ScaleToSizeAction extends Action { + public constructor( + protected readonly width: number, + protected readonly height: number, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new DelayAction(this.scaledDuration); + } + + protected onSetupTicker(target: SizedTargetNode): any { + if (target.width === undefined) { + throw new Error('Action can only be run against a target with a width & height.'); + } + + return { + sW: target.width, + sH: target.height, + }; + } + + protected onTick(target: SizedTargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.width = ticker.data.sW + (this.width - ticker.data.sW) * t; + target.height = ticker.data.sH + (this.height - ticker.data.sH) * t; + } +} diff --git a/src/actions/scale/index.ts b/src/actions/scale/index.ts new file mode 100644 index 0000000..7940b47 --- /dev/null +++ b/src/actions/scale/index.ts @@ -0,0 +1,3 @@ +export * from './ScaleByAction'; +export * from './ScaleToAction'; +export * from './ScaleToSizeAction'; diff --git a/src/actions/speed/SpeedByAction.ts b/src/actions/speed/SpeedByAction.ts new file mode 100644 index 0000000..596381d --- /dev/null +++ b/src/actions/speed/SpeedByAction.ts @@ -0,0 +1,20 @@ +import { Action } from '../../lib/Action'; + +export class SpeedByAction extends Action { + public constructor( + protected readonly _speed: number, + duration: TimeInterval, + ) { + super(duration); + } + + protected onTick(target: TargetNode, t: number, dt: number): void { + target.rotation += this._speed * dt; + } + + public reversed(): Action { + return new SpeedByAction(-this._speed, this.duration) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } +} diff --git a/src/actions/speed/SpeedToAction.ts b/src/actions/speed/SpeedToAction.ts new file mode 100644 index 0000000..5142558 --- /dev/null +++ b/src/actions/speed/SpeedToAction.ts @@ -0,0 +1,27 @@ + +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { DelayAction } from '../delay'; + +export class SpeedToAction extends Action { + public constructor( + protected readonly _speed: number, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new DelayAction(this.scaledDuration); + } + + protected onSetupTicker(target: TargetNode): any { + return { + startSpeed: target.speed + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.rotation = ticker.data.startRotation + (this._speed - ticker.data.startSpeed) * t; + } +} diff --git a/src/actions/speed/index.ts b/src/actions/speed/index.ts new file mode 100644 index 0000000..9fba845 --- /dev/null +++ b/src/actions/speed/index.ts @@ -0,0 +1,2 @@ +export * from './SpeedByAction'; +export * from './SpeedToAction'; diff --git a/src/actions/transparency/FadeByAction.ts b/src/actions/transparency/FadeByAction.ts new file mode 100644 index 0000000..4250d00 --- /dev/null +++ b/src/actions/transparency/FadeByAction.ts @@ -0,0 +1,21 @@ + +import { Action } from '../../lib/Action'; + +export class FadeByAction extends Action { + public constructor( + protected readonly alpha: number, + duration: TimeInterval, + ) { + super(duration); + } + + public reversed(): Action { + return new FadeByAction(-this.alpha, this.duration) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onTick(target: TargetNode, t: number, dt: number): void { + target.alpha += this.alpha * dt; + } +} diff --git a/src/actions/transparency/FadeInAction.ts b/src/actions/transparency/FadeInAction.ts new file mode 100644 index 0000000..6b53083 --- /dev/null +++ b/src/actions/transparency/FadeInAction.ts @@ -0,0 +1,21 @@ +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { FadeOutAction } from './FadeOutAction'; + +export class FadeInAction extends Action { + public reversed(): Action { + return new FadeOutAction(this.duration) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: TargetNode): any { + return { + startAlpha: target.alpha + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.alpha = ticker.data.startAlpha + (1.0 - ticker.data.startAlpha) * t; + } +} diff --git a/src/actions/transparency/FadeOutAction.ts b/src/actions/transparency/FadeOutAction.ts new file mode 100644 index 0000000..0667987 --- /dev/null +++ b/src/actions/transparency/FadeOutAction.ts @@ -0,0 +1,22 @@ + +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { FadeInAction } from './FadeInAction'; + +export class FadeOutAction extends Action { + public reversed(): Action { + return new FadeInAction(this.duration) + .setTimingMode(this.timingMode) + .setSpeed(this.speed); + } + + protected onSetupTicker(target: TargetNode): any { + return { + startAlpha: target.alpha + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.alpha = ticker.data.startAlpha + (0.0 - ticker.data.startAlpha) * t; + } +} diff --git a/src/actions/transparency/FadeToAction.ts b/src/actions/transparency/FadeToAction.ts new file mode 100644 index 0000000..11936b2 --- /dev/null +++ b/src/actions/transparency/FadeToAction.ts @@ -0,0 +1,27 @@ + +import { Action } from '../../lib/Action'; +import { IActionTicker } from '../../lib/IActionTicker'; +import { DelayAction } from '../delay'; + +export class FadeToAction extends Action { + public constructor( + protected readonly alpha: number, + duration: TimeInterval + ) { + super(duration); + } + + public reversed(): Action { + return new DelayAction(this.scaledDuration); + } + + protected onSetupTicker(target: TargetNode): any { + return { + startAlpha: target.alpha + }; + } + + protected onTick(target: TargetNode, t: number, dt: number, ticker: IActionTicker): void { + target.alpha = ticker.data.startAlpha + (this.alpha - ticker.data.startAlpha) * t; + } +} diff --git a/src/actions/transparency/index.ts b/src/actions/transparency/index.ts new file mode 100644 index 0000000..18b2a48 --- /dev/null +++ b/src/actions/transparency/index.ts @@ -0,0 +1,4 @@ +export * from './FadeByAction'; +export * from './FadeInAction'; +export * from './FadeOutAction'; +export * from './FadeToAction'; diff --git a/src/index.ts b/src/index.ts index d8eef6e..4aedbb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,34 @@ - import * as PIXI from 'pixi.js'; -import { Action, registerGlobalMixin } from "./Action"; + +import { _ as Action } from "./Action"; +import { TimingMode, TimingModeFn } from "./TimingMode"; +import { registerGlobalMixin } from './mixin'; // -// ----- Library exports ----- +// ----- [Side-effect] Initialize global mixin for DisplayObject: ----- // -export * from "./Action"; -export * from "./TimingMode"; +registerGlobalMixin(PIXI.DisplayObject); // -// ----- [Side-effect] Load global mixin: ----- +// ----- PixiJS Actions library: ----- // -registerGlobalMixin(PIXI.DisplayObject); +export { + Action, + registerGlobalMixin, + TimingMode, + TimingModeFn, +}; // -// ----- Additional types and documentation for the global mixin: ----- +// ----- Types and documentation for the global mixin: ----- // declare module 'pixi.js' { + export interface DisplayObject { + /** * A boolean value that determines whether actions on the node and its descendants are processed. */ @@ -34,7 +42,7 @@ declare module 'pixi.js' { /** * Adds an action to the list of actions executed by the node. * - * The new action is processed the next time the scene’s animation loop is processed. + * The new action is processed the next time the canvas's animation loop is processed. * * After the action completes, your completion block is called, but only if the action runs to * completion. If the action is removed before it completes, the completion handler is never @@ -59,7 +67,7 @@ declare module 'pixi.js' { /** * Adds an action to the list of actions executed by the node. * - * The new action is processed the next time the scene’s animation loop is processed. + * The new action is processed the next time the canvas's animation loop is processed. * * Runs the action as a promise. * @@ -87,4 +95,5 @@ declare module 'pixi.js' { */ removeAction(forKey: string): void; } + } diff --git a/src/lib/Action.ts b/src/lib/Action.ts new file mode 100644 index 0000000..d196b8c --- /dev/null +++ b/src/lib/Action.ts @@ -0,0 +1,159 @@ +import { TimingMode, TimingModeFn } from '../TimingMode'; +import { IActionTicker } from './IActionTicker'; + +export abstract class Action { + + // ----- Default Timing Modes: ----- + + protected static _defaultTimingModeEaseIn = TimingMode.easeInSine; + protected static _defaultTimingModeEaseOut = TimingMode.easeOutSine; + protected static _defaultTimingModeEaseInOut = TimingMode.easeInOutSine; + + // + // ----------------- Action: ----------------- + // + + protected constructor( + /** The duration required to complete an action. */ + public readonly duration: TimeInterval, + /** A speed factor that modifies how fast an action runs. */ + public speed: number = 1.0, + /** A setting that controls the speed curve of an animation. */ + public timingMode: TimingModeFn = TimingMode.linear, + /** @deprecated A global category bitmask which can be used to group actions. */ + public categoryMask: number = 0x1, + ) {} + + /** Duration of the action after its local speed scalar is applied. */ + public get scaledDuration(): number { + return this.duration / this.speed; + } + + /** + * @deprecated To be removed soon. Modify node and action speed directly instead. + * + * Set a category mask for this action. + * Use this to tick different categories of actions separately (e.g. separate different UI). + * + * This function mutates the underlying action. + */ + public setCategory(categoryMask: number): this { + this.categoryMask = categoryMask; + return this; + } + + /** + * Set the action's speed scale. Default: `1.0`. + * + * This function mutates the underlying action. + */ + public setSpeed(speed: number): this { + this.speed = speed; + return this; + } + + /** + * Adjust the speed curve of an animation. Default: `TimingMode.linear`. + * + * This function mutates the underlying action. + * + * @see {TimingMode} + */ + public setTimingMode(timingMode: TimingModeFn): this { + this.timingMode = timingMode; + return this; + } + + // + // ----------------- Default TimingMode Shortcuts: ----------------- + // + + /** + * Default `timingMode`. Sets the speed curve of the action to linear pacing. Linear pacing causes + * an animation to occur evenly over its duration. + * + * This function mutates the underlying action. + * + * @see {TimingMode.linear} + */ + public linear(): this { + return this.setTimingMode(TimingMode.linear); + } + + /** + * Sets the speed curve of the action to the default ease-in pacing. Ease-in pacing causes the + * animation to begin slowly and then speed up as it progresses. + * + * This function mutates the underlying action. + * + * @see {Action.DefaultTimingModeEaseIn} + */ + public easeIn(): this { + return this.setTimingMode(Action._defaultTimingModeEaseIn); + } + + /** + * Sets the speed curve of the action to the default ease-out pacing. Ease-out pacing causes the + * animation to begin quickly and then slow as it completes. + * + * This function mutates the underlying action. + * + * @see {Action.DefaultTimingModeEaseOut} + */ + public easeOut(): this { + return this.setTimingMode(Action._defaultTimingModeEaseOut); + } + + /** + * Sets the speed curve of the action to the default ease-in, ease-out pacing. Ease-in, ease-out + * pacing causes the animation to begin slowly, accelerate through the middle of its duration, + * and then slow again before completing. + * + * This function mutates the underlying action. + * + * @see {Action.DefaultTimingModeEaseInOut} + */ + public easeInOut(): this { + return this.setTimingMode(Action._defaultTimingModeEaseInOut); + } + + // + // ----------------- Action Ticker Methods: ----------------- + // + + /** (optional) */ + protected onSetupTicker(target: TargetNode, ticker: IActionTicker): any { + return undefined; + } + + /** (optional) */ + protected onTickerDidReset(ticker: IActionTicker): any { + return undefined; + } + + /** + * Creates an action that reverses the behavior of another action. + * + * This method always returns an action object; however, not all actions are reversible. + * When reversed, some actions return an object that either does nothing or that performs the same + * action as the original action. + */ + public abstract reversed(): Action; + + /** + * Update function for the action. + * + * @param target The affected display object. + * @param t The elapsed progress of the action, with the timing mode function applied. Generally a scalar number between 0.0 and 1.0. + * @param dt Relative change in progress since the previous animation change. Use this for relative actions. + * @param ticker The action ticker running this update. + * @param deltaTime The amount of time elapsed in this tick. This number is scaled by both speed of target and any parent actions. + */ + protected abstract onTick( + target: TargetNode, + t: number, + dt: number, + ticker: IActionTicker, + deltaTime: number + ): void; +} diff --git a/src/lib/ActionTicker.ts b/src/lib/ActionTicker.ts new file mode 100644 index 0000000..6a84d9d --- /dev/null +++ b/src/lib/ActionTicker.ts @@ -0,0 +1,299 @@ +import { Action } from "./Action"; +import { TimingModeFn } from "../TimingMode"; +import { getIsPaused, getSpeed } from "./utils/displayobject"; + +const EPSILON = 0.0000000001; +const EPSILON_ONE = 1 - EPSILON; + +/** + * An internal utility class that runs (or "ticks") stateless + * actions for the duration of their lifespan. + */ +export class ActionTicker { + + /** All currently executing actions. */ + protected static _running: ActionTicker[] = []; + + // + // ----- Static Methods: ----- + // + + /** Adds an action to the list of actions executed by the node. */ + public static runAction(key: string | undefined, target: TargetNode, action: Action): void { + if (key !== undefined) { + // Stop any existing, identical-keyed actions on insert. + ActionTicker.removeTargetActionForKey(target, key); + } + + this._running.push(new ActionTicker(key, target, action)); + } + + /** Whether a target has any actions. */ + public static hasTargetActions(target: TargetNode): boolean { + return ActionTicker._running.find(at => at.target === target) !== undefined; + } + + /** Retrieve an action with a key from a specific target. */ + public static getTargetActionForKey(target: TargetNode, key: string): Action | undefined { + return this._getTargetActionTickerForKey(target, key)?.action; + } + + /** Remove an action with a key from a specific target. */ + public static removeTargetActionForKey(target: TargetNode, key: string): void { + const actionTicker = this._getTargetActionTickerForKey(target, key); + + if (!actionTicker) { + return; + } + + ActionTicker._removeActionTicker(actionTicker); + } + + /** Remove all actions for a specific target. */ + public static removeAllTargetActions(target: TargetNode): void { + for (let i = ActionTicker._running.length - 1; i >= 0; i--) { + const actionTicker = ActionTicker._running[i]; + + if (actionTicker.target === target) { + ActionTicker._removeActionTicker(actionTicker); + } + } + } + + /** + * Tick all actions forward. + * + * @param deltaTimeMs Delta time given in milliseconds. + * @param categoryMask (Optional) Bitmask to filter which categories of actions to update. + * @param onErrorHandler (Optional) Handler errors from each action's tick. + */ + public static tickAll( + deltaTimeMs: number, + categoryMask: number | undefined = undefined, + onErrorHandler?: (error: any) => void + ): void { + const deltaTime = deltaTimeMs * 0.001; + + for (let i = ActionTicker._running.length - 1; i >= 0; i--) { + const actionTicker = ActionTicker._running[i]; + + if (categoryMask !== undefined && (categoryMask & actionTicker.action.categoryMask) === 0) { + continue; + } + + if (getIsPaused(actionTicker.target)) { + continue; + } + + try { + actionTicker.tick(deltaTime * getSpeed(actionTicker.target)); + } + catch (error) { + // Isolate individual action errors. + if (onErrorHandler === undefined) { + console.error('Action failed with error: ', error); + } + else { + onErrorHandler(error); + } + + // Remove offending ticker. + ActionTicker._removeActionTicker(actionTicker); + } + } + } + + /** Retrieve the ticker for an action with a key from a specific target. */ + protected static _getTargetActionTickerForKey( + target: TargetNode, + key: string + ): ActionTicker | undefined { + return ActionTicker._running.find(a => a.target === target && a.key === key); + } + + /** Remove an action ticker for a target. */ + protected static _removeActionTicker(actionTicker: ActionTicker): ActionTicker { + const index = ActionTicker._running.indexOf(actionTicker); + if (index >= 0) { + ActionTicker._running.splice(index, 1); + } + return actionTicker; + } + + // + // ----- Properties: ----- + // + + /** + * Relative speed of the action ticker. + * + * Copy-on-run: Copies the action's `speed` when the action is run. + */ + public speed: number; + + /** + * Relative speed of the action ticker. + * + * Copy-on-run: Copies the action's `timingMode` when the action is run. + */ + public timingMode: TimingModeFn; + + /** + * Expected duration of the action ticker. + * + * Copy-on-run: Copies the action's `scaledDuration` when the action is run. + */ + public scaledDuration: number; + + /** + * Any instance data that will live for the duration of the ticker. + * + * @see {Action.onSetupTicker()} + */ + public data: any; + + /** + * Whether the action has completed. + * + * Needs to be manually updated when `this.autoComplete = false`. + * + * Used by chainable actions. + */ + public isDone: boolean = false; + + /** + * Whether the action ticker will mark the action as done when time + * `this._elapsed >= this.scaledDuration`. + * + * Disable to manually control when `isDone` is triggered. + * + * Used by chainable actions. + */ + public autoComplete: boolean = true; + + // + // ----- Private properties: ----- + // + + /** Time elapsed in the action. */ + protected _elapsed: number = 0.0; + + /** + * Whether the action ticker has been setup. + * + * Triggered on the first iteration to copy-on-run the attributes + * from the action to the ticker. + */ + protected _isSetup = false; + + // + // ----- Constructor: ----- + // + + public constructor( + public key: string | undefined, + public target: TargetNode, + public action: Action, + ) { + this.speed = action.speed; + this.scaledDuration = action.scaledDuration; + this.timingMode = action.timingMode; + } + + // + // ----- Accessors: ----- + // + + /** The relative time elapsed between 0 and 1. */ + public get timeDistance(): number { + return this.scaledDuration === 0 ? 1 : Math.min(1, this._elapsed / this.scaledDuration); + } + + /** + * The relative time elapsed between 0 and 1, eased by the timing mode function. + * + * Can be a value beyond 0 or 1 depending on the timing mode function. + */ + protected get easedTimeDistance(): number { + return this.timingMode(this.timeDistance); + } + + // + // ----- Methods: ----- + // + + /** @returns Any unused time delta. Negative value means action is still in progress. */ + public tick(deltaTime: number): number { + if (!this._isSetup) { + // Copy action attributes: + this.speed = this.action.speed; + this.scaledDuration = this.action.duration; + this.timingMode = this.action.timingMode; + + // Perform first time setup: + this.data = (this.action as any).onSetupTicker(this.target, this); + this._isSetup = true; + } + + const target = this.target; + const action = this.action; + + // If action no longer valid, or target not on the stage + // we garbage collect its actions. + if ( + target == null + || target.destroyed + || target.parent === undefined + ) { + ActionTicker._removeActionTicker(this); + + return; + } + + const scaledTimeDelta = deltaTime * this.speed /* target speed is applied at the root */; + + if (this.scaledDuration === 0) { + // Instantaneous action. + (action as any).onTick(this.target, 1.0, 1.0, this, scaledTimeDelta); + this.isDone = true; + + // Remove completed action. + ActionTicker._removeActionTicker(this); + + return deltaTime; // relinquish the full time. + } + + if (deltaTime === 0) { + return -1; // Early exit, no progress. + } + + const b = this.easedTimeDistance; + this._elapsed += scaledTimeDelta; + const t = this.easedTimeDistance; + const dt = t - b; + + (action as any).onTick(this.target, t, dt, this, scaledTimeDelta); + + if (this.isDone || (this.autoComplete && this.timeDistance >= EPSILON_ONE)) { + this.isDone = true; + + // Remove completed action. + ActionTicker._removeActionTicker(this); + + return this._elapsed > this.scaledDuration ? this._elapsed - this.scaledDuration : 0; + } + + return -1; // relinquish no time + } + + /** + * Reset the ticker for this run. + * + * Used by chainable actions to reset their child action's tickers. + */ + public reset(): void { + this._elapsed = 0.0; + this.isDone = false; + (this.action as any).onTickerDidReset(this); + } +} diff --git a/src/lib/IActionTicker.ts b/src/lib/IActionTicker.ts new file mode 100644 index 0000000..d36d6a6 --- /dev/null +++ b/src/lib/IActionTicker.ts @@ -0,0 +1,20 @@ +import { TimingModeFn } from "../TimingMode"; + +export interface IActionTicker { + // Action write-on-run settings: + readonly scaledDuration: number; + readonly speed: number; + readonly timingMode: TimingModeFn; + + // Iteration: + readonly timeDistance: number; + + // State: + autoComplete: boolean; + isDone: boolean; + data: any; + + // Methods: + tick(deltaTime: number): number; + reset(): void; +} diff --git a/src/util.ts b/src/lib/utils/displayobject.ts similarity index 100% rename from src/util.ts rename to src/lib/utils/displayobject.ts diff --git a/src/mixin.ts b/src/mixin.ts new file mode 100644 index 0000000..aa89752 --- /dev/null +++ b/src/mixin.ts @@ -0,0 +1,64 @@ +import { ActionTicker } from "./lib/ActionTicker"; +import { _ as Action } from "./Action"; +import { getSpeed } from "./lib/utils/displayobject"; + +// +// ----- Global Mixin: ----- +// + +/** + * Register the global mixins for PIXI.DisplayObject. + * + * @param displayObject A reference to `PIXI.DisplayObject`. + */ +export function registerGlobalMixin(displayObject: any): void { + const prototype = displayObject.prototype; + + // - Properties: + + prototype.speed = 1.0; + prototype.isPaused = false; + + // - Methods: + + prototype.run = function (_action: Action, completion?: () => void): void { + const action = completion ? Action.sequence([_action, Action.run(completion)]) : _action; + ActionTicker.runAction(undefined, this, action); + }; + + prototype.runWithKey = function (action: Action, key: string): void { + ActionTicker.runAction(key, this, action); + }; + + prototype.runAsPromise = function ( + action: Action, + timeoutBufferMs: number = 100 + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const node = this; + return new Promise(function (resolve, reject) { + const timeLimitMs = timeoutBufferMs + (getSpeed(node) * action.duration * 1_000); + const timeoutCheck = setTimeout(() => reject('Took too long to complete.'), timeLimitMs); + node.run(action, () => { + clearTimeout(timeoutCheck); + resolve(); + }); + }); + }; + + prototype.action = function (forKey: string): Action | undefined { + return ActionTicker.getTargetActionForKey(this, forKey); + }; + + prototype.hasActions = function (): boolean { + return ActionTicker.hasTargetActions(this); + }; + + prototype.removeAllActions = function (): void { + ActionTicker.removeAllTargetActions(this); + }; + + prototype.removeAction = function (forKey: string): void { + ActionTicker.removeTargetActionForKey(this, forKey); + }; +} diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..1789999 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,33 @@ +// typealiases + +declare global { + + /** Time measured in seconds. */ + type TimeInterval = number; + + /** Targeted display node. */ + type TargetNode = PIXI.DisplayObject; + + /** Targeted display node that has a width/height. */ + type SizedTargetNode = TargetNode & SizeLike; + + /** A vector (e.g. PIXI.Point, or any node). */ + interface VectorLike { + x: number; + y: number; + } + + /** Any object with a width and height. */ + interface SizeLike { + width: number; + height: number; + } + + /** Any object containing an array of points (e.g. PIXI.SimpleRope). */ + interface PathObjectLike { + points: VectorLike[]; + } + +} + +export {}; diff --git a/tsconfig.json b/tsconfig.json index 92988e3..1962ca0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "noImplicitAny": true, "module": "es6", "target": "es6", - "allowJs": true, + "allowJs": false, "moduleResolution": "node", "downlevelIteration": true, "declaration": true, @@ -14,6 +14,6 @@ "baseUrl": "./", "skipLibCheck": true }, - "include": ["src/**/*"], - "exclude": ["src/test/**/*"] + "include": ["src/*", "src/**/*", "src/**/**/*"], + "exclude": ["src/__tests__/**/*"] } \ No newline at end of file