Skip to content

Commit

Permalink
feat: Add ex.BezierCurve and CurveBy/CurveTo actions (#3282)
Browse files Browse the repository at this point in the history
https://github.com/user-attachments/assets/e2bdff59-7551-4652-a4ba-7ce78d7eeaf8



This PR implements new CurveTo/CurveBy action for moving actors in a Bezier curve

```typescript
const curve = new ex.BezierCurve({
  controlPoints: [ex.vec(0, 700), ex.vec(100, -300), ex.vec(150, 800), ex.vec(500, 100)],
  quality: 10
});

actor.actions.repeatForever((ctx) => {
  ctx.curveTo({
    controlPoints: [ex.vec(100, -300), ex.vec(150, 800), ex.vec(500, 100)],
    durationMs: 6000
  });
  ctx.curveBy({
    controlPoints: [ex.vec(100, 0), ex.vec(-100, 0), ex.vec(0, 300)],
    durationMs: 1000
  });
  ctx.curveTo({
    controlPoints: [ex.vec(150, 800), ex.vec(100, -300), ex.vec(0, 700)],
    durationMs: 6000
  });
});
```
  • Loading branch information
eonarheim authored Nov 20, 2024
1 parent ac01dd4 commit 9176e5b
Show file tree
Hide file tree
Showing 13 changed files with 624 additions and 2 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@
"typescript.tsdk": "./node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"deno.enable": false
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added

- Added new `ex.BezierCurve` type for drawing cubic bezier curves
- Added 2 new actions `actor.actions.curveTo(...)` and `actor.actions.curveBy(...)`
- Added new `ex.lerp(...)`, `ex.inverseLerp(...)`, and `ex.remap(...)` for numbers
- Added new `ex.lerpVector(...)`,` ex.inverseLerpVector(...)`, and `ex.remapVector(...)` for `ex.Vector`
- Added new `actor.actions.flash(...)` `Action` to flash a color for a period of time
Expand Down
12 changes: 12 additions & 0 deletions sandbox/tests/bezier/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bezier Curve</title>
</head>
<body>
<script src="../../lib/excalibur.js"></script>
<script src="./index.js"></script>
</body>
</html>
82 changes: 82 additions & 0 deletions sandbox/tests/bezier/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
var game = new ex.Engine({
width: 600,
height: 400,
displayMode: ex.DisplayMode.FillScreen
});
game.toggleDebug();

var curve = new ex.BezierCurve({
controlPoints: [ex.vec(0, 700), ex.vec(100, -300), ex.vec(150, 800), ex.vec(500, 100)],
quality: 10
});

var reverseCurve = curve.clone();
reverseCurve.controlPoints = [...reverseCurve.controlPoints].reverse() as any;

var actor = new ex.Actor({
pos: ex.vec(500, 500),
width: 100,
height: 100,
color: ex.Color.Red,
angularVelocity: 1
});
game.add(actor);

actor.actions.repeatForever((ctx) => {
ctx.curveTo({
controlPoints: [ex.vec(100, -300), ex.vec(150, 800), ex.vec(500, 100)],
durationMs: 6000
});
ctx.curveBy({
controlPoints: [ex.vec(100, 0), ex.vec(-100, 0), ex.vec(0, 300)],
durationMs: 1000
});
ctx.curveTo({
controlPoints: [ex.vec(150, 800), ex.vec(100, -300), ex.vec(0, 700)],
durationMs: 6000
});
});

var time = 0;
var points: ex.Vector[] = [];
game.onPostDraw = (ctx: ex.ExcaliburGraphicsContext, elapsedMs) => {
if (time < 5000) {
var t = ex.clamp(ex.remap(0, 5000, 0, 1, time), 0, 1);

var p = curve.getPoint(t);
ctx.drawCircle(p, 20, ex.Color.Red);
var p2 = curve.getUniformPoint(t);
ctx.drawCircle(p2, 20, ex.Color.Purple);
points.push(p2);

var tangent = curve.getTangent(t);
var normal = curve.getNormal(t);
ex.Debug.drawRay(new ex.Ray(p, tangent), {
distance: 100,
color: ex.Color.Yellow
});
ex.Debug.drawRay(new ex.Ray(p, normal), {
distance: 100,
color: ex.Color.Green
});

var uTangent = curve.getUniformTangent(t);
var uNormal = curve.getUniformNormal(t);
ex.Debug.drawRay(new ex.Ray(p2, uTangent), {
distance: 100,
color: ex.Color.Yellow
});
ex.Debug.drawRay(new ex.Ray(p2, uNormal), {
distance: 100,
color: ex.Color.Green
});

time += elapsedMs;
}

for (let i = 0; i < points.length - 1; i++) {
ctx.drawLine(points[i], points[i + 1], ex.Color.Purple, 2);
}
};

game.start();
86 changes: 86 additions & 0 deletions src/engine/Actions/Action/CurveBy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Entity, TransformComponent } from '../../EntityComponentSystem';
import { BezierCurve, clamp, remap, vec, Vector } from '../../Math';
import { Action, nextActionId } from '../Action';

