fuzzyworld

tower / files / src / terrain / farms.ts

farms.ts

Blame: the commit that last changed each line · file · history

abc0aad0 2026-10-01 Pavel Safronov1/**
2 * Farmland: fields of crops, each lit on its own day. See design.md §1.9.7, D-095.
3 *
4 * Tower can put any light on any field at any hour (§1.3.1), so a farm in Tower does not
5 * live by the sun. Each crop gets the day its plant wants: a long-day crop is lit on into
6 * the night, a short-day one is darkened early, and every one of them gets darkness, because
7 * most flowering plants will not flower without it and some, like tomatoes, are injured by
8 * light that never stops. In the day it is lit, a field is lit as at noon from first light
9 * to last. What Tower cannot do is make a field darker than the rod's own glow by day
10 * (§1.3.2), so a short day ends in dusk rather than in night.
11 *
12 * The fields are laid out here, as a pure function of where you are, and the same function
13 * is written out as GLSL (`FARM_GLSL`), so the map and the 3D view put the same crop in the
14 * same field. Hashes are a permutation mod 289, exact in float on both sides.
15 */
16import { CIRCUMFERENCE_M } from '../canon/constants.js'
17
18export interface Crop {
19 name: string
20 /** sRGB of the flowers, fruit or ripe heads, of the leaves, and how much of the canopy shows the first. */
21 bloom: readonly [number, number, number]
22 leaf: readonly [number, number, number]
23 cover: number
24 /** Planting rows, metres apart, or 0 for a crop sown broadcast. */
25 rowM: number
26 /** Hours of light a day Tower gives it. */
27 dayH: number
28 /** How often it is planted, relative to the others. */
29 weight: number
30}
31
32/**
33 * The crops, in bloom or at their peak. The day lengths are the plants' own: rapeseed, flax,
34 * wheat, lavender, poppies and phacelia are long-day plants and flower sooner and fuller under
35 * long days; buckwheat and potatoes are short-day (potatoes set tubers on short days); tulips,
36 * sunflowers, maize and tomatoes are day-neutral, and tomatoes need about six hours of dark.
37 */
38export const CROPS: readonly Crop[] = [
39 { name: 'red tulips', bloom: [214, 32, 44], leaf: [84, 122, 64], cover: 0.75, rowM: 1.2, dayH: 14, weight: 1.4 },
40 { name: 'yellow tulips', bloom: [246, 204, 38], leaf: [84, 122, 64], cover: 0.75, rowM: 1.2, dayH: 14, weight: 1.2 },
41 { name: 'pink tulips', bloom: [238, 112, 164], leaf: [84, 122, 64], cover: 0.75, rowM: 1.2, dayH: 14, weight: 1.0 },
42 { name: 'white tulips', bloom: [242, 238, 224], leaf: [84, 122, 64], cover: 0.7, rowM: 1.2, dayH: 14, weight: 0.6 },
43 { name: 'lavender', bloom: [134, 104, 186], leaf: [104, 124, 104], cover: 0.7, rowM: 1.6, dayH: 16, weight: 1.2 },
44 { name: 'sunflowers', bloom: [236, 182, 28], leaf: [70, 108, 40], cover: 0.55, rowM: 0.75, dayH: 14, weight: 1.0 },
45 { name: 'rapeseed', bloom: [242, 222, 40], leaf: [118, 148, 52], cover: 0.9, rowM: 0, dayH: 18, weight: 1.1 },
46 { name: 'poppies', bloom: [212, 38, 34], leaf: [108, 140, 62], cover: 0.5, rowM: 0, dayH: 16, weight: 0.8 },
47 { name: 'flax', bloom: [104, 146, 222], leaf: [108, 140, 82], cover: 0.6, rowM: 0, dayH: 18, weight: 0.7 },
48 { name: 'phacelia', bloom: [148, 136, 214], leaf: [88, 120, 70], cover: 0.7, rowM: 0, dayH: 16, weight: 0.6 },
49 { name: 'buckwheat', bloom: [240, 232, 226], leaf: [96, 134, 66], cover: 0.6, rowM: 0, dayH: 12, weight: 0.5 },
50 { name: 'maize', bloom: [192, 172, 92], leaf: [86, 128, 48], cover: 0.15, rowM: 0.75, dayH: 14, weight: 0.8 },
51 { name: 'potatoes', bloom: [232, 230, 240], leaf: [60, 108, 50], cover: 0.25, rowM: 0.9, dayH: 12, weight: 0.7 },
52 { name: 'tomatoes', bloom: [200, 46, 38], leaf: [52, 100, 46], cover: 0.2, rowM: 1.5, dayH: 16, weight: 0.5 },
53 { name: 'wheat', bloom: [216, 186, 104], leaf: [150, 150, 70], cover: 0.95, rowM: 0, dayH: 18, weight: 0.8 },
54 { name: 'lettuce', bloom: [136, 192, 80], leaf: [110, 170, 70], cover: 0.5, rowM: 0.4, dayH: 14, weight: 0.4 },
55]
56
57/** Bare soil between the rows, and the farm tracks. */
58export const SOIL = [112, 88, 64] as const
59export const TRACK = [150, 126, 96] as const
60
61/**
62 * The fields are laid out the way farmland grows, not on a grid (D-097). The land is first
63 * divided into **blocks** about 1.5 km across — irregular, a jittered Voronoi of the surface —
64 * with farm tracks along their edges. Each block was taken in hand on its own terms: its fields
65 * run at its own angle, are its own size (220 to 440 m), and are drawn out long by its own
66 * stretch, as fields cleared at different times by different hands are. Inside a block the
67 * fields are the cells of a second Voronoi in the block's own frame — straight edges at odd
68 * angles, no two alike — and a third of them are cut again into two to four long strips, as
69 * smallholdings are. So no line runs further than a block, and no two blocks agree.
70 *
71 * The block lattice closes round the ring on a whole number of blocks, and everything inside
72 * a block is measured from the block's own centre, so the pattern has no seam and its numbers
73 * stay small enough for float32.
74 */
75export const BLOCK_M = 1500
76export const BLOCKS_AROUND = Math.round(CIRCUMFERENCE_M / BLOCK_M)
77export const BLOCK_A_M = CIRCUMFERENCE_M / BLOCKS_AROUND
78/** Farm tracks along the blocks' edges, and the grass margin between fields. */
79export const TRACK_M = 6
80export const MARGIN_M = 1.5
81
82const permute = (x: number): number => ((34 * x + 1) * x) % 289
83const mod = (x: number, m: number): number => ((x % m) + m) % m
84/** 0..1 from a cell and a salt, as the GLSL computes it. */
85export function farmHash(i: number, j: number, salt: number): number {
86 return permute(mod(permute(mod(permute(mod(i, 289)) + mod(j, 289), 289)) + salt, 289)) / 289
87}
88
89/** The crop table's cumulative weights, 0..1, for picking by a hash. */
90const totalWeight = CROPS.reduce((s, c) => s + c.weight, 0)
91export const CROP_EDGES = CROPS.map((_, i) => CROPS.slice(0, i + 1).reduce((s, c) => s + c.weight, 0) / totalWeight)
92
93export function pickCrop(h: number): number {
94 const i = CROP_EDGES.findIndex((e) => h < e)
95 return i < 0 ? CROPS.length - 1 : i
96}
97
98export interface Field {
99 crop: number
100 /** Metres to the nearest edge of the field. */
101 edgeM: number
102 /** On a track between blocks, rather than in a field. */
103 track: boolean
104 /** Metres across the field's rows, which run along its length. */
105 rowCoordM: number
106 /** Where the field is, as the point it was laid out from: z, and a in [0, circumference). */
107 centreZ: number
108 centreA: number
109}
110
111interface Nearest { i: number; j: number; x: number; y: number; edge: number }
112
113/** The two nearest jittered cell points in a lattice, and the bisector's distance between them. */
114function voronoi(x: number, y: number, at: (i: number, j: number) => [number, number]): Nearest {
115 const n0 = Math.floor(x), m0 = Math.floor(y)
116 let d1 = Infinity, d2 = Infinity
117 let b1 = { i: 0, j: 0, x: 0, y: 0 }, b2 = b1
118 for (let dj = -1; dj <= 1; dj++) {
119 for (let di = -1; di <= 1; di++) {
120 const i = n0 + di, j = m0 + dj
121 const [px, py] = at(i, j)
122 const d = (px - x) ** 2 + (py - y) ** 2
123 const c = { i, j, x: px, y: py }
124 if (d < d1) { d2 = d1; b2 = b1; d1 = d; b1 = c } else if (d < d2) { d2 = d; b2 = c }
125 }
126 }
127 const mx = (b1.x + b2.x) / 2 - x, my = (b1.y + b2.y) / 2 - y
128 const len = Math.hypot(b2.x - b1.x, b2.y - b1.y) || 1
129 return { ...b1, edge: (mx * (b2.x - b1.x) + my * (b2.y - b1.y)) / len }
130}
131
132/** The field at a point: `z` along the axis and `a` around, metres. */
133export function fieldAt(z: number, a: number): Field {
134 const a0 = mod(a, CIRCUMFERENCE_M)
135 // The block: a Voronoi cell of a jittered lattice, in metres.
136 const blk = voronoi(z / BLOCK_M, a0 / BLOCK_A_M, (i, j) => {
137 const jw = mod(j, BLOCKS_AROUND)
138 return [i + 0.15 + 0.7 * farmHash(i, jw, 11), j + 0.15 + 0.7 * farmHash(i, jw, 23)]
139 })
140 const bi = blk.i, bj = mod(blk.j, BLOCKS_AROUND)
141 const cz = blk.x * BLOCK_M, ca = blk.y * BLOCK_A_M
142 const blockEdgeM = blk.edge * Math.min(BLOCK_M, BLOCK_A_M)
143 // The block's own hand: the angle its fields run at, their size and their stretch.
144 const ang = farmHash(bi, bj, 7) * Math.PI
145 const stretch = 1.3 + 0.9 * farmHash(bi, bj, 8)
146 const size = 220 + 220 * farmHash(bi, bj, 9)
147 const dz = z - cz, da = a0 - ca
148 const cs = Math.cos(ang), sn = Math.sin(ang)
149 const ux = cs * dz + sn * da, uy = -sn * dz + cs * da
150 // The fields: a Voronoi in the block's frame, drawn out along its length.
151 const fld = voronoi(ux / (size * stretch), uy / size, (i, j) => [
152 i + 0.1 + 0.8 * farmHash(bi * 17 + i, bj * 29 + j, 13),
153 j + 0.1 + 0.8 * farmHash(bi * 17 + i, bj * 29 + j, 19),
154 ])
155 const ki = bi * 17 + fld.i, kj = bj * 29 + fld.j
156 // Some fields are cut again into long strips.
157 let part = 0, stripEdge = Infinity
158 if (farmHash(ki, kj, 31) < 0.35) {
159 const k = 2 + Math.floor(farmHash(ki, kj, 37) * 3)
160 const t = (uy / size - fld.y) * k + k / 2
161 part = Math.floor(t)
162 const f = t - Math.floor(t)
163 stripEdge = Math.min(f, 1 - f) * (size / k)
164 }
165 const fieldEdgeM = fld.edge * size
166 // Back to the world, for the point the field was laid out from.
167 const px = fld.x * size * stretch, py = fld.y * size
168 return {
169 crop: pickCrop(farmHash(ki, kj, 41 + part)),
170 edgeM: Math.min(blockEdgeM - TRACK_M / 2, fieldEdgeM - MARGIN_M / 2, stripEdge - MARGIN_M / 3),
171 track: blockEdgeM < TRACK_M / 2,
172 rowCoordM: uy,
173 centreZ: cz + cs * px - sn * py,
174 centreA: mod(ca + sn * px + cs * py, CIRCUMFERENCE_M),
175 }
176}
177
178/**
179 * A farm grows (D-097): from its seed outward over the land, one patch of ground at a time,
180 * taking the cheapest patch at its edge next — cheap meaning near, and a little lucky, and
181 * never water, sand, rock, a range or a slope too steep to plough — until it has the ground
182 * it was given. Near-and-lucky is what makes it a blob with lobes and bays rather than a disc
183 * or a spider. The patches are a lattice `GROW_M` on a side; a field belongs to the farm if the
184 * patch under the point it was laid out from does, so the farm's edge follows its fields'.
185 */
186export const GROW_M = 200
187export const GROW_AROUND = Math.round(CIRCUMFERENCE_M / GROW_M)
188export const GROW_A_M = CIRCUMFERENCE_M / GROW_AROUND
189
190/** The patch a point is in, as one number. */
191export function growNode(z: number, a: number): number {
192 return Math.round(z / GROW_M) * GROW_AROUND + mod(Math.round(a / GROW_A_M), GROW_AROUND)
193}
194
195export function growFarm(
196 seedZ: number, seedA: number, areaM2: number, salt: number, fits: (z: number, a: number) => boolean,
197): number[] {
198 const target = Math.max(1, Math.round(areaM2 / (GROW_M * GROW_A_M)))
199 const r0 = Math.sqrt(areaM2 / Math.PI)
200 const iz0 = Math.round(seedZ / GROW_M), ia0 = Math.round(seedA / GROW_A_M)
201 const key = (iz: number, ia: number): number => {
202 const d = Math.hypot((iz - iz0) * GROW_M, (ia - ia0) * GROW_A_M) / r0
203 return 0.5 * d + 0.5 * farmHash(iz + salt, ia * 7 + salt, 61) + 0.25 * farmHash(ia + salt, iz * 3, 67)
204 }
205 const taken = new Set<number>(), seen = new Set<number>()
206 const out: number[] = []
207 // A plain sorted frontier: a few thousand patches at most.
208 const frontier: { iz: number; ia: number; k: number }[] = []
209 const id = (iz: number, ia: number): number => iz * GROW_AROUND + mod(ia, GROW_AROUND)
210 const offer = (iz: number, ia: number): void => {
211 const n = id(iz, ia)
212 if (seen.has(n)) return
213 seen.add(n)
214 if (!fits(iz * GROW_M, ia * GROW_A_M)) return
215 const k = key(iz, ia)
216 let lo = 0, hi = frontier.length
217 while (lo < hi) { const m = (lo + hi) >> 1; if (frontier[m]!.k < k) lo = m + 1; else hi = m }
218 frontier.splice(lo, 0, { iz, ia, k })
219 }
220 offer(iz0, ia0)
221 while (out.length < target && frontier.length) {
222 const { iz, ia } = frontier.shift()!
223 const n = id(iz, ia)
224 if (taken.has(n)) continue
225 taken.add(n)
226 out.push(n)
227 offer(iz + 1, ia); offer(iz - 1, ia); offer(iz, ia + 1); offer(iz, ia - 1)
228 }
229 return out
230}
231
232/** Light on a field now: 'on' as at noon, 'dim' (the rod's glow, by day), or 'night'. */
233export type FieldLight = 'on' | 'dim' | 'night'
234
235/**
236 * Whether Tower has a crop lit at `hourOfDay`. Its day starts at sunrise and lasts its own
237 * `dayH`, however long the sun's day is: past sunset it is lit on into the night, and short
238 * of it Tower takes its light away — which by day leaves the rod's glow.
239 */
240export function fieldLight(dayH: number, hourOfDay: number, sunriseH: number, sunsetH: number): FieldLight {
241 const since = mod(hourOfDay - sunriseH, 24)
242 if (since < dayH) return 'on'
243 return since < sunsetH - sunriseH ? 'dim' : 'night'
244}
245
246const f4 = (x: number): string => x.toFixed(4)
247const lin = (c: readonly number[]): string => `vec3(${c.map((v) => f4((v / 255) ** 2.2)).join(', ')})`
248
249/**
250 * The same, as GLSL: `farmField(zM, aM)` returning the crop index, the distance to the field's
251 * edge and the rows' direction, and the crop table as constants.
252 */
253export const FARM_GLSL = /* glsl */`
254float farmPermute(float x) { return mod((34.0 * x + 1.0) * x, 289.0); }
255float farmHash(float i, float j, float salt) {
256 return farmPermute(mod(farmPermute(mod(farmPermute(mod(i, 289.0)) + mod(j, 289.0), 289.0)) + salt, 289.0)) / 289.0;
257}
258const int CROP_COUNT = ${CROPS.length};
259const float CROP_EDGE[${CROPS.length}] = float[](${CROP_EDGES.map(f4).join(', ')});
260const vec3 CROP_BLOOM[${CROPS.length}] = vec3[](${CROPS.map((c) => lin(c.bloom)).join(', ')});
261const vec3 CROP_LEAF[${CROPS.length}] = vec3[](${CROPS.map((c) => lin(c.leaf)).join(', ')});
262const float CROP_COVER[${CROPS.length}] = float[](${CROPS.map((c) => f4(c.cover)).join(', ')});
263const float CROP_ROW[${CROPS.length}] = float[](${CROPS.map((c) => f4(c.rowM)).join(', ')});
264const float CROP_DAY[${CROPS.length}] = float[](${CROPS.map((c) => f4(c.dayH)).join(', ')});
265const vec3 FARM_SOIL = ${lin(SOIL)};
266const vec3 FARM_TRACK = ${lin(TRACK)};
267int pickCrop(float h) {
268 for (int i = 0; i < CROP_COUNT; i++) if (h < CROP_EDGE[i]) return i;
269 return CROP_COUNT - 1;
270}
271/** Nearest two of a jittered lattice: xy the nearest cell, z the bisector's distance, w unused. */
272vec4 farmBlocks(vec2 x) {
273 vec2 n0 = floor(x);
274 float d1 = 1e9, d2 = 1e9;
275 vec2 c1 = n0, p1 = vec2(0.0), p2 = vec2(0.0);
276 for (int dj = -1; dj <= 1; dj++) for (int di = -1; di <= 1; di++) {
277 vec2 c = n0 + vec2(float(di), float(dj));
278 float jw = mod(c.y, ${BLOCKS_AROUND.toFixed(1)});
279 vec2 p = c + 0.15 + 0.7 * vec2(farmHash(c.x, jw, 11.0), farmHash(c.x, jw, 23.0));
280 float d = dot(p - x, p - x);
281 if (d < d1) { d2 = d1; p2 = p1; d1 = d; p1 = p; c1 = c; } else if (d < d2) { d2 = d; p2 = p; }
282 }
283 return vec4(c1, dot(0.5 * (p1 + p2) - x, normalize(p2 - p1)), 0.0);
284}
285vec4 farmFields(vec2 x, vec2 b, out vec2 p1) {
286 vec2 n0 = floor(x);
287 float d1 = 1e9, d2 = 1e9;
288 vec2 c1 = n0, p2 = vec2(0.0);
289 p1 = vec2(0.0);
290 for (int dj = -1; dj <= 1; dj++) for (int di = -1; di <= 1; di++) {
291 vec2 c = n0 + vec2(float(di), float(dj));
292 vec2 k = vec2(b.x * 17.0 + c.x, b.y * 29.0 + c.y);
293 vec2 p = c + 0.1 + 0.8 * vec2(farmHash(k.x, k.y, 13.0), farmHash(k.x, k.y, 19.0));
294 float d = dot(p - x, p - x);
295 if (d < d1) { d2 = d1; p2 = p1; d1 = d; p1 = p; c1 = c; } else if (d < d2) { d2 = d; p2 = p; }
296 }
297 return vec4(c1, dot(0.5 * (p1 + p2) - x, normalize(p2 - p1)), 0.0);
298}
299/** x: crop, y: metres to the field's edge (negative on a track), z: metres across its rows, w: along them. */
300vec4 farmField(float zM, float aM) {
301 float a0 = mod(aM, ${CIRCUMFERENCE_M.toFixed(3)});
302 vec4 blk = farmBlocks(vec2(zM / ${f4(BLOCK_M)}, a0 / ${f4(BLOCK_A_M)}));
303 vec2 b = vec2(blk.x, mod(blk.y, ${BLOCKS_AROUND.toFixed(1)}));
304 vec2 jit = 0.15 + 0.7 * vec2(farmHash(b.x, b.y, 11.0), farmHash(b.x, b.y, 23.0));
305 vec2 centre = (blk.xy + jit) * vec2(${f4(BLOCK_M)}, ${f4(BLOCK_A_M)});
306 float blockEdge = blk.z * ${f4(Math.min(BLOCK_M, BLOCK_A_M))};
307 float ang = farmHash(b.x, b.y, 7.0) * 3.14159265;
308 float stretch = 1.3 + 0.9 * farmHash(b.x, b.y, 8.0);
309 float size = 220.0 + 220.0 * farmHash(b.x, b.y, 9.0);
310 vec2 d = vec2(zM - centre.x, a0 - centre.y);
311 float cs = cos(ang), sn = sin(ang);
312 vec2 u = vec2(cs * d.x + sn * d.y, -sn * d.x + cs * d.y);
313 vec2 p1;
314 vec4 fl = farmFields(vec2(u.x / (size * stretch), u.y / size), b, p1);
315 vec2 k = vec2(b.x * 17.0 + fl.x, b.y * 29.0 + fl.y);
316 float part = 0.0, stripEdge = 1e9;
317 if (farmHash(k.x, k.y, 31.0) < 0.35) {
318 float n = 2.0 + floor(farmHash(k.x, k.y, 37.0) * 3.0);
319 float t = (u.y / size - p1.y) * n + n * 0.5;
320 part = floor(t);
321 float f = fract(t);
322 stripEdge = min(f, 1.0 - f) * (size / n);
323 }
324 float edge = min(min(blockEdge - ${f4(TRACK_M / 2)}, fl.z * size - ${f4(MARGIN_M / 2)}), stripEdge - ${f4(MARGIN_M / 3)});
325 if (blockEdge < ${f4(TRACK_M / 2)}) edge = min(edge, -0.01);
326 return vec4(float(pickCrop(farmHash(k.x, k.y, 41.0 + part))), edge, u.y, u.x);
327}
328`