Stroll
Walk a figure over an eroded island. Use the arrows or WASD to move and space to jump. Stairs lead up to a lookout that lights as you arrive. Shows addCharacter, move/jump, stepHeight, and withCharacter.
- Run
swift run --package-path Examples Example-3D-Physics-Stroll- Imports
OllinPhysics- Source
- Examples/3D/Physics/Stroll/Sketch.swift
import Ollin
import OllinPhysics
/// A walkable island. An eroded `Heightfield` becomes both the ground you see
/// and the ground you stand on, and a `Character3D` walks it: arrow keys or
/// WASD to go, space to jump. The island is built to be argued with. A flight
/// of steps climbs to a lookout only because the walker's `stepHeight` allows
/// it (drag that parameter to zero and the same stairs become a wall), the crates on
/// the plaza scatter only because it is strong enough to shove them, and the
/// hills around the rim get too steep to climb somewhere on the way up, which
/// is `maxSlope` deciding rather than the geometry. Stand on the lookout and
/// the sensor under it lights: a character shows up among the ordinary bodies,
/// so triggers see it walk in. Walk off the edge and you fall, and are put back.
@main
final class Stroll: Sketch {
let world = World3D()
var land = Heightfield(columns: 2, rows: 2)
var terrain = Mesh(positions: [], indices: [])
var walker: Character3D!
var lookout: Body3D?
var crates: [Body3D] = []
/// How fast the walk is, and the tallest step it will take. `stepHeight` is
/// the parameter to play with: at 0 the stairs stop it dead.
@Param(1 ... 7, icon: "figure.walk") var walkSpeed = 3.4
@Param(0 ... 0.6, icon: "stairs") var stepHeight = 0.4
/// How far the stride has swung, advanced by the ground actually covered,
/// so the legs stop moving when a wall does.
var stride = 0.0
/// The chase camera's eye, eased toward where it wants to be.
var eye = Vector3(0, 6, 10)
let landWidth = 26.0, landDepth = 26.0, landHeight = 4.0
/// The height the flat middle of the island ended up at, in world units.
/// Read back from the finished field rather than assumed: erosion and the
/// closing `normalized()` both move it, and the stairs have to land on it.
var plazaY = 0.0
/// The plaza's height while the field is still being written.
let plazaLevel = 0.16
let start = Vector3(0, 0, 3)
let palette: [Color] = [
Color(hex: 0xE8632F), Color(hex: 0xF2A93B), Color(hex: 0xD94F70),
Color(hex: 0x8D92E0), Color(hex: 0x72BFB2),
]
override func setup() {
buildIsland()
buildArchitecture()
walker = world.addCharacter(radius: 0.28, height: 1.7,
at: start + Vector3(0, plazaY + 0.4, 0),
stepHeight: stepHeight)
eye = walker.position + Vector3(0, 4, 9)
}
/// A flat plaza in the middle, hills around the rim, and nothing past the
/// shoreline. Erosion cuts the gullies that make the hills worth reading.
func buildIsland() {
let bumps = Heightfield.diamondSquare(size: 129, roughness: 0.55, seed: 7)
land = Heightfield(columns: 129, rows: 129) { u, v in
let d = (Vector2(u - 0.5, v - 0.5).length) * 2 // 0 center … 1 edge
// Flat out to the plaza, then hills, then falling into the water.
let hills = smoothstep(0.3, 0.78, d) * (1 - smoothstep(0.82, 1, d))
let shore = 1 - smoothstep(0.86, 1.0, d)
return (self.plazaLevel + hills * (0.3 + 0.7 * bumps.value(u: u, v: v)))
* shore
}
.eroded(.hydraulic(drops: 30_000), seed: 7)
.eroded(.thermal(talus: 0.014, iterations: 24))
.normalized()
plazaY = land.value(u: 0.5, v: 0.5) * landHeight
terrain = terrainMesh(land)
world.addBody(.heightfield(land, width: landWidth, depth: landDepth,
height: landHeight),
at: .zero, kind: .static, friction: 0.6)
}
/// The things on the plaza that answer back: a stair up to a lookout with a
/// sensor under its deck, and a few crates light enough to shove.
func buildArchitecture() {
// Four steps climbing north, each one just inside the default reach.
for index in 0 ..< 4 {
let rise = 0.3
let top = plazaY + rise * Double(index + 1)
world.addBody(.box(width: 3, height: top, depth: 1),
at: Vector3(0, top / 2, -2.4 - Double(index) * 1.0),
kind: .static, friction: 0.6)
}
// The lookout deck the stair arrives on.
let deckY = plazaY + 1.2
world.addBody(.box(width: 3.6, height: deckY, depth: 3),
at: Vector3(0, deckY / 2, -7.4), kind: .static, friction: 0.6)
// A detector standing on the deck: it reports the walker without ever
// pushing it, so stepping onto the lookout is something the sketch can
// notice happening.
lookout = world.addBody(.box(width: 3.4, height: 1.8, depth: 2.8),
at: Vector3(0, deckY + 0.9, -7.4), isSensor: true)
for index in 0 ..< 7 {
let angle = Double(index) / 7 * .tau
let crate = world.addBody(
.box(width: 0.5, height: 0.5, depth: 0.5),
at: Vector3(cos(angle) * 3.4, plazaY + 0.6, sin(angle) * 3.4 + 1.5),
rotated: random(0, .tau), axis: .unitY, density: 0.02, friction: 0.4)
crate.userData = palette[index % palette.count]
crates.append(crate)
}
}
override func draw() {
background(Color(hex: 0x0B1017))
// The sky is left visible here: an island wants a horizon behind it,
// and it carries the ambient that keeps the ground off mud.
environment(.sky(turbidity: 2.6, sunElevation: 0.85))
lightingPreset(.standard)
castShadows()
steer()
world.advance(by: deltaTime)
// Fallen off the island: put the walker back on the plaza.
if walker.position.y < -3 {
walker.position = start + Vector3(0, plazaY + 1, 0)
walker.stop()
}
followWithCamera()
material(.dielectric(roughness: 0.9))
fill(.white)
drawMesh(terrain)
drawArchitecture()
drawWalker()
let standing = lookout?.isTouching(walker.body) ?? false
drawCaption(standing
? "on the lookout arrows or WASD to walk, space to jump"
: "arrows or WASD to walk, space to jump")
}
/// The whole of the input: read the keys, hand the character a velocity,
/// ask for a jump. Everything else is the world's business.
func steer() {
walker.stepHeight = stepHeight
var east = 0.0, south = 0.0
if isKeyDown(.upArrow) || isKeyDown("w") { south -= 1 }
if isKeyDown(.downArrow) || isKeyDown("s") { south += 1 }
if isKeyDown(.leftArrow) || isKeyDown("a") { east -= 1 }
if isKeyDown(.rightArrow) || isKeyDown("d") { east += 1 }
let heading = Vector3(east, 0, south)
walker.walk(at: heading.length > 0 ? heading.normalized * walkSpeed : .zero)
if isKeyDown(" ") { walker.jump(4.6) }
// Face the way it is actually traveling, and swing the legs by the
// ground covered rather than the clock, so walking into a crate that
// will not move stops the stride too.
let travel = Vector3(walker.actualVelocity.x, 0, walker.actualVelocity.z)
if travel.length > 0.2 {
walker.facing = atan2(travel.x, travel.z)
}
stride += travel.length * deltaTime * 3.4
}
/// A camera that trails the walker at a fixed angle, easing rather than
/// snapping so a jump does not jolt the frame.
func followWithCamera() {
let focus = walker.position + Vector3(0, 1.1, 0)
let wanted = focus + Vector3(0, 8.5, 11)
eye += (wanted - eye) * min(1, deltaTime * 3.5)
camera(.perspective(eye: eye, target: focus, fieldOfView: .pi / 3.4))
}
/// The figure: modeled facing its own +z, so `withCharacter` turns it by
/// `facing` and stands it on the ground at the character's feet.
func drawWalker() {
let swing = sin(stride) * 0.42
material(.dielectric(roughness: 0.45))
withCharacter(walker) {
fill(Color(hex: 0x1D2430))
for (side, phase) in [(-1.0, swing), (1.0, -swing)] {
withState {
translate(0.12 * side, 0.72, 0)
rotate(phase, axis: .unitX)
translate(0, -0.36, 0)
drawCapsule(radius: 0.1, height: 0.5)
}
}
fill(Color(hex: 0xE8632F))
withState {
translate(0, 1.14, 0)
drawCapsule(radius: 0.21, height: 0.42)
}
fill(Color(hex: 0x1D2430))
for (side, phase) in [(-1.0, -swing), (1.0, swing)] {
withState {
translate(0.3 * side, 1.3, 0)
rotate(phase * 0.7, axis: .unitX)
translate(0, -0.24, 0)
drawCapsule(radius: 0.07, height: 0.34)
}
}
fill(Color(hex: 0xF2C9A0))
withState {
translate(0, 1.56, 0)
drawSphere(radius: 0.18)
}
// A brim so the facing reads at a glance.
fill(Color(hex: 0xD94F70))
withState {
translate(0, 1.62, 0.12)
drawBox(width: 0.34, height: 0.05, depth: 0.2)
}
}
}
func drawArchitecture() {
material(.dielectric(roughness: 0.75))
fill(Color(hex: 0xB9AE9C))
for body in world.bodies where body.kind == .static && !body.isSensor {
guard case .box(let w, let h, let d) = body.collider else { continue }
withBody(body) { drawBox(width: w, height: h, depth: d) }
}
// The lookout's marker, lit while the walker is standing in it.
if let lookout {
let standing = lookout.isTouching(walker.body)
fill(standing ? Color(hex: 0xF2A93B) : Color(hex: 0x3A4455))
material(standing ? .dielectric(roughness: 0.2) : .dielectric(roughness: 0.8))
withBody(lookout) {
withState {
translate(0, -0.85, 0)
drawBox(width: 3.4, height: 0.1, depth: 2.8)
}
}
}
material(.dielectric(roughness: 0.5))
for crate in crates {
fill(crate.userData as? Color ?? .white)
withBody(crate) { drawBox(width: 0.5, height: 0.5, depth: 0.5) }
}
}
/// The field as a mesh with a height-colored texture: sand at the
/// waterline, grass on the plaza, rock and scrub up the hills.
private func terrainMesh(_ field: Heightfield) -> Mesh {
let ramp = Ramp([Color(hex: 0xE4D2A6), Color(hex: 0x8FAE63),
Color(hex: 0x6E9450), Color(hex: 0x9A8E70),
Color(hex: 0xC3BCB1)])
var pixels = [UInt8]()
pixels.reserveCapacity(field.values.count * 4)
for value in field.values {
let c = ramp.color(at: clamp(value, 0, 1))
pixels.append(UInt8((c.red * 255).rounded()))
pixels.append(UInt8((c.green * 255).rounded()))
pixels.append(UInt8((c.blue * 255).rounded()))
pixels.append(255)
}
let mesh = field.mesh(width: landWidth, depth: landDepth, height: landHeight)
guard let texture = Image(width: field.columns, height: field.rows,
premultipliedRGBA: pixels) else { return mesh }
return mesh.textured(texture)
}
}