export interface CurveByOptions {
/**
* Curve relative to the current actor position to move
*/
controlPoints: [control1: Vector, control2: Vector, end: Vector];
/**
* Total duration for the action to run
*/
durationMs: number;
/**
* Dynamic mode will speed up/slow down depending on the curve
*
* Uniform mode will animate at a consistent velocity across the curve
*
* Default: 'dynamic'
*/
mode?: 'dynamic' | 'uniform';

quality?: number;
}

export class CurveBy implements Action {
id: number = nextActionId();

private _curve: BezierCurve;
private _durationMs: number;
private _entity: Entity<any>;
private _tx: TransformComponent;
private _currentMs: number;
private _started = false;
private _stopped = false;
private _mode: 'dynamic' | 'uniform' = 'dynamic';
constructor(entity: Entity, options: CurveByOptions) {
this._entity = entity;
this._tx = this._entity.get(TransformComponent);
if (!this._tx) {
throw new Error(`Entity ${entity.name} has no TransformComponent, can only curveTo on Entities with TransformComponents.`);
}
this._curve = this._curve = new BezierCurve({
controlPoints: [vec(0, 0), ...options.controlPoints],
quality: options.quality
});
this._durationMs = options.durationMs;
this._mode = options.mode ?? this._mode;
this._currentMs = this._durationMs;
}

update(elapsedMs: number): void {
if (!this._started) {
this._curve.setControlPoint(0, this._tx.globalPos);
this._curve.setControlPoint(1, this._curve.controlPoints[1].add(this._tx.globalPos));
this._curve.setControlPoint(2, this._curve.controlPoints[2].add(this._tx.globalPos));
this._curve.setControlPoint(3, this._curve.controlPoints[3].add(this._tx.globalPos));
this._started = true;
}
const t = clamp(remap(0, this._durationMs, 0, 1, this._durationMs - this._currentMs), 0, 1);
if (this._mode === 'dynamic') {
this._tx.pos = this._curve.getPoint(t);
} else {
this._tx.pos = this._curve.getUniformPoint(t);
}
this._currentMs -= elapsedMs;
if (this.isComplete(this._entity)) {
if (this._mode === 'dynamic') {
this._tx.pos = this._curve.getPoint(1);
} else {
this._tx.pos = this._curve.getUniformPoint(1);
}
}
}
isComplete(entity: Entity): boolean {
return this._stopped || this._currentMs < 0;
}
reset(): void {
this._currentMs = this._durationMs;
this._started = false;
this._stopped = false;
}
stop(): void {
this._stopped = true;
}
}
84 changes: 84 additions & 0 deletions src/engine/Actions/Action/CurveTo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Entity, TransformComponent } from '../../EntityComponentSystem';
import { BezierCurve, clamp, remap, vec, Vector } from '../../Math';
import { Action, nextActionId } from '../Action';

export interface CurveToOptions {
/**
* Curve in world coordinates to animate towards
*
* The start control point is assumed to be the actor's current position
*/
controlPoints: [control1: Vector, control2: Vector, end: Vector];
/**
* Total duration for the action to run
*/
durationMs: number;
/**
* Dynamic mode will speed up/slow down depending on the curve
*
* Uniform mode will animate at a consistent velocity across the curve
*
* Default: 'dynamic'
*/
mode?: 'dynamic' | 'uniform';
quality?: number;
}

