Ollin

ControllerInput

a game controller as a drawing instrument: the left stick steers a pen that leaves ink, the trigger sets how heavy the line is, and the shoulder buttons turn its heading. On a pad that reports motion, tilting the pad tips the canvas, so the ink runs downhill (Controller, ControllerButton)

Run
swift run --package-path Examples Example-Integration-ControllerInput
Imports
OllinController
Source
Examples/Integration/ControllerInput/Sketch.swift
import Ollin
import OllinController

/// A game controller as a drawing instrument.
///
/// The left stick steers a pen and the pen leaves ink, so the piece is however
/// you moved your thumb. The right trigger sets how heavy the line is, the face
/// buttons change color, the shoulders spin the pen's own heading, and the right
/// stick blows the ink sideways like wind. Pressing the menu button clears the
/// page.
///
/// Two of these only work on some hardware, which is the point of showing them:
/// a PlayStation or Switch controller reports motion, so tilting it tips the
/// whole canvas and the ink runs downhill, and a PlayStation controller has a
/// touchpad, so sliding a finger across it drags the color. An Xbox controller
/// has neither, and the sketch simply does not offer them rather than pretending.
///
/// With nothing plugged in, everything reads centered and nothing happens, so the
/// sketch says as much on the canvas and waits. Turn `map` on to see every stick,
/// trigger and button as it is read, which is the quickest way to tell whether a
/// controller is talking to the machine at all.
@main
final class ControllerInputExample: Sketch {

    @Param(icon: "gamecontroller") var map = false
    @Param(0.5 ... 8, icon: "figure.walk") var speed = 4.0
    @Param(icon: "gyroscope") var useMotion = true

    private var pen = Vector2.zero
    private var heading = 0.0
    private var ink = Color.black
    private var greeting = ""
    private var greetingFrames = 0

    private let palette: [ControllerButton: Color] = [
        .a: Color(hex: 0x1D3557), .b: Color(hex: 0xE63946),
        .x: Color(hex: 0x457B9D), .y: Color(hex: 0xF4A261),
    ]

    override var canvasSize: CanvasSize { .square(1080) }

    override func setup() {
        pen = center
        ink = palette[.a] ?? .black
        // The sensors cost battery, so they stay off until a sketch asks.
        controllersReportMotion(true)
        // Ink piles up frame after frame instead of being wiped.
        noClear()
        background(Color(white: 0.97))
    }

    override func draw() {
        let pad = controller

        // Arriving and leaving are events, so they are worth catching rather
        // than polling: this fires on exactly one frame.
        if pad.didConnect { greet("\(pad.name ?? "controller") connected") }
        if pad.didDisconnect { greet("controller disconnected") }

        if pad.wasPressed(.menu) { clearPage() }
        for (button, color) in palette where pad.wasPressed(button) { ink = color }

        move(pad)
        drawInk(pad)

        // Everything below is chrome, so it goes on top of a fresh coat rather
        // than into the ink.
        if map { drawMap(pad) }
        if !pad.isConnected { drawWaiting(pad) }
        drawGreeting()
    }

    // MARK: - The pen

    private func move(_ pad: Controller) {
        // The stick reads in canvas terms already: pushing up gives a negative
        // y, which is up the screen, so this needs no sign of its own.
        var step = pad.leftStick * speed

        // Wind, off the other stick.
        step += pad.rightStick * (speed * 0.4)

        // Tilting the controller tips the page, on hardware that reports it.
        if useMotion && pad.hasMotion {
            step += Vector2(pad.gravity.x, -pad.gravity.y) * speed * 0.6
        }

        heading += (pad.isDown(.rightShoulder) ? 0.06 : 0)
            - (pad.isDown(.leftShoulder) ? 0.06 : 0)
        step = step.rotated(by: heading)

        pen += step
        pen = Vector2(pen.x.wrapped(in: 0 ... width), pen.y.wrapped(in: 0 ... height))
    }

