Physics

Springs and throws

What a drag does when you let go: it glides on with the speed it had, then springs home. Stew Engine has no physics world (no gravity, forces or collisions): the feel comes from springs and a few exact rules, all on this page.

Throw it

Pick the ball up and throw it. A dot marks where it is on every frame, so wide gaps are fast and tight ones slow: the glide easing out, then the spring swinging about home.

throw sets how far it glides on, spring how it comes home, jelly how much it tilts with its speed and lift how much bigger it gets while held. Pointer actions has the basics; this page has the exact rules.

    throw.ts
    import { createStage, dragAction, frameClock, GRAB, RELEASE, THROW } from "@stew/engine";
    
    const stage = createStage(), clock = frameClock();
    const feel = {
      spring: 0.5,
      throw: 0.8,
      jelly: 0.6,
      lift: 0.5,
      lockX: false,
      lockY: false,
    };
    
    // the ball tells the page what happens; the layer round it moves
    dragAction({ el: ball, layer: ballLayer }, feel, stage, clock);
    
    for (const name of [GRAB, RELEASE, THROW]) {
      ball.addEventListener(name, () => log(name));
    }

    How fast it was going

    While it is held, each move updates its speed: 0.9 times the speed of that move, plus 0.1 times the speed before. A steady hand settles on its true speed, and a sudden flick counts at once. Only moves count: holding still before letting go doesn't slow it down.

    Speed starts at 0 on each press. It is measured in the units of the layer's parent (the move on the page, turned through the parent's screen matrix), on the action's clock. A locked axis has no speed.

    each move
    speed = 0.9 x distance / time + 0.1 x speed, per second, for x and y apart. The time since the last move counts as at least 1 ms.
    over 300 a second
    Let go faster than 300 units a second (x and y together) and it also sends stew:throw, right after stew:release, whatever throw is. The limit is fixed.

    The glide

    Letting go hands the position to two springs, one for x and one for y. The glide sets off at the speed it was let go at and eases off at a steady rate, so it comes to rest exactly as far on as the rules below say. With a spring, it springs home to 0, 0 once the glide has covered 99% of its way, carrying the speed it still has. Either part can be left out. Press it again mid-flight and it is caught where it is.

    Set the speed it was let go at, throw and spring. The clip below lays a release out as keyframes to play, plot and print: the glide along power2.out, then the spring along elastic.out. It lands on the same spots as the springs, on a slightly different curve.

    homelet gomoving 1000 units a secondglide endshomelet gocapglidespring0 s0.5 s1 s1.5 s2 s
    0.0 / 1.6 s
    cap
    60 + 240 x 0.6 = 204
    glide
    1000 x 0.12 x 0.6 = 72
    glide time
    0.35 + 0.25 x 0.6 = 0.5 s
    period
    max(0.12, 0.65 - 0.5 x 0.5) = 0.4
    spring time
    0.6 + 1.1 x 0.5 = 1.15 s
    stew:throw
    1000 > 300: sent
    release.json
    {
      "duration": 1.65,
      "tracks": [
        {
          "targetUid": "ball",
          "property": "x",
          "keyframes": [
            { "time": 0, "value": -150, "easing": "power2.out" },
            { "time": 0.5, "value": -78, "easing": "elastic.out(1, 0.4)" },
            { "time": 1.65, "value": 0, "easing": "elastic.out(1, 0.4)" }
          ]
        },
        {
          "targetUid": "ball",
          "property": "y",
          "keyframes": [
            { "time": 0, "value": -72, "easing": "power2.out" },
            { "time": 0.5, "value": -72, "easing": "elastic.out(1, 0.4)" },
            { "time": 1.65, "value": 0, "easing": "elastic.out(1, 0.4)" }
          ]
        }
      ]
    }
    The glide
    how far
    speed x 0.12 x throw on each axis, held within plus or minus 60 + 240 x throw.
    how fast
    It leaves the hand at the hand's speed, then covers all but 1 / e of what's left every how far / speed seconds: 0.12 x throw s when it isn't capped. Within a thousandth of a unit, it is there.
    when
    Only when an axis would move more than 2 units. With throw at 0, or both axes locked, there is no glide.
    throw 0.25
    At most 120 units.
    throw 0.6
    At most 204 units. Let go at 1000 a second, it glides 72, all but 1 / e of what's left every 0.072 s.
    throw 1
    At most 300 units.

    The spring

    After the glide (or at once, with none), it springs home, keeping the speed it had: it swings about home once every P of the spring's time T, and its swing dies away as e^(-9.23 t / T). A stronger spring swings faster and rings longer.

    P is 0.65 - 0.5 x spring, never under 0.12, and T is 0.6 + 1.1 x spring seconds, to three places. Once what's left of its swing is under a thousandth of a unit, it rests exactly on 0, 0. A spring of 0.01 or less is no spring: it stays where it lands, and the next drag starts from there.

    let gohomespring 0.25P 0.525, 0.875 sspring 0.5P 0.4, 1.15 sspring 0.7P 0.3, 1.37 sspring 1P 0.15, 1.7 s
    2.4 / 2.7 s
    springs.json
    {
      "duration": 2.7,
      "tracks": [
        {
          "targetUid": "spring-25",
          "property": "x",
          "keyframes": [
            { "time": 0.4, "value": -170, "easing": "elastic.out(1, 0.525)" },
            { "time": 1.275, "value": 0, "easing": "elastic.out(1, 0.525)" }
          ]
        },
        {
          "targetUid": "spring-50",
          "property": "x",
          "keyframes": [
            { "time": 0.4, "value": -170, "easing": "elastic.out(1, 0.4)" },
            { "time": 1.55, "value": 0, "easing": "elastic.out(1, 0.4)" }
          ]
        },
        {
          "targetUid": "spring-70",
          "property": "x",
          "keyframes": [
            { "time": 0.4, "value": -170, "easing": "elastic.out(1, 0.3)" },
            { "time": 1.77, "value": 0, "easing": "elastic.out(1, 0.3)" }
          ]
        },
        {
          "targetUid": "spring-100",
          "property": "x",
          "keyframes": [
            { "time": 0.4, "value": -170, "easing": "elastic.out(1, 0.15)" },
            { "time": 2.1, "value": 0, "easing": "elastic.out(1, 0.15)" }
          ]
        }
      ]
    }
    spring 0.25
    A swing every 0.525 of 0.875 s (bounce 0.389).
    spring 0.5
    A swing every 0.4 of 1.15 s (bounce 0.493).
    spring 0.7
    A swing every 0.3 of 1.37 s (bounce 0.597).
    spring 1
    A swing every 0.15 of 1.7 s (bounce 0.785). From 1.06 up, P stays 0.12.
    the curve
    How far from home it is: e^(-9.23 t / T) x (A cos(2 pi t / (P T)) + B sin(2 pi t / (P T))), with A and B set by where it starts and how fast. Its bounce is 1 - 9.23 / sqrt(9.23^2 + (2 pi / P)^2), and createSpring(clock, T, bounce) is the same spring.

    Jelly and lift

    jelly
    On each move it leans toward its sideways speed x 0.015 degrees, held within plus or minus 16, times jelly, on a spring that settles in 0.2 s and keeps its speed from move to move. Let go, it leans back to 0 the same way. Only sideways speed counts: a drag straight up never tilts.
    lift
    On the press it grows to 1 + 0.08 x lift on a spring that settles in 0.18 s, and back to 1 in 0.3 s when let go.
    about
    Both turn and stretch about the middle of the layer's box, measured once when dragAction starts.

    On a single node

    A Drag on one node of a path (nodeBend with kind: "drag") follows the same rules in the path's own units, with a shorter glide. A press must land within 18 pixels of the node on the page.

    glide
    At most 40 + 160 x throw units, once an axis would move more than 1 unit.
    spring
    The same as a shape's.
    jelly, lift
    Not used: a node doesn't tilt or grow.
    events
    stew:grab, stew:release and stew:throw, on the path.

    Springy eases

    Any keyframe can spring too: three ease families pass the mark or bounce on it. Easing shows them moving. These are their in curves: .out plays the in curve backwards and upside down, and .inOut plays the in curve over the first half and the out curve over the second.

    back.in(s)
    p^2 x ((s + 1) x p - s), with s 1.70158 by default. It dips below 0 first, so back.out passes the mark by about 10% and comes back.
    elastic.in(a, P)
    -a x 2^(10(p - 1)) x sin((p - 1 - s) x 2 pi / P), with s = P / (2 pi) x asin(1 / a). a is 1 by default and counts as 1 below that; P is 0.3 by default, 0.45 for .inOut.
    bounce.out
    Four arcs, each landing lower: it first reaches the mark at p = 1 / 2.75, then dips back to 0.75, 0.9375 and 0.984375 between landings.
    NextPerformance