Burst
Fruit thrown up through the frame, each one breaking at the top of its arc into pieces that tumble back down. Nobody cuts them: a shape lets go on its own, and what falls is exactly what went up. A click breaks the nearest one early, and space clears the frame. Shows Shape.fractured around the point where it gave way, each piece's centroid becoming a body of its own (fractured, centroid, addBody, World.remove).
- Run
swift run --package-path Examples Example-Physics-Burst- Imports
OllinPhysics- Source
- Examples/Physics/Burst/Sketch.swift
import Foundation
import Ollin
import OllinPhysics
/// Fruit thrown up through the frame, bursting at the top of its arc into
/// pieces that tumble back down. Nobody cuts them: each one comes apart on its
/// own, and what falls is exactly what went up, in `Shape.fractured` pieces
/// that fit back together with no gap and no overlap.
///
/// The whole of the break is one call. A fruit's outline is a `Shape`, and
/// `fractured(into:around:seed:)` cuts it into cells around the point where it
/// gave way, so the chips are small there and the wedges long away from it.
/// Each piece then becomes its own rigid body: put the body at the piece's
/// `centroid`, hand the solver the piece's points around that centroid, and
/// give it the parent's motion plus a push outward from the break.
///
/// **Click** to burst the nearest fruit early. **Space** clears the frame.
@main
final class Burst: Sketch {
let world = World()
/// What a body draws as, hung off `Body.userData`: its outline in its own
/// coordinates, its color, and (for a whole fruit) the upward speed at
/// which it lets go. A piece carries no such speed and never breaks again.
final class Look {
let shape: Shape
let color: Color
let breaksAt: Double?
init(shape: Shape, color: Color, breaksAt: Double? = nil) {
self.shape = shape
self.color = color
self.breaksAt = breaksAt
}
}
/// A ring at a break, drawn for a moment after it happens.
struct Flash {
var center: Vector2
var color: Color
var age = 0.0
}
var flashes: [Flash] = []
let palette: [Color] = [
Color(hex: 0xE8503A), Color(hex: 0xF2A13B), Color(hex: 0xF7D257),
Color(hex: 0x76C36B), Color(hex: 0x4FB3C4), Color(hex: 0xB06BD1),
]
override func setup() {
world.gravity = Vector2(0, 2200 * scale)
world.restitution = 0.2
world.drag = 0 // no air: a shape falls as fast as it is let go
noStroke()
}
/// Throw one fruit up from under the bottom edge.
func throwOne() {
guard world.bodies.count < 220 else { return }
let radius = random(52, 92) * scale
let color = randomChoice(palette)
let fromLeft = random() < 0.5
let x = fromLeft ? random(width * 0.12, width * 0.4) : random(width * 0.6, width * 0.88)
// Three kinds of fruit, each a convex outline so the whole one is a
// single collider before it comes apart.
let outline: [Vector2]
let collider: Collider
switch Int(random(3)) {
case 0:
outline = (0 ..< 40).map { i in
let a = Double(i) / 40 * .tau
return Vector2(cos(a), sin(a)) * radius
}
collider = .circle(radius: radius)
case 1:
let w = radius * 1.7, h = radius * 1.35
outline = [Vector2(-w / 2, -h / 2), Vector2(w / 2, -h / 2),
Vector2(w / 2, h / 2), Vector2(-w / 2, h / 2)]
collider = .box(width: w, height: h)
default:
let sides = Int(random(5, 8))
outline = (0 ..< sides).map { i in
let a = Double(i) / Double(sides) * .tau
return Vector2(cos(a), sin(a)) * radius
}
collider = .polygon(outline)
}
let fruit = world.addBody(collider, at: Vector2(x, height + radius * 1.4),
density: 1, friction: 0.4, restitution: 0.2)
fruit.velocity = Vector2(random(-220, 220) * scale, -random(1750, 2050) * scale)
fruit.angle = random(.tau)
fruit.angularVelocity = random(-3, 3)
fruit.userData = Look(shape: Shape(outline), color: color,
breaksAt: random(-140, 140) * scale)
}
/// Break one fruit into pieces, each with the parent's motion and a push
/// out of the break.
func burst(_ fruit: Body, look: Look) {
let turn = fruit.angle
let here = fruit.position
// Everything the pieces inherit is read before the fruit is destroyed:
// a body is spent the moment it leaves the world.
let motion = fruit.velocity
let spin = fruit.angularVelocity
// Where it gives way: off-center, so the cut is not the same twice.
let impact = look.shape.centroid + Vector2(random(-1, 1), random(-1, 1))
* (look.shape.bounds.map { min($0.width, $0.height) * 0.35 } ?? 0)
let pieces = look.shape.fractured(into: Int(random(7, 13)), around: impact,
seed: Int(random(10_000)))
world.remove(fruit)
flashes.append(Flash(center: here + impact.rotated(by: turn), color: look.color))
for piece in pieces {
let middle = piece.centroid
let local = piece.mapPoints { $0 - middle }
guard let corners = local.contours.first?.points, corners.count >= 3 else { continue }
// A piece that comes to rest is still the piece that was drawn, so
// the outline the body carries is the one the solver was handed.
let shard = world.addBody(.polygon(corners), at: here + middle.rotated(by: turn),
density: 1, friction: 0.5, restitution: 0.25)
shard.angle = turn
let push = (middle - impact).normalized * random(180, 420) * scale
shard.velocity = motion + push
shard.angularVelocity = spin + random(-7, 7)
shard.userData = Look(shape: local,
color: look.color.mixed(with: .white, random(0, 0.22)))
}
}
override func mousePressed() {
// The nearest fruit still whole, which is the one with a break speed.
let whole = world.bodies.filter { ($0.userData as? Look)?.breaksAt != nil }
guard let nearest = whole.min(by: {
$0.position.distance(to: mouse) < $1.position.distance(to: mouse)
}), let look = nearest.userData as? Look else { return }
burst(nearest, look: look)
}
override func keyPressed() {
if key == " " {
world.removeAll()
flashes.removeAll()
}
}
override func draw() {
background(Color(hex: 0x14161C))
if frameCount % 16 == 0 { throwOne() }
world.advance(by: deltaTime)
// Past the top of the arc it lets go: the fruit is on its way down, and
// still high enough that the break happens inside the frame.
for body in world.bodies {
guard let look = body.userData as? Look, let breaksAt = look.breaksAt else { continue }
if body.velocity.y >= breaksAt && body.position.y < height * 0.72 {
burst(body, look: look)
}
}
// Anything below the frame has had its fall.
for body in world.bodies where body.position.y > height + 260 * scale {
world.remove(body)
}
for body in world.bodies {
guard let look = body.userData as? Look else { continue }
withState {
translate(body.position)
rotate(body.angle)
fill(look.color)
// A whole fruit has a rind, so the flesh the break reveals is
// a lighter color inside a darker edge.
if look.breaksAt != nil {
stroke(look.color.mixed(with: .black, 0.4))
strokeWeight(7 * scale)
} else {
noStroke()
}
drawShape(look.shape)
}
}
noStroke()
noFill()
for flash in flashes {
let t = flash.age / 0.3
strokeWeight((1 - t) * 5 * scale)
stroke(flash.color.withAlpha((1 - t) * 0.5))
drawCircle(center: flash.center, radius: (30 + t * 120) * scale)
}
noStroke()
for i in flashes.indices { flashes[i].age += deltaTime }
flashes.removeAll { $0.age > 0.3 }
}
}