Immediate Easing Chain

This document was written in order to understand easing chains in immediate mode, i.e. without object-oriented tween systems like what Phaser JS (scene.tweens), Unity (DOTween), and Roblox (TweenService) have

This approach can be applied to any other frameworks that don’t have any built-in tweening library included, e.g. PICO-8, TIC-80, SDL2, and Raylib

If you wish to skip reading & see the demonstration right away, here’s the game in action:

 

Controls

Click Start Lerp to watch Blinky’s animation chain (move, move backwards, spin)

After the chain completes, use WASD to move him freely!

Variable Declaration

Before we start on the update & render logic, let’s declare the variables first

Prepare the state variables, which will contain the [started], [complete] and [index in the chain]

var
  isChainStarted, isChainComplete: boolean;
  chainIdx: integer;

Declare the state variables that you want to apply easing to

var
  startX, endX: integer;
  startAngle, endAngle: double;
  chainLerpTimer: TLerpTimer;

The key here is that you must have at least:

  • Start & end variable pair
  • 1 TLerpTimer

This pattern below can work too, if you only want to interpolate x:

var
  startX, endX: integer;
  chainLerpTimer: TLerpTimer;

Starting logic

This section should only handle chain state initialisation

procedure beginEasingChain;
begin
  isChainStarted := true;
  isChainComplete := false;
  chainIdx := 0;

  startX := 100; endX := 150;
  initLerp(chainLerpTimer, getTimer, 1.0)
end;

It has the [started], [complete] and the [index in the chain], and also the variables to interpolate later
Don’t forget to initialise the TLerpTimer

Update logic

Update logic only handles state transition

Given this example:

if isChainStarted and not isChainComplete then begin
  { Handle state transition }
  if isLerpComplete(chainLerpTimer, getTimer) then begin
    case chainIdx of
      0: begin
        { Initialise your state here }
      end;
      1: begin
        { Same pattern as index 0 }
        inc(chainIdx)
      end;
      
      2: inc(chainIdx); { Immediate transition, no setup needed }
      
      3: begin
        { Handle chain onComplete }

        isChainStarted := false;
        isChainComplete := true;
      end;
    end;
  end;
end;

This shows the transition from chainIdx 0 to 1

Your state initialisation in case chainIdx of 0 can be structured like this:

perc := getLerpPerc(chainLerpTimer, getTimer);
x := lerpEaseOutSine(startX, endX, perc);  { current X }

startX := trunc(x);
endX := endX - 50;
initLerp(chainLerpTimer, getTimer, 1.0);

inc(chainIdx)

Basically:

  • Initialise the state variables,
  • Initialise the TLerpTimer associated with it,
  • Increment chain index, or
    • Store the final state somewhere and assign complete

Movement logic

This can be handled when the easing chain is not in progress

This condition can be used to check if it’s still in progress:

if isChainStarted and not isChainComplete then

or shorter:

if not isChainStarted then

An example here is for when you want to move Blinky:

if not isChainStarted then begin
  if isKeyDown(SC_W) then blinkyY := blinkyY - Velocity * dt;
  if isKeyDown(SC_S) then blinkyY := blinkyY + Velocity * dt;

  if isKeyDown(SC_A) then blinkyX := blinkyX - Velocity * dt;
  if isKeyDown(SC_D) then blinkyX := blinkyX + Velocity * dt;
end;

Render logic

This should not have side effects, i.e. not altering the state variables

You can use the [started] variable to see if the easing chain is still going

if isChainStarted then begin
  case chainIdx of
    2: begin
      { Current state --> apply easing --> handle rendering }
      perc := getLerpPerc(chainLerpTimer, getTimer);

      x := lerpEaseOutSine(startX, endX, perc);
      angle := lerpEaseOutSine(startAngle, endAngle, perc);

      sprRotate(imgBlinky, trunc(x) + 8, trunc(blinkyY) + 8, angle);
    end;
    
    else begin
      perc := getLerpPerc(chainLerpTimer, getTimer);
      x := lerpEaseOutSine(startX, endX, perc);
      spr(imgBlinky, trunc(x), trunc(blinkyY));
    end
  end;
end else
  spr(imgBlinky, trunc(blinkyX), trunc(blinkyY));

Basically:

  • If the chain is still ongoing, move the sprite normally
  • An exception is when the chainIdx has the number 2, it’s moving and rotating at the same time
  • Otherwise, the else branch handles when the chain is not yet started or is already completed: just render the sprite normally

State Flow

(Not started)
|
v
Button press: call beginEasingChain
|
v
Running: chainIdx 0, 1, 2
|
v
Complete when chainIdx reaches 3
|
v
(Finished): isChainComplete is true, can move with WASD

This entry was posted in Indie games, Posit-92 and tagged , , . Bookmark the permalink.