Ollin

Drag

grab, drag, and throw balls. It uses the press and release hooks, draws the per-frame mouse - previousMouse delta as a stretch arrow, and averages the release velocity over the last few frames

Run
swift run --package-path Examples Example-Input-Drag
Source
Examples/Input/Drag/Sketch.swift
import Ollin

/// The whole press-drag-release surface in one toy. Grab a ball
/// (`mousePressed()` fires once), drag it (`mouse - previousMouse` is this
/// frame's drag, drawn magnified as the bright stretch arrow), and let go
/// (`mouseReleased()`): the ball flies off with the hand's speed averaged over
/// the last few frames, so a flick that ended fast still throws hard. The
/// stretch arrow's weight also leans on `pressure`, which a plain mouse
/// reports as a steady 1 while pressed.
///
/// The opening rack is a `shuffled` deck (each size dealt exactly once, in a
/// random order) and every ball's ink is a weighted `randomChoice`: mostly
/// bone, sometimes amber, rarely coral. Press r to re-deal.
@main
final class Drag: Sketch {
    struct Ball {
        var position: Vector2
        var velocity: Vector2 = .zero
        var radius: Double
        var ink: Color
    }

    var balls: [Ball] = []
    /// Index of the ball being dragged, while a press holds one.
    var held: Int?
    /// Where the grab landed relative to the ball's center, so picking a ball
    /// up by its edge does not snap it to the cursor.
    var holdOffset = Vector2.zero
    /// The hand's speed over the last few frames, points per second. Averaging
    /// a handful of samples reads the flick, not the final frame's jitter.
    var recentSpeeds: [Vector2] = []

    override func setup() {
        noStroke()
        deal()
    }

    override func draw() {
        background(Color(hex: 0x0E1116))
        let delta = mouse - previousMouse           // this frame's drag

        // Free balls glide, slow down, and bounce off the walls; the held one
        // follows the cursor. deltaTime keeps the feel the same at any frame rate.
        for i in balls.indices where i != held {
            balls[i].position += balls[i].velocity * deltaTime
            balls[i].velocity *= exp(-0.9 * deltaTime)
            bounce(&balls[i])
        }
        if let i = held {
            balls[i].position = mouse + holdOffset
            recentSpeeds.append(delta / max(deltaTime, 0.001))
            if recentSpeeds.count > 6 { recentSpeeds.removeFirst() }
        }

        for ball in balls {
            fill(ball.ink)
            drawCircle(ball.position.x, ball.position.y, ball.radius)
        }

        // Two arrows while dragging: the bright one is this frame's delta
        // magnified, the teal one the averaged speed the ball would leave with
        // now. Moved steadily they coincide; they split when the hand
        // accelerates, which is the difference averaging is for.
        if let i = held {
            withState {
                if !recentSpeeds.isEmpty {
                    let fling = recentSpeeds.reduce(.zero, +) / Double(recentSpeeds.count)
                    stroke(Color(hex: 0x64DFDF, alpha: 0.6))
                    strokeWeight(2 * scale)
                    drawArrow(from: balls[i].position, to: balls[i].position + fling * 0.1)
                }
                stroke(Color(white: 1, alpha: 0.85))
                strokeWeight((2 + 3 * pressure) * scale)
                drawArrow(from: balls[i].position, to: balls[i].position + delta * 6)
            }
        }

        drawCaption("grab a ball, drag, let go: it flies with the hand's last few frames · r re-deals")
    }

    /// Fires once per press: pick up the topmost ball under the cursor.
    override func mousePressed() {
        guard let i = balls.indices.last(where: {
            balls[$0].position.distance(to: mouse) < balls[$0].radius + 12 * scale
        }) else { return }
        held = i
        holdOffset = balls[i].position - mouse
        balls[i].velocity = .zero
        recentSpeeds.removeAll()
    }

    /// Fires once per release: the ball leaves with the averaged speed.
    override func mouseReleased() {
        guard let i = held else { return }
        if !recentSpeeds.isEmpty {
            balls[i].velocity = recentSpeeds.reduce(.zero, +) / Double(recentSpeeds.count)
        }
        held = nil
    }

    override func keyPressed() {
        if key == "r" { deal() }
    }

    /// A fresh rack across the middle. `shuffled` deals each size exactly once
    /// in a random order (unlike `randomChoice`, which could repeat one), and
    /// the color weights are lopsided on purpose, so the accents stay rare.
    func deal() {
        held = nil
        let inks: [Color] = [Color(hex: 0xE8E4D8), Color(hex: 0xFFB703), Color(hex: 0xE56B6F)]
        balls = shuffled([30.0, 38, 46, 54, 62]).enumerated().map { i, size in
            Ball(position: Vector2(width * (0.15 + 0.175 * Double(i)), height / 2),
                 radius: size * scale,
                 ink: randomChoice(inks, weights: [6, 3, 1]))
        }
    }

    /// Keep a ball on the canvas: past an edge, fold the overshoot back in and
    /// reflect the velocity, losing a little energy per hit.
    func bounce(_ ball: inout Ball) {
        let r = ball.radius
        var p = ball.position
        var v = ball.velocity
        if p.x < r {
            p = Vector2(2 * r - p.x, p.y)
            v = Vector2(abs(v.x) * 0.85, v.y)
        }
        if p.x > width - r {
            p = Vector2(2 * (width - r) - p.x, p.y)
            v = Vector2(-abs(v.x) * 0.85, v.y)
        }
        if p.y < r {
            p = Vector2(p.x, 2 * r - p.y)
            v = Vector2(v.x, abs(v.y) * 0.85)
        }
        if p.y > height - r {
            p = Vector2(p.x, 2 * (height - r) - p.y)
            v = Vector2(v.x, -abs(v.y) * 0.85)
        }
        ball.position = p
        ball.velocity = v
    }
}

↑↓ move ↩ open