Chapter 15
Mountains
However far the rider goes, the mountains stay as far away as ever. They only turn as the rider turns. So the ride treats them as far scenery: a mountain is just a height at a direction.
There are two ranges: a high one to the north, with snow on its peaks, and a lower one to the west. In front of both runs a band of low, rolling land, in the grass's own green.
Directions §
A direction needs a type, and so does a change of direction. Angle.Bearing is a direction in the world, such as north, and Angle.Turn is how far to turn from one direction to another. Both are opaque types around one float, and only the Angle module builds or opens them. Here a Turn is passed where a Bearing belongs:
app [main!] { game: "../../game.roc", lib: "../../../lib/main.roc" }
import game.Angle
main! = |_args| {
north = Angle.Bearing.radians(0.0)
quarter = Angle.Turn.radians(1.5)
echo!(north.turn_from(quarter).to_radians().to_str())
Ok({})
}
── ✗ type mismatch ──────────────────────────────────── turn_as_bearing.roc:8:24
The first argument being passed to this function has the wrong type.
echo!(north.turn_from(quarter).to_radians().to_str())
^^^^^^^
This argument has the type:
Angle.Turn
But turn_from needs the first argument to be:
Angle.Bearing
── 1 error and 0 warnings ────────────────────────────────── turn_as_bearing.roc
The compiler refuses it. A bearing plus a turn is a bearing. The turn from one bearing to another is a turn. The Angle module's functions keep the two types apart everywhere.
A range §
A range is a record:
## A mountain range: the bearing of its middle, how far it reaches either
## side, how far above the horizon it reaches at most, and the two frequencies of the ridges
## along it, broad and fine. Each ridge frequency is how many waves would fit
## a whole turn.
MountainRange : {
center : Angle.Bearing,
half_width : Angle.Turn,
height_ceiling : FarRise,
broad_ridge_waves : F64,
fine_ridge_waves : F64,
}
Its center is the bearing of its middle, and half_width is the turn from the middle to either end. height_ceiling is how high above the horizon it reaches at most. The two wave counts shape its ridges. A range's height at a bearing comes from one function:
range_height : MountainRange, Angle.Bearing -> FarHeight
The range is highest in its middle and falls to zero at its ends, and two waves on top of that height make its ridges, with sizes we chose by eye. The rolling land is a little under one wave of its own, all the way round.
Across the screen §
To find a range's outline, the ride goes across the screen two pixels at a time, and asks which bearing each column looks along. The middle of the screen looks along the camera's heading, and a column to its right looks a little further right, by a turn that comes from the lens. Lens.turn_at_x gives that turn from the column's place across the screen and the focal length. A column is a record:
## A column of the skyline: where it is across the screen, the bearing seen
## there, and the north range's height at that bearing, which the north range's
## silhouette and its snow both need. That height is worked out once per column,
## from its bearing, by `sky_columns_for`, the one builder of a column.
SkylineColumn : {
x : Pixels,
bearing : Angle.Bearing,
north_height : FarHeight,
}
A silhouette is one flat region whose top edge follows the heights along the columns. One function returns a silhouette, whichever range or land it is:
## A silhouette across the screen, its top at `height_at` each of `sky_columns`
## through a lens of `focal_length`, but never below `base_y`, closed along
## `base_y`.
silhouette :
List(SkylineColumn),
{
focal_length : Pixels,
height_at : SkylineColumn -> FarHeight,
color : RgbColor,
base_y : Pixels,
} -> Shapes.Shape
silhouette = |sky_columns, { focal_length, height_at, color, base_y }| {
left = Screen.drawn_left
right = Screen.drawn_right
crest = sky_columns.map(
|column| {
top_y = Lens.y_for_height({ height: height_at(column), focal_length })
{
x: column.x,
y: if top_y > base_y {
base_y
} else {
top_y
},
}
},
)
Shapes.region_from_corners(
crest.concat([{ x: right, y: base_y }, { x: left, y: base_y }]),
Brush.flat(color),
)
}
The record it takes has a field that holds a function: height_at, which says how high the silhouette stands at a column. A function is a value like any other, so it can sit in a record field. silhouette calls height_at(column) for each column and finds where that height lands on the screen with Lens.y_for_height. A mountain is so far off that its distance drops out: how high it looks depends only on its height above the horizon and the lens. Then silhouette joins the tops into one region closed along base_y, which is the grass's top for the ranges and the horizon for the land. The if holds the top at base_y or above it.
Back to front §
Skyline.skyline_shapes calls silhouette three times, with a different height_at each time, and returns the results back to front, in the order they're painted: the west range, the north range, the north's snow, then the land:
## The skyline, back to front, for a camera facing `heading` through a lens
## of `focal_length`: the west range, the north range, the north's snow, then the
## land. `darkness` is how far the sky has darkened, and the rock and snow
## darken as far.
skyline_shapes : { heading : Angle.Bearing, focal_length : Pixels, .. },
{
darkness : Fraction,
} -> List(Shapes.Shape)
skyline_shapes = |aim, { darkness }| {
sky_columns = sky_columns_for(aim)
snow_shapes =
match snow_shape(
sky_columns,
{ focal_length: aim.focal_length, color: snow_color(darkness) },
) {
Ok(snow) => [snow]
Err(NoSnow) => []
}
[
[
silhouette(
sky_columns,
{
focal_length: aim.focal_length,
height_at: |column| Skyline.west_range_height(column.bearing),
color: Sky.color_at_darkness(west_rock, darkness),
base_y: range_base_y,
},
),
silhouette(
sky_columns,
{
focal_length: aim.focal_length,
height_at: |column| column.north_height,
color: Sky.color_at_darkness(north_rock, darkness),
base_y: range_base_y,
},
),
],
snow_shapes,
[
silhouette(
sky_columns,
{
focal_length: aim.focal_length,
height_at: |column| Skyline.land_height(column.bearing),
color: Grass.grass_color_at(darkness),
base_y: land_base_y,
},
),
],
].join()
}
aim is the camera, as far as the skyline needs it. The .. in its type, { heading : …, focal_length : …, .. }, means the record may hold more fields, and this function reads just these two.
Each height_at is an anonymous function written in place. The west range's asks west_range_height for the column's bearing. The north range's reads the height the column already holds, since the snow needs it too. The land's asks land_height. The snow is a Try: when too little of it shows on the screen, it's Err(NoSnow), and the match gives an empty list.
At dusk the rock darkens through Sky.color_at_darkness, as the grass does, and the land takes the grass's own color. The snow goes blue, through the same blend the sky uses:
snow_color : Fraction -> RgbColor
snow_color = |darkness| ColorShade.color_between({
from: snow_day,
to: snow_night,
fraction: darkness,
})
The mountains are far scenery: they turn with the rider and stay as far away as ever. Next comes the road.