export class CurveTo implements Action {
id: number = nextActionId();

private _curve: BezierCurve;
private _durationMs: number;
private _entity: Entity<any>;
private _tx: TransformComponent;
private _currentMs: number;
private _started = false;
private _stopped = false;
private _mode: 'dynamic' | 'uniform' = 'dynamic';
constructor(entity: Entity, options: CurveToOptions) {
this._entity = entity;
this._tx = this._entity.get(TransformComponent);
if (!this._tx) {
throw new Error(`Entity ${entity.name} has no TransformComponent, can only curveTo on Entities with TransformComponents.`);
}
this._curve = new BezierCurve({
controlPoints: [vec(0, 0), ...options.controlPoints],
quality: options.quality
});
this._durationMs = options.durationMs;
this._mode = options.mode ?? this._mode;
this._currentMs = this._durationMs;
}

update(elapsedMs: number): void {
if (!this._started) {
this._curve.setControlPoint(0, this._tx.globalPos.clone());
this._started = true;
}
const t = clamp(remap(0, this._durationMs, 0, 1, this._durationMs - this._currentMs), 0, 1);
if (this._mode === 'dynamic') {
this._tx.pos = this._curve.getPoint(t);
} else {
this._tx.pos = this._curve.getUniformPoint(t);
}
this._currentMs -= elapsedMs;
if (this.isComplete(this._entity)) {
if (this._mode === 'dynamic') {
this._tx.pos = this._curve.getPoint(1);
} else {
this._tx.pos = this._curve.getUniformPoint(1);
}
}
}
isComplete(entity: Entity): boolean {
return this._stopped || this._currentMs < 0;
}
reset(): void {
this._currentMs = this._durationMs;
this._started = false;
this._stopped = false;
}
stop(): void {
this._currentMs = 0;
}
}
22 changes: 22 additions & 0 deletions src/engine/Actions/ActionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { Entity } from '../EntityComponentSystem/Entity';
import { Action } from './Action';
import { Color } from '../Color';
import { Flash } from './Action/Flash';
import { CurveTo, CurveToOptions } from './Action/CurveTo';
import { CurveBy, CurveByOptions } from './Action/CurveBy';

/**
* The fluent Action API allows you to perform "actions" on
Expand Down Expand Up @@ -61,6 +63,26 @@ export class ActionContext {
return this;
}

/**
* Animates an actor with a specified bezier curve, overrides the first control point
* to be the actor's current position.
* @param options
*/
public curveBy(options: CurveByOptions): ActionContext {
this._queue.add(new CurveBy(this._entity, options));
return this;
}

/**
* Animates an actor with a specified bezier curve, overrides the first control point
* to be the actor's current position.
* @param options
*/
public curveTo(options: CurveToOptions): ActionContext {
this._queue.add(new CurveTo(this._entity, options));
return this;
}

/**
* This method will move an actor to the specified `x` and `y` position over the
* specified duration using a given {@apilink EasingFunctions} and return back the actor. This
Expand Down
10 changes: 10 additions & 0 deletions src/engine/Actions/ActionsComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { ActionQueue } from './ActionQueue';
import { RotationType } from './RotationType';
import { Action } from './Action';
import { Color } from '../Color';
import { CurveToOptions } from './Action/CurveTo';
import { CurveByOptions } from './Action/CurveBy';

export interface ActionContextMethods extends Pick<ActionContext, keyof ActionContext> {}

Expand Down Expand Up @@ -65,6 +67,14 @@ export class ActionsComponent extends Component implements ActionContextMethods
this._ctx?.clearActions();
}

public curveBy(options: CurveByOptions): ActionContext {
return this._getCtx().curveBy.apply(this._ctx, [options]);
}

public curveTo(options: CurveToOptions): ActionContext {
return this._getCtx().curveTo.apply(this._ctx, [options]);
}

/**
* This method will move an actor to the specified `x` and `y` position over the
* specified duration using a given {@apilink EasingFunctions} and return back the actor. This
Expand Down
3 changes: 2 additions & 1 deletion src/engine/Actions/Index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from './Action/ScaleBy';
export * from './Action/ScaleTo';
export * from './Action/Delay';
export * from './Action/Flash';

export * from './Action/CurveTo';
export * from './Action/CurveBy';
export * from './ActionsComponent';
export * from './ActionsSystem';
Loading

0 comments on commit 9176e5b

Please sign in to comment.