tower / commit
feat(farms): farms that grow, irregular fields, each crop on its own day; the world holds still while it is made (D-095..D-097)
README.md | 9 ++ content/world.json | 135 ++++++++++++++++++ decisions.md | 129 +++++++++++++++++ design.md | 81 ++++++++++- src/terrain/compile.ts | 59 +++++++- src/terrain/farms.ts | 328 ++++++++++++++++++++++++++++++++++++++++++++ src/terrain/field.ts | 44 +++++- src/terrain/map-image.ts | 12 ++ src/terrain/service.ts | 2 +- src/terrain/tile.ts | 4 +- src/ui/app.ts | 11 +- src/views/exterior.ts | 3 +- src/views/interior-model.ts | 76 +++++++++- src/views/interior.ts | 3 +- src/views/palette.ts | 1 + src/views/terrain-lod.ts | 4 +- src/world/clock.ts | 20 ++- src/world/schema.ts | 22 ++- test/farms.test.ts | 151 ++++++++++++++++++++ test/terrain.test.ts | 4 +- world-generation.md | 15 ++ 21 files changed, 1090 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1fbc6f8..bae33f3 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,15 @@ triangles — from inside and from outside. Tower can do this because it control the hull loses where, up to about 5 TW (design.md §1.9.4, §1.9.6; D-090). The sun cap is double-glazed and does none of this: the Sunward Sea is a temperate one. +**The farms keep their own days.** Nine farms round the world — on the Uplands, in clearings +cut out of Greatwood, and before the far end where the railway ends — share 250 km² of +farmland — tulips, lavender, sunflowers, rapeseed, poppies, flax, maize, potatoes, tomatoes, +wheat and more, most of it in bloom — is lit by Tower as each crop wants: as at noon from +sunrise for twelve to eighteen hours, long-day crops on into the night and short-day ones +dimmed early, and every one of them given some darkness, which flowering plants need. After +sunset the fields blaze in a dark world and go out one crop at a time (design.md §1.9.7; +D-095). + **Tower has a sun, and it crosses the world once a day.** The mirrorlets that fly in the beam gather into one shoal and throw the beam's light out sideways there, which makes a single blazing sun at one point on the beam. It lights at the **sun cap** at sunrise, moves diff --git a/content/world.json b/content/world.json index 857f737..66f87a4 100644 --- a/content/world.json +++ b/content/world.json @@ -56,6 +56,141 @@ } } }, + { + "id": "ringside-fields", + "name": "Ringside Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. On the Uplands at about 1,000 m, beside the Uplands Ring. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283749516, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 1.7, + "z": 129000 + }, + "areaKm2": 27.8 + }, + { + "id": "far-ringside-fields", + "name": "Far Ringside Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. On the Uplands, on the far side of the world from Ringside, by the Ring again. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283757435, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 4.3, + "z": 131000 + }, + "areaKm2": 27.8 + }, + { + "id": "greatwood-clearing", + "name": "Greatwood Clearing", + "blurb": "", + "notes": "D-095, D-096. Provisional name. A clearing cut out of Greatwood near its sunward edge. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283765354, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 2.6, + "z": 240000 + }, + "areaKm2": 27.8 + }, + { + "id": "capital-fields", + "name": "Capital Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. In Greatwood, 44 km antispinward of the Capital: the nearest fields to the city. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283773273, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 5.4, + "z": 262000 + }, + "areaKm2": 27.8 + }, + { + "id": "deepwood-fields", + "name": "Deepwood Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. Deep in Greatwood, on the far side of the world from The Deep. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283781192, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 3.8, + "z": 320000 + }, + "areaKm2": 27.8 + }, + { + "id": "greatwood-fields", + "name": "Greatwood Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. In Greatwood's deepward half. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283789111, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 2.0, + "z": 380000 + }, + "areaKm2": 27.8 + }, + { + "id": "railhead-fields", + "name": "Railhead Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. Where the Axial Line ends. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283797030, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 0.0, + "z": 470000 + }, + "areaKm2": 27.8 + }, + { + "id": "far-fields", + "name": "Far Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. On the open ground before the far end, opposite the railhead. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283804949, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 3.4, + "z": 455000 + }, + "areaKm2": 27.8 + }, + { + "id": "last-fields", + "name": "Last Fields", + "blurb": "", + "notes": "D-095, D-096. Provisional name. The nearest fields to the Deepward Sea and the ice. About 28 km², grown from here over the land (D-097); one of nine farms that share the 250 km² Tower lights on their own days. Tower gives each crop the day its plant wants (design.md §1.9.7): lit as at noon from sunrise, long-day crops on into the night, short-day crops dimmed early, every one of them some darkness.", + "public": true, + "seed": 1283812868, + "kind": "farmland", + "geometry": { + "type": "point", + "theta": 5.0, + "z": 470000 + }, + "areaKm2": 27.8 + }, { "id": "the-cap-range", "name": "The Cap Range", diff --git a/decisions.md b/decisions.md index 45595db..3551602 100644 --- a/decisions.md +++ b/decisions.md @@ -3208,3 +3208,132 @@ faint border, and closes as the leg starts to fade to the next. The next leg's c empty. No numbers: a clock read at a glance, the way a progress ring is. It is drawn as an SVG rectangle over the caption's border, its dash measured in pixels of the border's own length, which follows the caption's size as its text changes. Both tours, inside and out, have it. + +## D-095 — The Fields: farmland where each crop is lit on its own day + +**Asked for** (author, 2026-09-30): "please add farm fields to the model. these should be +realistic fields, in their perfect bloom, so colorful. and this will be a new thing, but these +fields will receive 100% sunlight, even at night." + +**The conflict, raised before building.** Tower's night is made by banking the beam, because +the rod's glow cannot be aimed away (§1.3.2). Fields lit at full sun all night need the beam +up, and the whole world gets the rod's glow with it: 250 km² of fields would add about +0.5 W/m² everywhere, all night; 1,000 km², about 2 W/m², a permanent twilight. **The author +chose 250 km²**, at z 430–496 km (the unauthored belt by the far end) — and, asked how the +flowers stay in bloom, changed the brief: "flowers get darkness, and other things (corn, +potatoes, tomatoes, etc.) will have appropriate timings. i thought 100% daylight would be +helpful for plants, but if it's not, adjust as appropriate, since Tower can provide whatever +lighting." + +**What it is.** + +- **The Fields** (provisional name), a new feature kind, `farmland`: 250 km² at z 455–480 km, + 10 km around, where the Axial Line ends. Its edge wanders by noise; it clears forest, and + stays off water, sand, rock and broken ground. +- **The fields** (`terrain/farms.ts`) are cells 480 m along the axis by about 360 m around — + the ring a whole number of them — some split in two, with 6 m tracks between. Each is one of + sixteen crops by a weighted hash, three-quarters of them flowers: tulips red, yellow, pink + and white, lavender, sunflowers, rapeseed, poppies, flax, phacelia, buckwheat; and maize, + potatoes, tomatoes, wheat, lettuce. Each crop has its bloom, fruit or ripe-head colour, its + leaf colour, how much of it shows, and its row spacing. The layout is a pure function of + position, written out in GLSL too, so the map and both views agree field by field. +- **Each crop's day** follows its plant: 18 h for rapeseed, flax and wheat; 16 h for lavender, + poppies, phacelia and tomatoes; 14 h for tulips, sunflowers, maize and lettuce; 12 h for + buckwheat and potatoes. Every crop gets at least six hours of dark. Its day starts at sunrise + and is lit as at noon throughout — on into the night where it is longer than the sun's; + where it is shorter, cut to the rod's glow, since Tower cannot make a field dark by day. +- **Drawn** as rows over soil with flowers or fruit among the leaves, clumps and gaps close in, + and the crop's average colour once rows are under a pixel. Lit fields are drawn as at noon + at the dial's brightness whatever the hour, so after sunset they blaze in a dark world and + go out crop by crop. The map colours each field by its crop. + +**The night it costs.** At the equinox about 91% of the fields are still lit at sunset — +220 GW, 0.4% of the beam — adding about 0.4 W/m² of rod glow everywhere, falling to 0.2 at +two hours, 0.1 at four, and nothing at six. **This is canon but not drawn**: the world's +night light in the app does not yet include it (design.md §5). + +**Tests** (`test/farms.test.ts`): the ring closes on whole cells; the same field at the same +place and across the seam; tracks between cells; every crop planted, flowers most; a long-day +crop lit into the night then dark, a short-day crop dimmed before sunset; six hours of dark for +every crop; and on the compiled world, farmland inside The Fields, cleared of trees, and none +outside it. + +## D-096 — The farms spread round the world, and the world holds still while it is made + +*Amends D-095.* + +**Asked for** (author, 2026-09-30): "the fields are concentrated in a single region. let's +split them up and rearrange them around the world." And, separately: "don't run the world +while the building process is going on. the 'sun' moves while the world is still building, +and that seems weird. have everything be paused until the build process is all finished." + +**Nine farms instead of one.** The 250 km² D-095 chose for the night stays the total; it is +now nine farms of about 28 km² each, rounded rectangles 6.2 km along the axis and 4.8 km +around, each its own `farmland` feature with a provisional name: + +| Farm | θ | z | Where | +|---|---|---|---| +| Ringside Fields | 1.7 | 129 km | the Uplands, beside the Uplands Ring | +| Far Ringside Fields | 4.3 | 131 km | the Uplands, the other side of the world | +| Greatwood Clearing | 2.6 | 240 km | Greatwood, near its sunward edge | +| Capital Fields | 5.4 | 262 km | Greatwood, 44 km antispinward of the Capital | +| Deepwood Fields | 3.8 | 320 km | Greatwood, opposite The Deep | +| Greatwood Fields | 2.0 | 380 km | Greatwood's deepward half | +| Railhead Fields | 0.0 | 470 km | where the Axial Line ends | +| Far Fields | 3.4 | 455 km | before the far end, opposite the railhead | +| Last Fields | 5.0 | 470 km | the nearest to the Deepward Sea and the ice | + +Each site was checked on the compiled world for open ground — no water, desert, bare rock +or range — before it was placed. The Uplands are the roughest: part of each Uplands farm +falls on broken ground and is left unploughed, as the farmland's own rules leave it. The +ones in Greatwood clear their ground of trees, so they read as clearings. + +**The world holds still while it is made.** The clock (`world/clock.ts`) gains `held`: it +keeps whether it is running — the panel still says so — but no time passes. The app holds it +from the moment it starts until the status dialog has gone, or, for a compile quick enough +never to show the dialog, until the compile is done; and again whenever the world is +recompiled. Both views read it too: while it is held their frame time is zero, so the hull +does not spin, the water does not move, the sky through the far cap does not turn and a tour +does not advance. + +**Tests:** nine farms sharing 240–260 km², spread more than 4 rad round the ring and 300 km +down the axis, each farmland at its heart and cleared of trees. + +## D-097 — Farms grow over the land, and their fields are irregular + +*Amends D-095 and D-096.* + +**Asked for** (author, 2026-09-30): "the current rectangular regions (both the general 9 and +the farms inside them) are very orderly, can we do some sort of natural growing pattern that +results in more random-looking patterns?" + +**A farm grows** (`terrain/farms.ts growFarm`, compiled last, over the land as it is). A farm +in the world file is now a **point and an area** — the nine of D-096 keep their places, each +`areaKm2: 27.8`. At compile time it grows from the point over a 200 m lattice of patches, always +taking next the patch on its edge with the lowest key: half how far it is from the seed (over +the radius a disc of that area would have), half luck, by hash. It never takes ground that is +wet, sand, rock, a range (over 150 m of relief), ice, or steeper than 12%, so it goes round a +river or a hill instead of over it, until it has its area. Near-and-lucky is what makes it a +blob with lobes and bays — a test holds it to at least 1.3 times the edge of a disc the same +size, all in one piece. A field belongs to the farm if the patch under the point it was laid +out from does, so **the farm's edge runs along its fields' edges** and no field is cut in half; +the ground shader sharpens the vertex-interpolated weight so the edge does not smear. A farm +may still be drawn as a polygon or band, which is its outline as before. + +**The fields are irregular** (`fieldAt`, and its GLSL twin). In place of D-095's grid: + +- **Blocks** about 1.5 km across: a jittered Voronoi of the surface, a whole number of them + round the ring, with 6 m farm tracks along their edges. +- **Each block on its own terms**: its fields run at its own angle, are its own size (220 to + 440 m) and drawn out long by its own stretch (1.3 to 2.2), as land cleared at different times + by different hands is. +- **Fields within a block**: a second Voronoi, in the block's frame — straight edges at odd + angles — with grass margins between. A third are cut into two to four long strips. +- Rows run along each field's length. Everything inside a block is measured from the block's + centre, so the pattern has no seam round the ring and its numbers stay small for float32. + +**Tests** (`test/farms.test.ts`): no seam; fields of many sizes (the largest fifth at least +twice the smallest fifth); tracks along the blocks and only there; every crop, flowers most; +a grown farm taking its area in one piece, ragged, and going round ground that does not fit; +and in the world, nine farms grown to 240–260 km² between them, farmland and no trees about +each seed. diff --git a/design.md b/design.md index 42dedf0..8bc5398 100644 --- a/design.md +++ b/design.md @@ -709,6 +709,57 @@ light dial, so the fast ice reads as dusk, not black. The frost closes a cleared cleared thaws across its turn. Both views share the window, so the frost is the same from outside. +#### 1.9.7 Farms on their own days + +*Asked for by the author, 2026-09-30; D-095.* + +**Tower gives each crop the day its plant wants.** Any light on any field at any hour is +what the swarm is for (§1.3.1), so a farm in Tower does not live by the sun. The author's +first thought was full sunlight around the clock; the plants would not thank Tower for it. +Most flowering plants measure the night to know when to flower, and some — tomatoes — are +injured by light that never stops. So every field gets darkness, and its own length of day: + +| | Day Tower gives it | Why | +|---|---|---| +| rapeseed, flax, wheat | 18 h | long-day plants: they flower sooner and fuller under long days | +| lavender, poppies, phacelia, tomatoes | 16 h | long-day; tomatoes are day-neutral but need about six hours of dark | +| tulips, sunflowers, maize, lettuce | 14 h | day-neutral, or nearly | +| buckwheat, potatoes | 12 h | short-day; potatoes set their tubers on short days | + +- **A field's day starts at sunrise and is lit as at noon** from first light to last, not + by a sun climbing and setting. A long-day crop is lit **on into the night**; a short-day + crop has its light taken away before the sun sets. +- **Tower cannot make a field dark by day.** The rod's own glow cannot be aimed away + (§1.3.2), so a short day ends in the far end's dusk, a twentieth of the light, and only + the world's night is truly dark. +- **The fields show at night.** For the first hours after sunset most of them are still lit + as at noon — a patchwork blazing in a dark world — and they go out one crop at a time, + the last of them six hours after sunset at the equinox. +- **They cost the world a little of its night.** Lighting fields after dark means a beam + not quite banked, and the rod glows with the beam. With **250 km²** of fields, chosen by + the author for this, about 220 GW goes to them just after sunset — 0.4% of the beam — + which adds about 0.4 W/m² of the rod's glow everywhere, a moonlit night's worth, falling + to nothing by six hours after sunset. At 1,000 km² it would be a permanent twilight. +- **Nine farms share the 250 km²**, spread round the world and down its length (D-096): + two on the Uplands beside the Uplands Ring, four in clearings cut out of Greatwood — one + of them the nearest fields to the Capital — and three on the open ground before the far + end, one at the Axial Line's railhead. Each is about 28 km² (provisional names: Ringside, + Far Ringside, Greatwood Clearing, Capital, Deepwood, Greatwood, Railhead, Far and Last + Fields). Three-quarters of the fields are flowers in bloom — tulips red, yellow, pink and + white, lavender, sunflowers, rapeseed, poppies, flax, phacelia, buckwheat — and the rest + maize, potatoes, tomatoes, wheat and lettuce. +- **Farms grow; they are not drawn** (D-097). Each farm is a seed and an area, and grows + outward over the land a patch at a time, taking the nearest and luckiest ground at its + edge and never water, sand, rock, a range or a slope too steep to plough — so it comes out + ragged, with lobes and bays, and goes round a river rather than across it. Its edge runs + along its fields' edges: a field is in the farm or not, whole. +- **Fields are irregular, the way old farmland is.** The land is divided into blocks about + 1.5 km across, with farm tracks along their edges, and each block was taken in hand on its + own terms: its fields run at its own angle, are its own size (220–440 m), and are drawn out + long by its own stretch. Inside it the fields have straight edges meeting at odd angles, + and a third of them are cut again into long strips. No line runs further than a block, and + no two blocks agree. + ### 1.10 Slat orientation — RESOLVED: none Axial strips versus rings was weighed at length (D-016) and answered by removing @@ -731,6 +782,7 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4): | 105–150 km | **The Uplands** | ground Tower raised to about 1,000 m, ringing the world, so the rivers off the range can run down to The Narrows (D-064) | | 148–218 km | **The Dry Reach** | desert **ringing the world**, 22,000 km², 13% of the surface: mesas over dune fields, dry washes, and salt pans ringed by shorelines (D-069) | | 176/190/203 km | **three ridges** | ringing, concentric — and **The Narrows** is the only breach in all three | +| 129–470 km | **the farms** | nine farms of about 28 km², 250 km² in all, round the world: on the Uplands, in Greatwood's clearings, before the far end (§1.9.7, D-095, D-096) | | 214–430 km | **Greatwood** | forest belt: about three billion trees, with meadows Tower left open in it (D-071) | | 248–402 km | **The Deep** | 154 × 32 km sea, long down the axis and narrow around | | 270 km | **The Capital / The Spindle** | axial midpoint; 20 km across, its spinward edge on The Deep's shore (D-070) | @@ -854,7 +906,8 @@ puts Sisters Tarn at 2,173 m. - **The Spindle's height.** 3.2 km is a placeholder; an object dropped from its top lands ~760 m antispinward of its base. -- **z 430–496 km** is unauthored. The circumference outside the named peaks is no +- **z 430–496 km** is unauthored but for three of the farms (D-096). The circumference + outside the named peaks is no longer empty — The Girdle carries the range round the whole 314 km — but it is unwalked: peaks with no names, and no rivers drawn off them yet. @@ -1390,3 +1443,29 @@ Things decided to be worth coming back to, not scheduled. everyone else. It is drawn as bright as from anywhere. Whether the sun going out over the ice each evening is seen from the ice as a blaze or as a dark disc in lit air is the author's call. +- **The farms' light at night** (D-095). Lighting the fields after sunset keeps the beam + from being fully banked, and the rod's glow with it: about 0.4 W/m² more everywhere at + sunset, nothing by six hours after. The app's night light does not include it yet. +- **3D models for the things drawn from plain shapes** (author, 2026-09-30). Everything that + moves or stands on the land is built in code from boxes, cones and spheres. The author will + supply real models; the app needs a way to load them. The list, with sizes as drawn: + - *Tour craft* (`views/craft.ts`, metres, nose along +x, top along +y): paraglider (wing + ~10 m), glider (17 m span), light aircraft (11 m span), seaplane, helicopter (11 m rotor), + airliner (42 m), airship (120 m), giant airship (420 m), locomotive (22 m), railway + carriage (25 m), spacecraft (~80 m). + - *Instanced, drawn by the hundred or thousand*: the sailing ship as hull, mainsail and jib + (~180 of them, 8–45 m; the mainsail swings about the mast, so it must stay a separate + part with the mast at the origin), conifer, broadleaf and giant tree (up to 6,000 each, + `views/forest-view.ts`), and the flat tree cards for distant woods (up to 75,000 — an + image, not a model). + - *Not candidates*: terrain, water, ice, falls, the railway viaducts, the hull dressings, the + collector, beam, sun, moon and windows, all of which are shaped by the world. + + The plan: glTF binary (`.glb`) files in a `models/` folder, a small manifest naming which + kind each replaces and how it faces, and the built-in shape as the fallback for any kind + without one, so models can arrive one at a time. Imported models go through Tower's own + lighting and haze — otherwise they are lit wrong and never fade into the distance — keeping + each part's colour at first, and image textures once a textured variant of the shader + exists. Budgets: tour craft can be detailed (tens of thousands of triangles); ships a few + thousand; trees a few hundred. Models must be licensed for redistribution, since the app is + published. diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts index 03c2033..e1336ea 100644 --- a/src/terrain/compile.ts +++ b/src/terrain/compile.ts @@ -22,6 +22,7 @@ import { type CForest, compileForests } from './forest.js' import { FALL_MIN_M, foamOf, manningSpeed } from './water.js' import { hash3 } from './noise.js' import { iceSide } from './ice.js' +import { GROW_AROUND, GROW_A_M, GROW_M, growFarm } from './farms.js' import { type Box, type Line, type Shape, nearestA, shapeBox, shapeOf, signedDistance, sliceLine, smoothLine, } from './shapes.js' @@ -69,6 +70,18 @@ export interface CBasin { openM: number } +/** Farmland (farms.ts, D-095): where the fields are. The fields themselves are laid out live. */ +export interface CFarm { + id: string + /** The outline of a drawn farm, or null for a grown one. */ + shape: Shape | null + edgeM: number + seed: number + box: Box + /** A grown farm's patches of ground (farms.ts `growFarm`), or empty for a drawn one. */ + nodes: number[] +} + /** A waterfall: where a river's surface steps down, and by how much. */ export interface CFall { river: string @@ -121,6 +134,8 @@ export interface CompiledWorld { seed: number ground: { baseM: number; reliefM: number } uplifts: CUplift[] + /** Optional, so a world compiled before farmland existed still loads from the cache. */ + farms?: CFarm[] /** The deserts' compiled landforms: plateaus, washes, salt pans (desert.ts). */ deserts: CDesert[] /** The forests: their density fields and what grows in them (forest.ts). */ @@ -149,7 +164,7 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com const cw: CompiledWorld = { seed: world.seed, ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM }, - uplifts: [], deserts: [], forests: [], mountains: [], basins: [], rivers: [], falls: [], + uplifts: [], deserts: [], forests: [], mountains: [], basins: [], rivers: [], falls: [], farms: [], } const seedOf = (f: Feature): number => hash3(world.seed, f.seed, 0x5eed) @@ -217,9 +232,51 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com } compileRivers(world.features.filter((f): f is River => f.kind === 'river'), cw, onStep) + compileFarms(world, cw, seedOf, onStep) return cw } +/** + * Farmland, last: a farm grows over the land as it is, water and rivers and all (D-097). A + * drawn farm is only its outline; its fields are laid out live (farms.ts). + */ +function compileFarms(world: World, cw: CompiledWorld, seedOf: (f: Feature) => number, onStep: StepListener): void { + const land = prepare({ ...cw, farms: [] }) + /** Ground a farm may take: dry, not sand or rock or a range, and gentle enough to plough. */ + const fits = (z: number, a: number): boolean => { + const theta = a / RADIUS_M + const s = land.sample(theta, z, 50) + if (s.water > s.h || s.desert > 0.2 || s.rock > 0.2 || s.relief > 150 || s.glacier > 0) return false + const h = s.h + const gz = (land.sample(theta, z + 100, 50).h - h) / 100 + const ga = (land.sample(theta + 100 / RADIUS_M, z, 50).h - h) / 100 + return Math.hypot(gz, ga) < 0.12 + } + for (const f of world.features) { + if (f.kind !== 'farmland') continue + onStep(`Sowing ${f.name}`) + const seed = seedOf(f) + if (f.geometry.type === 'point') { + const z = f.geometry.z, a = f.geometry.theta * RADIUS_M + const nodes = growFarm(z, a, f.areaKm2 * 1e6, seed % 289, fits) + // The box, measured round from the seed so a farm across θ = 0 is not the whole ring. + let z0 = Infinity, z1 = -Infinity, a0 = Infinity, a1 = -Infinity + for (const n of nodes) { + const nz = Math.floor(n / GROW_AROUND) * GROW_M + const na = a + nearestA((n % GROW_AROUND) * GROW_A_M, a) - a + z0 = Math.min(z0, nz); z1 = Math.max(z1, nz); a0 = Math.min(a0, na); a1 = Math.max(a1, na) + } + const pad = 1200 + cw.farms!.push({ id: f.id, shape: null, edgeM: f.edgeM, seed, nodes, + box: { z0: z0 - pad, z1: z1 + pad, a0: a0 - pad, a1: a1 + pad } }) + continue + } + const shape = shapeOf(f.geometry) + if (!shape) continue + cw.farms!.push({ id: f.id, shape, edgeM: f.edgeM, seed, nodes: [], box: grow(shapeBox(shape), f.edgeM) }) + } +} + /** The greatest distance from any interior point to the edge, sampled. */ function maxInside(shape: Shape): number { if (shape.type === 'band') return (shape.zTo - shape.zFrom) / 2 diff --git a/src/terrain/farms.ts b/src/terrain/farms.ts new file mode 100644 index 0000000..811d38d --- /dev/null +++ b/src/terrain/farms.ts @@ -0,0 +1,328 @@ +/** + * Farmland: fields of crops, each lit on its own day. See design.md §1.9.7, D-095. + * + * Tower can put any light on any field at any hour (§1.3.1), so a farm in Tower does not + * live by the sun. Each crop gets the day its plant wants: a long-day crop is lit on into + * the night, a short-day one is darkened early, and every one of them gets darkness, because + * most flowering plants will not flower without it and some, like tomatoes, are injured by + * light that never stops. In the day it is lit, a field is lit as at noon from first light + * to last. What Tower cannot do is make a field darker than the rod's own glow by day + * (§1.3.2), so a short day ends in dusk rather than in night. + * + * The fields are laid out here, as a pure function of where you are, and the same function + * is written out as GLSL (`FARM_GLSL`), so the map and the 3D view put the same crop in the + * same field. Hashes are a permutation mod 289, exact in float on both sides. + */ +import { CIRCUMFERENCE_M } from '../canon/constants.js' + +export interface Crop { + name: string + /** sRGB of the flowers, fruit or ripe heads, of the leaves, and how much of the canopy shows the first. */ + bloom: readonly [number, number, number] + leaf: readonly [number, number, number] + cover: number + /** Planting rows, metres apart, or 0 for a crop sown broadcast. */ + rowM: number + /** Hours of light a day Tower gives it. */ + dayH: number + /** How often it is planted, relative to the others. */ + weight: number +} + +/** + * The crops, in bloom or at their peak. The day lengths are the plants' own: rapeseed, flax, + * wheat, lavender, poppies and phacelia are long-day plants and flower sooner and fuller under + * long days; buckwheat and potatoes are short-day (potatoes set tubers on short days); tulips, + * sunflowers, maize and tomatoes are day-neutral, and tomatoes need about six hours of dark. + */ +export const CROPS: readonly Crop[] = [ + { name: 'red tulips', bloom: [214, 32, 44], leaf: [84, 122, 64], cover: 0.75, rowM: 1.2, dayH: 14, weight: 1.4 }, + { name: 'yellow tulips', bloom: [246, 204, 38], leaf: [84, 122, 64], cover: 0.75, rowM: 1.2, dayH: 14, weight: 1.2 }, + { name: 'pink tulips', bloom: [238, 112, 164], leaf: [84, 122, 64], cover: 0.75, rowM: 1.2, dayH: 14, weight: 1.0 }, + { name: 'white tulips', bloom: [242, 238, 224], leaf: [84, 122, 64], cover: 0.7, rowM: 1.2, dayH: 14, weight: 0.6 }, + { name: 'lavender', bloom: [134, 104, 186], leaf: [104, 124, 104], cover: 0.7, rowM: 1.6, dayH: 16, weight: 1.2 }, + { name: 'sunflowers', bloom: [236, 182, 28], leaf: [70, 108, 40], cover: 0.55, rowM: 0.75, dayH: 14, weight: 1.0 }, + { name: 'rapeseed', bloom: [242, 222, 40], leaf: [118, 148, 52], cover: 0.9, rowM: 0, dayH: 18, weight: 1.1 }, + { name: 'poppies', bloom: [212, 38, 34], leaf: [108, 140, 62], cover: 0.5, rowM: 0, dayH: 16, weight: 0.8 }, + { name: 'flax', bloom: [104, 146, 222], leaf: [108, 140, 82], cover: 0.6, rowM: 0, dayH: 18, weight: 0.7 }, + { name: 'phacelia', bloom: [148, 136, 214], leaf: [88, 120, 70], cover: 0.7, rowM: 0, dayH: 16, weight: 0.6 }, + { name: 'buckwheat', bloom: [240, 232, 226], leaf: [96, 134, 66], cover: 0.6, rowM: 0, dayH: 12, weight: 0.5 }, + { name: 'maize', bloom: [192, 172, 92], leaf: [86, 128, 48], cover: 0.15, rowM: 0.75, dayH: 14, weight: 0.8 }, + { name: 'potatoes', bloom: [232, 230, 240], leaf: [60, 108, 50], cover: 0.25, rowM: 0.9, dayH: 12, weight: 0.7 }, + { name: 'tomatoes', bloom: [200, 46, 38], leaf: [52, 100, 46], cover: 0.2, rowM: 1.5, dayH: 16, weight: 0.5 }, + { name: 'wheat', bloom: [216, 186, 104], leaf: [150, 150, 70], cover: 0.95, rowM: 0, dayH: 18, weight: 0.8 }, + { name: 'lettuce', bloom: [136, 192, 80], leaf: [110, 170, 70], cover: 0.5, rowM: 0.4, dayH: 14, weight: 0.4 }, +] + +/** Bare soil between the rows, and the farm tracks. */ +export const SOIL = [112, 88, 64] as const +export const TRACK = [150, 126, 96] as const + +/** + * The fields are laid out the way farmland grows, not on a grid (D-097). The land is first + * divided into **blocks** about 1.5 km across — irregular, a jittered Voronoi of the surface — + * with farm tracks along their edges. Each block was taken in hand on its own terms: its fields + * run at its own angle, are its own size (220 to 440 m), and are drawn out long by its own + * stretch, as fields cleared at different times by different hands are. Inside a block the + * fields are the cells of a second Voronoi in the block's own frame — straight edges at odd + * angles, no two alike — and a third of them are cut again into two to four long strips, as + * smallholdings are. So no line runs further than a block, and no two blocks agree. + * + * The block lattice closes round the ring on a whole number of blocks, and everything inside + * a block is measured from the block's own centre, so the pattern has no seam and its numbers + * stay small enough for float32. + */ +export const BLOCK_M = 1500 +export const BLOCKS_AROUND = Math.round(CIRCUMFERENCE_M / BLOCK_M) +export const BLOCK_A_M = CIRCUMFERENCE_M / BLOCKS_AROUND +/** Farm tracks along the blocks' edges, and the grass margin between fields. */ +export const TRACK_M = 6 +export const MARGIN_M = 1.5 + +const permute = (x: number): number => ((34 * x + 1) * x) % 289 +const mod = (x: number, m: number): number => ((x % m) + m) % m +/** 0..1 from a cell and a salt, as the GLSL computes it. */ +export function farmHash(i: number, j: number, salt: number): number { + return permute(mod(permute(mod(permute(mod(i, 289)) + mod(j, 289), 289)) + salt, 289)) / 289 +} + +/** The crop table's cumulative weights, 0..1, for picking by a hash. */ +const totalWeight = CROPS.reduce((s, c) => s + c.weight, 0) +export const CROP_EDGES = CROPS.map((_, i) => CROPS.slice(0, i + 1).reduce((s, c) => s + c.weight, 0) / totalWeight) + +export function pickCrop(h: number): number { + const i = CROP_EDGES.findIndex((e) => h < e) + return i < 0 ? CROPS.length - 1 : i +} + +export interface Field { + crop: number + /** Metres to the nearest edge of the field. */ + edgeM: number + /** On a track between blocks, rather than in a field. */ + track: boolean + /** Metres across the field's rows, which run along its length. */ + rowCoordM: number + /** Where the field is, as the point it was laid out from: z, and a in [0, circumference). */ + centreZ: number + centreA: number +} + +interface Nearest { i: number; j: number; x: number; y: number; edge: number } + +/** The two nearest jittered cell points in a lattice, and the bisector's distance between them. */ +function voronoi(x: number, y: number, at: (i: number, j: number) => [number, number]): Nearest { + const n0 = Math.floor(x), m0 = Math.floor(y) + let d1 = Infinity, d2 = Infinity + let b1 = { i: 0, j: 0, x: 0, y: 0 }, b2 = b1 + for (let dj = -1; dj <= 1; dj++) { + for (let di = -1; di <= 1; di++) { + const i = n0 + di, j = m0 + dj + const [px, py] = at(i, j) + const d = (px - x) ** 2 + (py - y) ** 2 + const c = { i, j, x: px, y: py } + if (d < d1) { d2 = d1; b2 = b1; d1 = d; b1 = c } else if (d < d2) { d2 = d; b2 = c } + } + } + const mx = (b1.x + b2.x) / 2 - x, my = (b1.y + b2.y) / 2 - y + const len = Math.hypot(b2.x - b1.x, b2.y - b1.y) || 1 + return { ...b1, edge: (mx * (b2.x - b1.x) + my * (b2.y - b1.y)) / len } +} + +/** The field at a point: `z` along the axis and `a` around, metres. */ +export function fieldAt(z: number, a: number): Field { + const a0 = mod(a, CIRCUMFERENCE_M) + // The block: a Voronoi cell of a jittered lattice, in metres. + const blk = voronoi(z / BLOCK_M, a0 / BLOCK_A_M, (i, j) => { + const jw = mod(j, BLOCKS_AROUND) + return [i + 0.15 + 0.7 * farmHash(i, jw, 11), j + 0.15 + 0.7 * farmHash(i, jw, 23)] + }) + const bi = blk.i, bj = mod(blk.j, BLOCKS_AROUND) + const cz = blk.x * BLOCK_M, ca = blk.y * BLOCK_A_M + const blockEdgeM = blk.edge * Math.min(BLOCK_M, BLOCK_A_M) + // The block's own hand: the angle its fields run at, their size and their stretch. + const ang = farmHash(bi, bj, 7) * Math.PI + const stretch = 1.3 + 0.9 * farmHash(bi, bj, 8) + const size = 220 + 220 * farmHash(bi, bj, 9) + const dz = z - cz, da = a0 - ca + const cs = Math.cos(ang), sn = Math.sin(ang) + const ux = cs * dz + sn * da, uy = -sn * dz + cs * da + // The fields: a Voronoi in the block's frame, drawn out along its length. + const fld = voronoi(ux / (size * stretch), uy / size, (i, j) => [ + i + 0.1 + 0.8 * farmHash(bi * 17 + i, bj * 29 + j, 13), + j + 0.1 + 0.8 * farmHash(bi * 17 + i, bj * 29 + j, 19), + ]) + const ki = bi * 17 + fld.i, kj = bj * 29 + fld.j + // Some fields are cut again into long strips. + let part = 0, stripEdge = Infinity + if (farmHash(ki, kj, 31) < 0.35) { + const k = 2 + Math.floor(farmHash(ki, kj, 37) * 3) + const t = (uy / size - fld.y) * k + k / 2 + part = Math.floor(t) + const f = t - Math.floor(t) + stripEdge = Math.min(f, 1 - f) * (size / k) + } + const fieldEdgeM = fld.edge * size + // Back to the world, for the point the field was laid out from. + const px = fld.x * size * stretch, py = fld.y * size + return { + crop: pickCrop(farmHash(ki, kj, 41 + part)), + edgeM: Math.min(blockEdgeM - TRACK_M / 2, fieldEdgeM - MARGIN_M / 2, stripEdge - MARGIN_M / 3), + track: blockEdgeM < TRACK_M / 2, + rowCoordM: uy, + centreZ: cz + cs * px - sn * py, + centreA: mod(ca + sn * px + cs * py, CIRCUMFERENCE_M), + } +} + +/** + * A farm grows (D-097): from its seed outward over the land, one patch of ground at a time, + * taking the cheapest patch at its edge next — cheap meaning near, and a little lucky, and + * never water, sand, rock, a range or a slope too steep to plough — until it has the ground + * it was given. Near-and-lucky is what makes it a blob with lobes and bays rather than a disc + * or a spider. The patches are a lattice `GROW_M` on a side; a field belongs to the farm if the + * patch under the point it was laid out from does, so the farm's edge follows its fields'. + */ +export const GROW_M = 200 +export const GROW_AROUND = Math.round(CIRCUMFERENCE_M / GROW_M) +export const GROW_A_M = CIRCUMFERENCE_M / GROW_AROUND + +/** The patch a point is in, as one number. */ +export function growNode(z: number, a: number): number { + return Math.round(z / GROW_M) * GROW_AROUND + mod(Math.round(a / GROW_A_M), GROW_AROUND) +} + +export function growFarm( + seedZ: number, seedA: number, areaM2: number, salt: number, fits: (z: number, a: number) => boolean, +): number[] { + const target = Math.max(1, Math.round(areaM2 / (GROW_M * GROW_A_M))) + const r0 = Math.sqrt(areaM2 / Math.PI) + const iz0 = Math.round(seedZ / GROW_M), ia0 = Math.round(seedA / GROW_A_M) + const key = (iz: number, ia: number): number => { + const d = Math.hypot((iz - iz0) * GROW_M, (ia - ia0) * GROW_A_M) / r0 + return 0.5 * d + 0.5 * farmHash(iz + salt, ia * 7 + salt, 61) + 0.25 * farmHash(ia + salt, iz * 3, 67) + } + const taken = new Set<number>(), seen = new Set<number>() + const out: number[] = [] + // A plain sorted frontier: a few thousand patches at most. + const frontier: { iz: number; ia: number; k: number }[] = [] + const id = (iz: number, ia: number): number => iz * GROW_AROUND + mod(ia, GROW_AROUND) + const offer = (iz: number, ia: number): void => { + const n = id(iz, ia) + if (seen.has(n)) return + seen.add(n) + if (!fits(iz * GROW_M, ia * GROW_A_M)) return + const k = key(iz, ia) + let lo = 0, hi = frontier.length + while (lo < hi) { const m = (lo + hi) >> 1; if (frontier[m]!.k < k) lo = m + 1; else hi = m } + frontier.splice(lo, 0, { iz, ia, k }) + } + offer(iz0, ia0) + while (out.length < target && frontier.length) { + const { iz, ia } = frontier.shift()! + const n = id(iz, ia) + if (taken.has(n)) continue + taken.add(n) + out.push(n) + offer(iz + 1, ia); offer(iz - 1, ia); offer(iz, ia + 1); offer(iz, ia - 1) + } + return out +} + +/** Light on a field now: 'on' as at noon, 'dim' (the rod's glow, by day), or 'night'. */ +export type FieldLight = 'on' | 'dim' | 'night' + +/** + * Whether Tower has a crop lit at `hourOfDay`. Its day starts at sunrise and lasts its own + * `dayH`, however long the sun's day is: past sunset it is lit on into the night, and short + * of it Tower takes its light away — which by day leaves the rod's glow. + */ +export function fieldLight(dayH: number, hourOfDay: number, sunriseH: number, sunsetH: number): FieldLight { + const since = mod(hourOfDay - sunriseH, 24) + if (since < dayH) return 'on' + return since < sunsetH - sunriseH ? 'dim' : 'night' +} + +const f4 = (x: number): string => x.toFixed(4) +const lin = (c: readonly number[]): string => `vec3(${c.map((v) => f4((v / 255) ** 2.2)).join(', ')})` + +/** + * The same, as GLSL: `farmField(zM, aM)` returning the crop index, the distance to the field's + * edge and the rows' direction, and the crop table as constants. + */ +export const FARM_GLSL = /* glsl */` +float farmPermute(float x) { return mod((34.0 * x + 1.0) * x, 289.0); } +float farmHash(float i, float j, float salt) { + return farmPermute(mod(farmPermute(mod(farmPermute(mod(i, 289.0)) + mod(j, 289.0), 289.0)) + salt, 289.0)) / 289.0; +} +const int CROP_COUNT = ${CROPS.length}; +const float CROP_EDGE[${CROPS.length}] = float[](${CROP_EDGES.map(f4).join(', ')}); +const vec3 CROP_BLOOM[${CROPS.length}] = vec3[](${CROPS.map((c) => lin(c.bloom)).join(', ')}); +const vec3 CROP_LEAF[${CROPS.length}] = vec3[](${CROPS.map((c) => lin(c.leaf)).join(', ')}); +const float CROP_COVER[${CROPS.length}] = float[](${CROPS.map((c) => f4(c.cover)).join(', ')}); +const float CROP_ROW[${CROPS.length}] = float[](${CROPS.map((c) => f4(c.rowM)).join(', ')}); +const float CROP_DAY[${CROPS.length}] = float[](${CROPS.map((c) => f4(c.dayH)).join(', ')}); +const vec3 FARM_SOIL = ${lin(SOIL)}; +const vec3 FARM_TRACK = ${lin(TRACK)}; +int pickCrop(float h) { + for (int i = 0; i < CROP_COUNT; i++) if (h < CROP_EDGE[i]) return i; + return CROP_COUNT - 1; +} +/** Nearest two of a jittered lattice: xy the nearest cell, z the bisector's distance, w unused. */ +vec4 farmBlocks(vec2 x) { + vec2 n0 = floor(x); + float d1 = 1e9, d2 = 1e9; + vec2 c1 = n0, p1 = vec2(0.0), p2 = vec2(0.0); + for (int dj = -1; dj <= 1; dj++) for (int di = -1; di <= 1; di++) { + vec2 c = n0 + vec2(float(di), float(dj)); + float jw = mod(c.y, ${BLOCKS_AROUND.toFixed(1)}); + vec2 p = c + 0.15 + 0.7 * vec2(farmHash(c.x, jw, 11.0), farmHash(c.x, jw, 23.0)); + float d = dot(p - x, p - x); + if (d < d1) { d2 = d1; p2 = p1; d1 = d; p1 = p; c1 = c; } else if (d < d2) { d2 = d; p2 = p; } + } + return vec4(c1, dot(0.5 * (p1 + p2) - x, normalize(p2 - p1)), 0.0); +} +vec4 farmFields(vec2 x, vec2 b, out vec2 p1) { + vec2 n0 = floor(x); + float d1 = 1e9, d2 = 1e9; + vec2 c1 = n0, p2 = vec2(0.0); + p1 = vec2(0.0); + for (int dj = -1; dj <= 1; dj++) for (int di = -1; di <= 1; di++) { + vec2 c = n0 + vec2(float(di), float(dj)); + vec2 k = vec2(b.x * 17.0 + c.x, b.y * 29.0 + c.y); + vec2 p = c + 0.1 + 0.8 * vec2(farmHash(k.x, k.y, 13.0), farmHash(k.x, k.y, 19.0)); + float d = dot(p - x, p - x); + if (d < d1) { d2 = d1; p2 = p1; d1 = d; p1 = p; c1 = c; } else if (d < d2) { d2 = d; p2 = p; } + } + return vec4(c1, dot(0.5 * (p1 + p2) - x, normalize(p2 - p1)), 0.0); +} +/** x: crop, y: metres to the field's edge (negative on a track), z: metres across its rows, w: along them. */ +vec4 farmField(float zM, float aM) { + float a0 = mod(aM, ${CIRCUMFERENCE_M.toFixed(3)}); + vec4 blk = farmBlocks(vec2(zM / ${f4(BLOCK_M)}, a0 / ${f4(BLOCK_A_M)})); + vec2 b = vec2(blk.x, mod(blk.y, ${BLOCKS_AROUND.toFixed(1)})); + vec2 jit = 0.15 + 0.7 * vec2(farmHash(b.x, b.y, 11.0), farmHash(b.x, b.y, 23.0)); + vec2 centre = (blk.xy + jit) * vec2(${f4(BLOCK_M)}, ${f4(BLOCK_A_M)}); + float blockEdge = blk.z * ${f4(Math.min(BLOCK_M, BLOCK_A_M))}; + float ang = farmHash(b.x, b.y, 7.0) * 3.14159265; + float stretch = 1.3 + 0.9 * farmHash(b.x, b.y, 8.0); + float size = 220.0 + 220.0 * farmHash(b.x, b.y, 9.0); + vec2 d = vec2(zM - centre.x, a0 - centre.y); + float cs = cos(ang), sn = sin(ang); + vec2 u = vec2(cs * d.x + sn * d.y, -sn * d.x + cs * d.y); + vec2 p1; + vec4 fl = farmFields(vec2(u.x / (size * stretch), u.y / size), b, p1); + vec2 k = vec2(b.x * 17.0 + fl.x, b.y * 29.0 + fl.y); + float part = 0.0, stripEdge = 1e9; + if (farmHash(k.x, k.y, 31.0) < 0.35) { + float n = 2.0 + floor(farmHash(k.x, k.y, 37.0) * 3.0); + float t = (u.y / size - p1.y) * n + n * 0.5; + part = floor(t); + float f = fract(t); + stripEdge = min(f, 1.0 - f) * (size / n); + } + float edge = min(min(blockEdge - ${f4(TRACK_M / 2)}, fl.z * size - ${f4(MARGIN_M / 2)}), stripEdge - ${f4(MARGIN_M / 3)}); + if (blockEdge < ${f4(TRACK_M / 2)}) edge = min(edge, -0.01); + return vec4(float(pickCrop(farmHash(k.x, k.y, 41.0 + part))), edge, u.y, u.x); +} +` diff --git a/src/terrain/field.ts b/src/terrain/field.ts index d44debd..8443091 100644 --- a/src/terrain/field.ts +++ b/src/terrain/field.ts @@ -20,12 +20,13 @@ * a metre. Same function, same answer, fewer octaves where they could not be seen. */ import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' -import { type CBasin, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js' +import { type CBasin, type CFarm, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js' import { BENCH_STEP_M, type CDesert, hillsAt, sampleDesert, terrace } from './desert.js' import { type CForest, canopyAt, stemsAt } from './forest.js' import { type CMountainGroup, pinKernel, sampleGrid } from './mountains.js' import { type CylPoint, cylPoint, fbm, hash3, ridged } from './noise.js' import { iceCover, iceMantle, iceMargin, iceSide, rangeRock } from './ice.js' +import { fieldAt, growNode } from './farms.js' import { type Box, LineIndex, type Nearest, nearestA, nearestOnSegments, signedDistance } from './shapes.js' export const Stage = { @@ -67,6 +68,8 @@ export interface Sample { ice: number /** Grounded ice standing out of the sea here, 0..1: the ice wall at the cap (D-093). */ glacier: number + /** Farmland here, 0..1 (farms.ts, D-095): which crop is the shader's to work out. */ + farm: number /** 0..1 claims on the surface. The shader turns these into colour. */ desert: number forest: number @@ -121,7 +124,7 @@ export class Runtime { private indexes: LineIndex[] private out: Sample = { h: 0, water: -Infinity, waterCover: 0, waterMesh: false, - body: -1, flowZ: 0, flowA: 0, foam: 0, ice: 0, glacier: 0, + body: -1, flowZ: 0, flowA: 0, foam: 0, ice: 0, glacier: 0, farm: 0, desert: 0, forest: 0, relief: 0, rock: 0, salt: 0, stems: 0, canopyM: 0, snowlineM: 3000, } private nearest: Nearest = { d: 0, seg: 0, t: 0 } @@ -148,6 +151,7 @@ export class Runtime { o.foam = 0 o.ice = 0 o.glacier = 0 + o.farm = 0 o.desert = 0 o.forest = 0 o.relief = 0 @@ -195,6 +199,8 @@ export class Runtime { if (!inBox(f.box, z, a)) continue forest(f, p, z, a, detailM, o) } + // Fields after the trees: a farm is cleared ground, and it takes its ground from the wood. + for (const f of w.farms ?? []) if (inBox(f.box, z, a)) farm(f, p, z, a, detailM, o) return o } } @@ -247,6 +253,40 @@ function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim) } +/** A grown farm's patches, as a set, made once per compiled farm. */ +const grown = new WeakMap<CFarm, Set<number>>() +function grownNodes(f: CFarm): Set<number> { + let s = grown.get(f) + if (!s) grown.set(f, (s = new Set(f.nodes))) + return s +} + +/** + * Farmland (farms.ts, D-095): cleared and sown inside its outline, which wanders by noise + * so the farm's edge is not a drawn line. Not on water, sand or rock, and not up a range: + * the fields stop where the ground is too broken to plough. + */ +function farm(f: CFarm, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { + if (o.water > o.h) return + let m: number + if (f.shape) { + const [wz, wa] = warp(f.seed, p, z, a, f.edgeM * 0.8, f.edgeM * 4, detailM) + m = smoothstep(-f.edgeM, 0, signedDistance(f.shape, wz, wa)) + } else { + // A grown farm (D-097) holds a field whole or not at all: whichever patch of ground the + // field was laid out from decides, so the farm's edge runs along its fields' edges. + const fld = fieldAt(z, a) + m = grownNodes(f).has(growNode(fld.centreZ, fld.centreA)) ? 1 : 0 + } + if (m <= 0) return + const claim = m * (1 - o.desert) * (1 - o.rock) * (1 - smoothstep(150, 400, o.relief)) + if (claim <= 0) return + o.farm = claim + o.forest *= 1 - claim + o.stems *= 1 - claim + o.canopyM *= 1 - claim +} + /** * The trees standing on this ground (forest.ts). A tile draws the canopy from a distance * and the trees themselves near the eye (views/forest-view.ts); both read this. diff --git a/src/terrain/map-image.ts b/src/terrain/map-image.ts index caf2309..cb86406 100644 --- a/src/terrain/map-image.ts +++ b/src/terrain/map-image.ts @@ -12,6 +12,7 @@ import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' import type { Runtime } from './field.js' import { cylPoint, fbm } from './noise.js' import { ICE, colourOf } from './palette.js' +import { CROPS, SOIL, fieldAt } from './farms.js' import { MATERIALS, materialsAt } from './tile.js' const HILLSHADE = { azimuthDeg: 315, altitudeDeg: 42, strength: 0.55 } @@ -71,6 +72,17 @@ export function renderMapRows( for (let i = 0; i < MATERIALS.length; i++) m[i] = mats[base * MATERIALS.length + i]! m[4] = m[4]! * (1 - Math.min(1, Math.max(0, (slope - 0.55) / 0.55))) colourOf(m, h, colour) + // Farmland (D-095): each field the colour its crop averages to, rows and soil and bloom. + const f = m[10]! + if (f > 0) { + const crop = CROPS[fieldAt((x + 0.5) * mppX, ((y + 0.5) * mppY) % CIRCUMFERENCE_M + thetaOrigin * RADIUS_M).crop]! + const plant = crop.rowM > 0 ? 0.66 : 1 + for (let k = 0; k < 3; k++) { + const own = crop.leaf[k]! + (crop.bloom[k]! - crop.leaf[k]!) * crop.cover + const field = SOIL[k]! + (own - SOIL[k]!) * plant + colour[k] = colour[k]! + (field - colour[k]!) * f + } + } const c = ice[base]! if (c > 0) { colour[0] = colour[0]! + (ICE[0] - colour[0]!) * c diff --git a/src/terrain/service.ts b/src/terrain/service.ts index 945fe6d..a1f0434 100644 --- a/src/terrain/service.ts +++ b/src/terrain/service.ts @@ -257,7 +257,7 @@ export class TerrainService { // Every compile step, plus drawing the map. const counted = world.features.filter((f) => - ['desert', 'forest', 'mountains', 'lake', 'sea', 'river'].includes(f.kind) + ['desert', 'forest', 'farmland', 'mountains', 'lake', 'sea', 'river'].includes(f.kind) || (f.kind === 'region' && f.upliftM)).length // Mountain ranges add an erosion step per group; counting one per range is an upper bound. const ranges = world.features.filter((f) => f.kind === 'mountains').length diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts index 170a6e2..0896e1e 100644 --- a/src/terrain/tile.ts +++ b/src/terrain/tile.ts @@ -53,7 +53,7 @@ export function tileCentreKm(t: TileKey): [number, number, number] { * of it can change without regenerating anything. */ export const MATERIALS = [ - 'plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed', 'salt', 'sandstone', + 'plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed', 'salt', 'sandstone', 'farm', ] as const /** @@ -160,6 +160,8 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number out[offset + 7] = s.waterMesh ? 1 : 0 out[offset + 8] = salt out[offset + 9] = sandstone + // Fields lie over whatever the ground would otherwise be; the shader draws the crop. + out[offset + 10] = s.farm * (1 - water) * (1 - snow) } /** diff --git a/src/ui/app.ts b/src/ui/app.ts index 3dc5f70..1421c27 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -193,14 +193,22 @@ export class App { /** Lines shown this generation: the bar follows them, so it never runs ahead of the words. */ let told = 0 let timer: ReturnType<typeof setTimeout> | null = null + /** + * The world stands still until it has been made (D-096): from the first compile until + * the status dialog has gone, or — for a compile quick enough never to show it — until + * the compile is done. Held from the start, before the first compile has even begun. + */ + const hold = (): void => clock.setHeld(service.progress.busy || !dialog.hidden || timer !== null) + clock.setHeld(true) const pump = (): void => { timer = null + hold() const p = service.progress const now = performance.now() if (dialog.hidden) { // Quick compiles never show the dialog at all, and their lines are dropped. - if (!p.busy) { queue.length = 0; told = 0; return } + if (!p.busy) { queue.length = 0; told = 0; hold(); return } const waited = now - p.startedAt if (waited < STATUS_DELAY_MS) { timer = setTimeout(pump, STATUS_DELAY_MS - waited) @@ -222,6 +230,7 @@ export class App { if (!p.busy && !queue.length && now - lastLine >= lineMs) { dialog.hidden = true told = 0 + hold() return } timer = setTimeout(pump, lineMs) diff --git a/src/views/exterior.ts b/src/views/exterior.ts index aa6b174..26840cc 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -784,7 +784,8 @@ export class ExteriorView { this.lastFrame = performance.now() const tick = (now: number) => { if (!this.running) return - const dt = Math.min(0.1, (now - this.lastFrame) / 1000) + // Nothing moves while the world is being made (clock.ts `held`, D-096). + const dt = clock.held ? 0 : Math.min(0.1, (now - this.lastFrame) / 1000) this.lastFrame = now // On the tour the hull turns at the leg's own rate — real time, unless it says otherwise. const spin = this.tour ? (this.tour.frozen ? 0 : this.tour.current?.rate ?? 1) : this.spinning ? this.timeScale : 0 diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts index c2198ae..cb84f0e 100644 --- a/src/views/interior-model.ts +++ b/src/views/interior-model.ts @@ -26,9 +26,10 @@ import { HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M, MOON_RADIUS_M, SPECIFIC_GAS_CONSTANT, SUN_RADIUS_M, TEMPERATURE_K, } from '../canon/constants.js' -import { type Sky, lightMix } from '../world/sky.js' +import { DIALS, type Sky, lightMix } from '../world/sky.js' import { PANE_KM, frostAt, panes } from '../world/frost.js' import { ICE, PALETTE_GLSL } from '../terrain/palette.js' +import { FARM_GLSL } from '../terrain/farms.js' import { LOWEST_GROUND_M } from '../terrain/tile.js' import { AIMED_AT_EDGE, AIMED_AT_FAST, ROD_GLOW_BY_DAY, lightShare as lightShareAt } from '../terrain/ice.js' import { fallSheet } from '../terrain/falls.js' @@ -250,12 +251,12 @@ const GROUND_VERT = /* glsl */` uniform mat4 uFrame; attribute vec4 aMatA; attribute vec4 aMatB; -attribute vec2 aExtra; +attribute vec3 aExtra; varying vec3 vWorld; varying vec3 vNormal2; varying vec4 vMatA; varying vec4 vMatB; -varying vec2 vExtra; +varying vec3 vExtra; void main() { vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz; @@ -275,10 +276,17 @@ varying vec3 vWorld; varying vec3 vNormal2; varying vec4 vMatA; varying vec4 vMatB; -varying vec2 vExtra; +varying vec3 vExtra; ${HAZE_GLSL} ${SUN_GLSL} ${PALETTE_GLSL} +${FARM_GLSL} +/** + * The farms' light (D-095): x hours since sunrise, y the sun's day, z how much brighter + * Tower's noon is than the sky now (through the exposure's 0.35 power), w 0 if the beam is + * shut down and nothing can be lit. + */ +uniform vec4 uFarm; /** Cheap value noise, to keep the ground from being flat colour. */ float hash(vec2 p) { @@ -297,7 +305,7 @@ void main() { float elevM = (${R.toFixed(1)} - length(vWorld.xy)) * 1000.0; // The palette is sRGB; every uniform colour here is linear. Three inserts that // decode into its own materials and never into a custom one. - vec3 base = pow(groundColour(vMatA, vMatB, vExtra, elevM), vec3(2.2)); + vec3 base = pow(groundColour(vMatA, vMatB, vExtra.xy, elevM), vec3(2.2)); /** * Strata. Rock is laid in beds, and a bed is a level in Tower — a constant radius — so * the bands line up across separate mesas however far apart they stand, which is what @@ -338,9 +346,60 @@ void main() { float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45; base *= 1.0 + (grain - 0.5) * 0.12 * near * land; + /** + * Farmland (farms.ts, D-095): the field this is in, its crop in rows over bare soil, the + * flowers or fruit among the leaves, and tracks between the fields. Close in a row is a + * row; past a few pixels a row, the crop is the mix it averages to. Coordinates are taken + * in the field's block, from its centre, which keeps the noise small enough for float32. + * A farm's edge follows its fields' (D-097), and is sharpened here so the tile's vertices + * do not smear it across a neighbour. + */ + float farmW = smoothstep(0.3, 0.7, vExtra.z); + int crop = 0; + if (farmW > 0.01) { + float zM = vWorld.z * 1000.0; + float aM = atan(vWorld.y, vWorld.x) * ${(R * 1000).toFixed(1)}; + if (aM < 0.0) aM += ${(2 * Math.PI * R * 1000).toFixed(1)}; + vec4 fld = farmField(zM, aM); + crop = int(fld.x + 0.5); + // Along the rows and across them, metres from the block's centre: small, so float32 holds. + vec2 local = fld.wz; + float foot = max(length(fwidth(vec2(zM, aM))), 0.01); + float rowM = CROP_ROW[crop]; + // Close in, a crop is plants: clumps and gaps at a hand's breadth, fading out by the + // time a pixel covers half a metre. + float closeIn = 1.0 - smoothstep(0.05, 0.5, foot); + float clump = vnoise(local / 0.12) * 0.5 + vnoise(local / 0.45 + 7.7) * 0.5; + float plant = 1.0 - 0.3 * smoothstep(0.62, 0.78, 1.0 - clump) * closeIn; + if (rowM > 0.0) { + float st = fract(local.y / rowM); + float band = smoothstep(0.12, 0.22, st) * (1.0 - smoothstep(0.78, 0.88, st)); + plant = mix(band * plant, 0.66, smoothstep(rowM * 0.25, rowM * 0.8, foot)); + } + float cover = CROP_COVER[crop]; + float blossom = vnoise(local / 0.35) * 0.6 + vnoise(local / 1.7 + 3.1) * 0.4; + float bloom = smoothstep(1.0 - cover - 0.08, 1.0 - cover + 0.08, blossom); + bloom = mix(bloom, cover, smoothstep(0.1, 0.6, foot)); + vec3 crops = mix(CROP_LEAF[crop], CROP_BLOOM[crop], bloom) * (1.0 + (clump - 0.5) * 0.5 * closeIn); + vec3 field = mix(FARM_SOIL, crops, plant); + field = mix(field, FARM_TRACK, 1.0 - smoothstep(-foot, foot, fld.y)); + base = mix(base, field, farmW); + } + // Lit by the sun wherever on the axis it is (SUN_GLSL). The rest of the world is lit // land, and lit land is what fills the shadows. vec3 lit = base * (0.34 + 0.80 * direct(vWorld, normalize(vNormal2))) * uExposure * lightShare(vWorld); + /** + * Each crop on its own day (farms.ts \`fieldLight\`): lit as at noon from sunrise for as + * many hours as the plant wants, on into the night if it wants more than the sun gives; + * taken down to the rod's glow by day if it wants fewer; dark by night. + */ + if (farmW > 0.01 && uFarm.w > 0.5) { + vec3 litF = lit; + if (uFarm.x < CROP_DAY[crop]) litF = base * 1.14 * uExposure * uFarm.z; + else if (uFarm.x < uFarm.y) litF = lit * ${(ROD_GLOW_BY_DAY ** 0.35).toFixed(4)}; + lit = mix(lit, litF, farmW); + } vec3 T = towerTransmittance(uCam, vWorld); gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0); @@ -2019,6 +2078,8 @@ export class InteriorModel { uMoon: { value: new Vector4(L / 2, 0, 0, 0) }, // The far end's dim, and the sky's light by where it comes from (SUN_GLSL, D-091). uDim: { value: new Vector4(0, 0, 0, 0) }, + // The farms' own days (GROUND_FRAG, D-095). + uFarm: { value: new Vector4(0, 12, 1, 1) }, uLightMix: { value: new Vector3(0, 1, 0) }, } } @@ -2207,6 +2268,10 @@ export class InteriorModel { const total = mix.sun + mix.rod + mix.moon const local = iced && eyeZ !== undefined ? lightShareAt(iced.ice!, iced.openM, eyeZ, mix) : 1 const exposure = (sky.watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35 + // The farms (D-095): where in its day each crop is, and how bright Tower's noon is now. + const sinceSunrise = (((sky.hourOfDay - sky.times.sunriseH) % 24) + 24) % 24 + const dayLen = sky.times.sunsetH - sky.times.sunriseH + const farmBoost = (DIALS[sky.dial] / Math.max(sky.watts, BRIGHTNESS_NIGHT_W_M2)) ** 0.35 const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(sky.watts * local, BRIGHTNESS_NIGHT_W_M2)) ** 0.6 const haze = new Color(sky.watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY) const zKm = (sky.sunZ ?? 0) / KM @@ -2225,6 +2290,7 @@ export class InteriorModel { const dim = m.uniforms['uDim']!.value as Vector4 if (iced) dim.set(iced.openM / KM, iced.ice!.edgeM / KM, iced.ice!.fastM / KM, Math.sign(iced.ice!.fastM - iced.ice!.edgeM)) else dim.set(0, 0, 0, 0) + ;(m.uniforms['uFarm']!.value as Vector4).set(sinceSunrise, dayLen, farmBoost, sky.dial === 'dark' ? 0 : 1) const lm = m.uniforms['uLightMix']!.value as Vector3 if (total > 0) lm.set(mix.sun / total, mix.rod / total, mix.moon / total) else lm.set(0, 1, 0) diff --git a/src/views/interior.ts b/src/views/interior.ts index b6482e4..05753a2 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -952,7 +952,8 @@ export class InteriorView { this.lastFrame = performance.now() const tick = (now: number) => { if (!this.running) return - const dt = Math.min(0.1, (now - this.lastFrame) / 1000) + // Nothing moves while the world is being made (clock.ts `held`, D-096). + const dt = clock.held ? 0 : Math.min(0.1, (now - this.lastFrame) / 1000) this.lastFrame = now /** * The pinwheel. Nothing inside Tower rotates — you, the ground and the air diff --git a/src/views/palette.ts b/src/views/palette.ts index 890a84d..68fd0d5 100644 --- a/src/views/palette.ts +++ b/src/views/palette.ts @@ -16,5 +16,6 @@ export const KIND_COLOR: Record<FeatureKind, string> = { river: '#5cc0e8', forest: '#6fbf8a', desert: '#d9c084', + farmland: '#e889b8', region: '#9aa8bd', } diff --git a/src/views/terrain-lod.ts b/src/views/terrain-lod.ts index 6acd492..1dcc4dd 100644 --- a/src/views/terrain-lod.ts +++ b/src/views/terrain-lod.ts @@ -238,8 +238,8 @@ export class TerrainLOD { const mats = new InterleavedBuffer(data.materials, MATERIALS.length) g.setAttribute('aMatA', new InterleavedBufferAttribute(mats, 4, 0, true)) g.setAttribute('aMatB', new InterleavedBufferAttribute(mats, 4, 4, true)) - // Salt and sandstone sit past the two vec4s (tile.ts MATERIALS). - g.setAttribute('aExtra', new InterleavedBufferAttribute(mats, 2, 8, true)) + // Salt, sandstone and farmland sit past the two vec4s (tile.ts MATERIALS). + g.setAttribute('aExtra', new InterleavedBufferAttribute(mats, 3, 8, true)) /** * The index array is shared, but each tile gets its own attribute around it. * Sharing the attribute too would share one GL buffer, and disposing any tile diff --git a/src/world/clock.ts b/src/world/clock.ts index 1711905..cba906a 100644 --- a/src/world/clock.ts +++ b/src/world/clock.ts @@ -18,6 +18,12 @@ export class WorldClock { /** Hours since day 0 began. */ hours = 12 running = false + /** + * Held while the world is being made (D-096): the clock keeps its running state — the + * panel still says it runs — but no time passes, so the sun does not cross a world that is + * not there yet. The views read this too, and stand still with it. + */ + held = false /** World seconds per real second. */ rate = 900 /** How bright Tower runs its day. */ @@ -80,12 +86,20 @@ export class WorldClock { this.changed() } + setHeld(on: boolean): void { + if (this.held === on) return + this.held = on + this.changed() + } + private tick(now: number): void { if (!this.running) return - const dt = Math.min(0.1, (now - this.last) / 1000) + const dt = this.held ? 0 : Math.min(0.1, (now - this.last) / 1000) this.last = now - this.hours += (dt * this.rate) / 3600 - this.changed() + if (dt > 0) { + this.hours += (dt * this.rate) / 3600 + this.changed() + } this.frame = requestAnimationFrame((t) => this.tick(t)) } diff --git a/src/world/schema.ts b/src/world/schema.ts index 846fb84..ce85002 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -24,7 +24,7 @@ export const WORLD_VERSION = 2 // ------------------------------------------------------------------------ kinds /** Features that shape the ground. A region can too, when it is given an uplift. */ -export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest'] as const +export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest', 'farmland'] as const /** Built things. Generated from in later phases (world-generation.md §4.7, §4.8). */ export const BUILT_KINDS = ['settlement', 'road', 'path', 'railway'] as const /** Labels on the world. They generate nothing, except a region with an uplift. */ @@ -250,6 +250,17 @@ export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note'> heightM?: number } +/** + * Fields of crops, each lit by Tower on its own day (terrain/farms.ts, D-095). The fields + * are laid out from where they are. A farm given as a **point** grows from it over the land + * until it has `areaKm2` (D-097); one given as a polygon or band is that outline, with a soft + * edge `edgeM` wide. + */ +export interface Farmland extends Base<'farmland'> { + edgeM: number + areaKm2: number +} + /** * A named area. It is a label, and it can also be ground Tower raised: `upliftM` lifts * everything inside it, with a soft edge `edgeM` wide, and paints nothing — the land @@ -261,7 +272,7 @@ export interface Region extends Base<'region'> { } export type Feature = - | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Railway | Marker | Region + | Mountains | Lake | Sea | River | Desert | Forest | Farmland | Settlement | Route | Railway | Marker | Region /** The gentle relief everywhere nothing else is (today's "open ground"). */ export interface Ground { @@ -378,6 +389,8 @@ export function geometryTypesFor(kind: FeatureKind): readonly GeometryType[] { } if (kind === 'settlement') return ['point'] if (kind === 'region') return ['polygon', 'band'] + // A farm is grown from a point (D-097), or drawn as an outline. + if (kind === 'farmland') return ['point', 'polygon', 'band'] // Markers can be any shape: The Narrows is a landmark with length. return ['point', 'polyline', 'polygon', 'band'] } @@ -422,6 +435,8 @@ export function withKind(common: Common, kind: FeatureKind): Feature { ...c, kind, upliftM: 0, edgeM: 3000, densityPerHa: 450, conifer: 0.55, meadows: 0.12, giants: false, treelineM: 2200, } + case 'farmland': + return { ...c, kind, edgeM: 600, areaKm2: 28 } case 'settlement': return { ...c, kind, size: 'village', radiusM: SETTLEMENT_RADIUS_M.village } case 'road': @@ -633,6 +648,9 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature { } case 'region': return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) } + case 'farmland': + return { ...d, edgeM: Math.max(1, num(f['edgeM'], d.edgeM)), + areaKm2: Math.max(0.1, Math.min(2000, num(f['areaKm2'], d.areaKm2))) } case 'settlement': { const sizes = ['hamlet', 'village', 'town', 'station', 'city'] as const const size = sizes.includes(f['size'] as never) ? (f['size'] as Settlement['size']) : d.size diff --git a/test/farms.test.ts b/test/farms.test.ts new file mode 100644 index 0000000..7184094 --- /dev/null +++ b/test/farms.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { CIRCUMFERENCE_M } from '../src/canon/constants.js' +import { + BLOCKS_AROUND, BLOCK_A_M, CROPS, GROW_AROUND, GROW_A_M, GROW_M, fieldAt, fieldLight, growFarm, pickCrop, +} from '../src/terrain/farms.js' +import { compileWorld } from '../src/terrain/compile.js' +import { prepare } from '../src/terrain/field.js' +import { parseWorld } from '../src/world/schema.js' + +const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))) + +/** The fields met on a grid over a square of ground, by the point each was laid out from. */ +function fieldsIn(z0: number, a0: number, sideM: number, stepM: number): Map<string, number> { + const seen = new Map<string, number>() + for (let z = z0; z < z0 + sideM; z += stepM) { + for (let a = a0; a < a0 + sideM; a += stepM) { + const f = fieldAt(z, a) + if (f.track) continue + const k = `${f.centreZ.toFixed(1)},${f.centreA.toFixed(1)}` + seen.set(k, (seen.get(k) ?? 0) + 1) + } + } + return seen +} + +describe('the fields are laid out as farmland grows, not on a grid (D-097)', () => { + it('closes the ring on a whole number of blocks, with no seam', () => { + expect(BLOCKS_AROUND * BLOCK_A_M).toBeCloseTo(CIRCUMFERENCE_M, 6) + expect(fieldAt(467_123, 2_345)).toEqual(fieldAt(467_123, 2_345)) + expect(fieldAt(467_123, 2_345)).toEqual(fieldAt(467_123, 2_345 + CIRCUMFERENCE_M)) + expect(fieldAt(467_123, -100)).toEqual(fieldAt(467_123, CIRCUMFERENCE_M - 100)) + }) + + it('makes fields of many sizes, not one', () => { + const sizes = [...fieldsIn(460_000, 100_000, 6000, 40).values()].sort((x, y) => x - y) + expect(sizes.length).toBeGreaterThan(60) + const small = sizes[Math.floor(sizes.length * 0.2)]!, large = sizes[Math.floor(sizes.length * 0.8)]! + expect(large / small).toBeGreaterThan(2) + }) + + it('runs tracks along the blocks and no further', () => { + let tracks = 0 + for (let i = 0; i < 4000; i++) if (fieldAt(460_000 + (i % 80) * 37, 100_000 + Math.floor(i / 80) * 41).track) tracks++ + expect(tracks / 4000).toBeGreaterThan(0.001) + expect(tracks / 4000).toBeLessThan(0.03) + }) + + it('plants every crop, flowers most', () => { + const seen = new Map<number, number>() + for (let i = 0; i < 6000; i++) { + const f = fieldAt(400_000 + (i % 80) * 230, Math.floor(i / 80) * 290) + seen.set(f.crop, (seen.get(f.crop) ?? 0) + 1) + } + expect(seen.size).toBe(CROPS.length) + const flowers = [...seen].filter(([c]) => c <= 10).reduce((s, [, n]) => s + n, 0) + expect(flowers / 6000).toBeGreaterThan(0.6) + expect(pickCrop(0)).toBe(0) + expect(pickCrop(0.9999)).toBe(CROPS.length - 1) + }) +}) + +describe('a farm grows over the land (D-097)', () => { + const patch = GROW_M * GROW_A_M + const all = (): boolean => true + + it('takes the ground it is given, in one piece', () => { + const nodes = growFarm(300_000, 50_000, 28e6, 5, all) + expect(nodes.length * patch).toBeGreaterThan(27.5e6) + expect(nodes.length * patch).toBeLessThan(28.5e6) + const set = new Set(nodes) + const reached = new Set([nodes[0]!]) + const queue = [nodes[0]!] + while (queue.length) { + const n = queue.pop()! + const iz = Math.floor(n / GROW_AROUND), ia = n % GROW_AROUND + for (const m of [n + GROW_AROUND, n - GROW_AROUND, iz * GROW_AROUND + ((ia + 1) % GROW_AROUND), iz * GROW_AROUND + ((ia + GROW_AROUND - 1) % GROW_AROUND)]) { + if (set.has(m) && !reached.has(m)) { reached.add(m); queue.push(m) } + } + } + expect(reached.size).toBe(nodes.length) + }) + + it('grows ragged, with lobes and bays — more edge than a disc of the same size', () => { + const nodes = new Set(growFarm(300_000, 50_000, 28e6, 5, all)) + let edge = 0 + for (const n of nodes) { + const iz = Math.floor(n / GROW_AROUND), ia = n % GROW_AROUND + for (const m of [n + GROW_AROUND, n - GROW_AROUND, iz * GROW_AROUND + ia + 1, iz * GROW_AROUND + ia - 1]) if (!nodes.has(m)) edge++ + } + const disc = 2 * Math.sqrt(Math.PI * nodes.size) * (4 / Math.PI) + expect(edge).toBeGreaterThan(disc * 1.3) + }) + + it('never takes ground that does not fit, and goes round it', () => { + const river = (z: number): boolean => Math.abs(z - 300_600) > 300 + const nodes = growFarm(300_000, 50_000, 28e6, 5, (z) => river(z)) + for (const n of nodes) expect(river(Math.floor(n / GROW_AROUND) * GROW_M)).toBe(true) + expect(nodes.length * patch).toBeGreaterThan(27.5e6) + }) +}) + +describe('each crop gets its own day (D-095)', () => { + // An equinox day: sunrise 07:00, sunset 19:00. + const rise = 7, set = 19 + + it('lights a long-day crop on into the night, and then gives it darkness', () => { + expect(fieldLight(18, 12, rise, set)).toBe('on') + expect(fieldLight(18, 23.5, rise, set)).toBe('on') + expect(fieldLight(18, 1.5, rise, set)).toBe('night') + }) + + it('takes a short-day crop down to the rod\'s dusk before the sun sets', () => { + expect(fieldLight(12, 19.5, rise, 20)).toBe('dim') + expect(fieldLight(12, 21, rise, 20)).toBe('night') + }) + + it('gives every crop at least six hours of darkness', () => { + for (const c of CROPS) expect(24 - c.dayH).toBeGreaterThanOrEqual(6) + }) +}) + +describe('the farms stand round the world (D-095, D-096, D-097)', () => { + const cw = compileWorld(world) + const rt = prepare(cw) + const farms = world.features.filter((f) => f.kind === 'farmland') + + it('are nine, seeded round the ring and down the axis, grown to 250 km² between them', () => { + expect(farms).toHaveLength(9) + const thetas = farms.map((f) => (f.geometry.type === 'point' ? f.geometry.theta : NaN)) + const zs = farms.map((f) => (f.geometry.type === 'point' ? f.geometry.z : NaN)) + expect(Math.max(...thetas) - Math.min(...thetas)).toBeGreaterThan(4) + expect(Math.max(...zs) - Math.min(...zs)).toBeGreaterThan(300_000) + const area = cw.farms!.reduce((s, f) => s + f.nodes.length, 0) * GROW_M * GROW_A_M / 1e6 + expect(area).toBeGreaterThan(240) + expect(area).toBeLessThan(260) + }) + + it('is farmland about each seed, cleared of forest, and none far from them', () => { + for (const f of farms) { + if (f.geometry.type !== 'point') continue + let farmed = 0 + for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) { + const s = rt.sample(f.geometry.theta + (j * 400) / 50_000, f.geometry.z + i * 400, 20) + if (s.farm > 0.5) { farmed++; expect(s.stems, f.name).toBeLessThan(1) } + } + expect(farmed, f.name).toBeGreaterThan(12) + } + expect(rt.sample(0, 440_000, 20).farm).toBe(0) + }) +}, 120_000) diff --git a/test/terrain.test.ts b/test/terrain.test.ts index 543b740..50ac566 100644 --- a/test/terrain.test.ts +++ b/test/terrain.test.ts @@ -17,8 +17,8 @@ const rt = prepare(cw) describe('the world compiles', () => { it('ports every v1 feature, plus the ground raised under the rivers', () => { // Twenty-nine ported from v1, plus The Tall Wood, which Tower placed from the seed (D-071), - // the two railways (D-086), and the Cap Range's name (D-093). - expect(world.features).toHaveLength(33) + // the two railways (D-086), and the Cap Range's name (D-093), and nine farms (D-095, D-096). + expect(world.features).toHaveLength(42) // Eight ranges in two groups: the Girdle with the four massifs on it, and the // three desert ridges, which overlap each other. expect(cw.mountains).toHaveLength(2) diff --git a/world-generation.md b/world-generation.md index b714cb0..1cbbd89 100644 --- a/world-generation.md +++ b/world-generation.md @@ -917,6 +917,7 @@ What each concept is authored as — the parameters a change touches: | Sea | band or polygon | level, depth, shore, wind, ships, ice (the ice edge and where the fast ice begins) | | Desert | polygon or band | uplift, edge, dunes, hills, plateaus, past water | | Forest | polygon or band | uplift, edge, density, conifer share, meadows, treeline, giants | +| Farmland | polygon or band | edge (D-095); the fields are laid out from where they are | | Settlement | point | size, radius; a generated one is **adopted** by writing it in | | Weather | the plan's events | target, kind, start, duration, wind, when published, revisions with reasons; the cycle | | Road / path | polyline | width | @@ -1242,6 +1243,20 @@ draws in about 1.2 s. its own hash against the cover, with leads between and cracks where two floes meet; the fast ice is one sheet cracked in 660 m cells. Ice stills the waves under it. The map whitens the water by the cover. +- **Farmland** (`terrain/farms.ts`, D-095, D-097). A farm given as a point grows at compile + time over a 200 m lattice: from its seed, always taking the frontier patch with the lowest + key — half its distance from the seed over the farm's radius, half a hash — and never a patch + that is wet, sand, rock, a range (relief over 150 m), ice, or steeper than 12%, until it has + its `areaKm2`. The fields are a Voronoi of jittered **blocks** ~1.5 km across (a whole + number round the ring) with 6 m tracks on their edges; in each block, a second Voronoi in + the block's own frame — its own angle, field size 220–440 m and stretch 1.3–2.2 — measured + from the block's centre so float32 holds; a third of those fields are cut into 2–4 strips. + Each field takes a crop from a weighted table by hash; its rows run along its length. A + field belongs to a grown farm if the patch under the point it was laid out from does. The + same layout is written as GLSL, so the map and the views agree field by field. A sample + carries only how much farmland it is, which clears forest; the ground shader draws the crop + — rows over soil, bloom among the leaves, averaged once a row is under a pixel — and lights + each field on its crop's own day from a uniform carrying the hours since sunrise. - **Ice mountains** (`field.ts glacier`, D-093): a sea's `ice.range` raises rock from the sea bed at `fromM` to `heightM` at the sea's end, steepening toward it (height ∝ t^1.6), ±30% round the ring and ridged by up to 0.7 of its height. Ice mantles it from where it leaves