    private func drawInk(_ pad: Controller) {
        // The trigger is a level, not a switch, so a light pull is a fine line.
        let weight = 1 + pad.rightTrigger * 28
        guard pad.leftStick.length > 0 || pad.rightStick.length > 0 || pad.hasMotion else { return }

        // A finger on the touchpad shifts the hue, on a controller that has one.
        var color = ink
        if pad.hasTouchpad && pad.isTouching {
            color = Color.mix(ink, Color(hex: 0x2A9D8F), (pad.touch.x + 1) / 2, in: .oklab)
        }

        // The left trigger lifts ink back off the page instead of laying it on.
        if pad.leftTrigger > 0 {
            fill(Color(white: 0.97).withAlpha(pad.leftTrigger * 0.25))
            noStroke()
            drawCircle(center: pen, radius: weight * 2)
            return
        }

        stroke(color.withAlpha(0.85))
        strokeWeight(weight)
        strokeCap(.round)
        drawPoint(at: pen)
    }

    private func clearPage() {
        noStroke()
        fill(Color(white: 0.97))
        drawRect(corner: .zero, width: width, height: height)
        greet("cleared")
    }

    // MARK: - Saying what is happening

    private func greet(_ message: String) {
        greeting = message
        greetingFrames = 90
    }

    private func drawGreeting() {
        guard greetingFrames > 0 else { return }
        greetingFrames -= 1
        let fade = min(Double(greetingFrames) / 30, 1)
        fill(Color(white: 0.1).withAlpha(fade))
        textSize(22)
        textAlign(.center)
        drawText(greeting, center.x, height - 60)
    }

    private func drawWaiting(_ pad: Controller) {
        // An export has no live input, and says so; an empty slot is not a
        // fault and says nothing, so there is a reason to show only sometimes.
        let message = pad.unavailableReason
            ?? "plug in a game controller, or pair one in System Settings"
        drawStatus(message, style: .info)
    }

    // MARK: - The map

    private func drawMap(_ pad: Controller) {
        withState {
            translate(40, 40)
            noStroke()
            fill(Color(white: 1, alpha: 0.88))
            drawRect(corner: .zero, width: 320, height: 250, cornerRadius: 14)

            fill(Color(white: 0.1))
            textSize(15)
            textAlign(.left)
            drawText(pad.name ?? "nothing attached", 18, 32)

            stick(pad.leftStick, at: Vector2(70, 110), label: "left")
            stick(pad.rightStick, at: Vector2(180, 110), label: "right")
            bar(pad.leftTrigger, at: Vector2(250, 70), label: "LT")
            bar(pad.rightTrigger, at: Vector2(285, 70), label: "RT")

            var x = 18.0
            for button in ControllerButton.allCases {
                fill(pad.isDown(button) ? Color(hex: 0xE63946) : Color(white: 0.85))
                drawCircle(center: Vector2(x, 200), radius: 6)
                x += 16
                if x > 300 { x = 18 }
            }

            fill(Color(white: 0.35))
            textSize(12)
            let motion = pad.hasMotion
                ? "tilt \(String(format: "%.2f, %.2f", pad.gravity.x, pad.gravity.y))"
                : "no motion on this controller"
            drawText(motion, 18, 228)
        }
    }

    private func stick(_ value: Vector2, at origin: Vector2, label: String) {
        noFill()
        stroke(Color(white: 0.8))
        strokeWeight(2)
        drawCircle(center: origin, radius: 34)
        noStroke()
        fill(Color(hex: 0x1D3557))
        drawCircle(center: origin + value * 30, radius: 7)
        fill(Color(white: 0.45))
        textSize(11)
        textAlign(.center)
        drawText(label, origin.x, origin.y + 52)
        textAlign(.left)
    }

    private func bar(_ value: Double, at origin: Vector2, label: String) {
        noStroke()
        fill(Color(white: 0.85))
        drawRect(corner: origin, width: 20, height: 80)
        fill(Color(hex: 0x457B9D))
        drawRect(corner: origin + Vector2(0, 80 * (1 - value)), width: 20, height: 80 * value)
        fill(Color(white: 0.45))
        textSize(11)
        drawText(label, origin.x, origin.y + 96)
    }
}

private extension Double {
    /// Keep the pen on the page by bringing it round the other side.
    func wrapped(in range: ClosedRange<Double>) -> Double {
        let span = range.upperBound - range.lowerBound
        guard span > 0 else { return range.lowerBound }
        var value = self
        while value < range.lowerBound { value += span }
        while value > range.upperBound { value -= span }
        return value
    }
}

↑↓ move ↩ open