Steve's Roc Apps roc-apps on GitHub

Safari Ride Chapters

Chapter 31

The truck

All through the ride, the rider follows a truck up the road. The truck is the ride's one true solid: a body of flat faces in three dimensions, seen from wherever the camera stands, with headlights for the dark and brake lights for the bends. Here the truck stands at one place on the road. How it moves comes in the next chapter.

A truck from behind, from behind and to one side, and from the side.

The whole job §

One function makes the truck's sprite. The sprite holds the headlight beams, the body and the brake lights, painted in that order:

Truck.roc
## How the truck looks in `setting`: its beams, its body, then its brake
## lights.
scenery_sprite : Truck, Sprite.Setting -> Sprite
scenery_sprite = |truck, setting|
	Sprite.composite([
		truck.headlights.sprite(setting),
		TruckBody.body_sprite(setting),
		truck.brake_lights.sprite(setting),
	])

Sprite.composite makes one sprite of several, each painted over the ones before:

Sprite.roc
## One sprite of several, each painted over the ones before it.
composite : List(Sprite) -> Sprite
composite = |sprites| Sprite.(sprites.join_map(Sprite.to_shapes))

The setting is the one every kind gets, the truck's spot, the lens and the sky's darkness:

Sprite.roc
## What a kind of object draws its sprite from: where it stands in the
## frame, the camera's focal length, and how dark the sky is.
Setting : {
	spot : Spot,
	focal_length : Pixels,
	darkness : Fraction,
}

The Truck value holds which of the truck's lamps are on:

Truck.roc, in Truck
## Which of the truck's lights are on.
Truck := { headlights : TruckHeadlights, brake_lights : TruckBrakeLights }.{
# ...

Each lamp is [On, Off], and each has a module that owns two things: when its lamp is on, and how the lamp looks. The rest of this chapter explains the body, then the two lamps.

The body's shape §

TruckShape says what the truck looks like, in the truck's own frame. Each part of the truck is a face, a color and its corners:

TruckShape.roc
## A face of the truck: a color and its corners, in the truck's own frame.
TruckFace : { color : RgbColor, corners : List(TruckShape.TruckPoint) }

A corner is three distances: ahead of and to the right of the middle of the trailer, and up from the road:

TruckShape.roc
## A point on the truck, in the truck's own frame: how far ahead of the
## trailer's center and how far to the right of it, each negative the other
## way, and how high above the road under the truck.
TruckPoint : {
	ahead : Distance,
	rightward : Distance,
	height : Distance,
}

The truck is blue: a dark blue body, darker blue sides, a lighter blue roof, and black tires. The whole truck is its faces, in one list, and join puts the lists inside it end to end:

TruckShape.roc
## The whole truck as it is built: its two sides, the trailer's back, its
## roof, the cab's windshield and nose, then its tires.
truck_faces : List(TruckShape.TruckFace)
truck_faces = [
	sides,
	[trailer_back, roof, windshield, nose],
	tires,
].join()

Shape, placement, projection §

The shape reaches the screen in three stages:

Stage What it does
Shape Says where each corner of each face is in the truck's own frame
Placement Turns one corner by the road's heading, carries it out from the truck's foot, and lowers it by the ground's drop
Projection Turns one whole face into a polygon on the screen

Placement works a corner at a time. TruckSighting finds the truck on the road ahead and works out its spot once. The truck is rigid and faces straight along the road, so every corner moves the same way, through that spot:

TruckPlacement.roc
## Where a point on the truck stands in the rider's view, for a truck at
## `spot` facing along the road.
point_at_height : Spot, TruckShape.TruckPoint -> RiderView.PointAtHeight
point_at_height = |spot, point|
	spot.standing_point({ facing: Angle.straight_ahead, offset: point })

Every corner is lowered by the same drop, how far the ground has curved away beneath the truck's spot. Across a 12-metre truck the drop changes by a few centimetres at most, far less than a pixel.

Projection works a face at a time, for two reasons. A face that reaches behind the camera is cut at the near plane, and the cut can take corners off a face and add new ones, so it needs the whole outline. And the faces are sorted by depth, a whole face at a time, so a nearer face covers a farther one. TruckBody.body_sprite takes those steps in order, with a name for each:

TruckBody.roc
## The truck's body in `setting`, its farthest faces first.
body_sprite : Sprite.Setting -> Sprite
body_sprite = |setting| {
	placed_faces = TruckShape.truck_faces.map(
		|face| TruckPlacement.placed_face(setting.spot, face),
	)
	faces_far_to_near = DepthSort.sort_by(placed_faces, face_depth)
	dimmed_faces = faces_far_to_near.map(
		|face| { ..face, color: Sky.color_at_darkness(face.color, setting.darkness) },
	)
	Sprite.from_shapes(
		dimmed_faces.keep_oks(|face| Camera.clipped_polygon(face, setting.focal_length)),
	)
}

placed_faces stands every face on the road, faces_far_to_near sorts them, and dimmed_faces darkens each face's color with the sky. Then Camera.clipped_polygon cuts each face at the near plane and projects it, and returns an Err for a face wholly behind the camera. The “The math layer” chapter shows how the cut works. keep_oks keeps the rest. The truck keeps all its faces however far off it stands, because the rider is always looking for it.

The headlights §

The ride runs from day into night, so the headlights come on near the end. They work like a light sensor on the lamps themselves: once the sky is dark enough, they're on.

TruckHeadlights.roc
## The headlights under a sky of `darkness`: on once the sky is dark
## enough, like a light sensor on the lamps.
at : Fraction -> TruckHeadlights
at = |darkness|
	if darkness >= headlights_on_darkness {
		On
	} else {
		Off
	}

They come on at this darkness:

TruckHeadlights.roc
## The headlights come on once the sky is 41.2% dark, about when the sun sinks
## behind the west range. The value was measured once, on this ride;
## gates/expects/RideExpects.roc pins the step.
headlights_on_darkness : Fraction
headlights_on_darkness = Fraction.of(0.412)

On this ride, that's step 3,555 of 6,746. Once on, they stay on, because from there the sky only darkens. TruckHeadlights.sprite matches on both tags. An Off lamp is an empty sprite. An On lamp's sprite is two beams, each a wedge that fades from the lamp toward its far end:

TruckHeadlights.roc
## How the headlights look: their two beams while they are on, and nothing
## while they are off.
sprite : TruckHeadlights, Sprite.Setting -> Sprite
sprite = |headlights, setting|
	match headlights {
		On => Sprite.from_shapes(beam_shapes(setting))
		Off => Sprite.from_shapes([])
	}

The brake lights §

The brake lights read the truck's pace, so they're on while the truck's last step slowed it, by day or by night. The pace is part of the truck's carried progress:

TruckJourney.roc
## Whether the truck's last step slowed it.
Pace := [Slowing, NotSlowing].{
	is_eq : _
}
TruckBrakeLights.roc
## The brake lights of a truck going at `pace`: on while its last step
## slowed it.
at : TruckJourney.Pace -> TruckBrakeLights
at = |pace|
	match pace {
		Slowing => On
		NotSlowing => Off
	}

Their sprite works as the headlights' does: two lamps on the back of the trailer, each with a glow around it, and an empty sprite when they're off.

For each frame, FrameView sets both lamps together, in one record of lights, from the sky's darkness and the truck's pace:

FrameView.roc, in ride_facts
# ...
headlights: TruckHeadlights.at(Sky.darkness_at(instant.clock)),
brake_lights: TruckBrakeLights.at(instant.truck.pace),
# ...

Step the ride back past step 3,555, and the headlights go off again: they read only the sky, so the sky turns them off. The brake lights read the truck's last step, which the ride carries, so they come back with it. Next, the truck moves.