fuzzyworld

tower / files / src / terrain / ships.ts

ships.ts

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

a088e5d5 2026-09-17 Pavel Safronov1/**
2 * Sailing ships on the cap seas. See world-generation.md §18.
3 *
4 * A ship's position is a pure function of world time, like the weather: each one keeps
5 * a lane around the ring at a steady speed, so anyone at any hour sees the same fleet
6 * in the same places without anything having been simulated up to that hour.
7 *
8 * Ships stay in harbour while a gale or a storm is *published* for their sea (weather.ts
9 * `inHarbour`) — people act on what they were told. Harbours are not drawn yet, so a
10 * ship in harbour is simply not on the water.
11 *
12 * Only ring seas (bands) carry ships for now: a lane round a ring never meets a shore.
13 */
14import { CIRCUMFERENCE_M, G0 } from '../canon/constants.js'
15import { hash3, mulberry32 } from './noise.js'
16import type { Sea, World } from '../world/schema.js'
17import { inHarbour } from '../world/weather.js'
18
19export interface ShipLane {
20 sea: string
21 /** Mean distance along the axis, and how far the lane weaves either side of it. */
22 z0: number
23 weaveM: number
24 weaveLengthM: number
25 weavePhase: number
26 /** Where the ship was at hour 0, and which way round it sails: +1 spinward. */
27 a0: number
28 dir: 1 | -1
29 speedMS: number
30 lengthM: number
31}
32
33/** Ships keep at least this far off either shore of a band. */
34export const LANE_MARGIN_M = 4000
35
e18dad04 2026-09-30 Pavel Safronov36/**
37 * How far short of the ice edge a frozen sea's water counts as open for a ship's lane (D-090):
38 * the edge wanders 1.5 km either way (field.ts `seaIce`), and loose floes lie off it.
39 */
40export const ICE_CLEAR_M = 2000
41
42/**
43 * The lanes on a sea, seeded from the world and the sea, so everyone sees the same fleet.
44 * On a sea with ice they keep to the open water.
45 */
a088e5d5 2026-09-17 Pavel Safronov46export function lanesOf(sea: Sea, worldSeed: number): ShipLane[] {
47 if (sea.geometry.type !== 'band' || sea.ships <= 0) return []
e18dad04 2026-09-30 Pavel Safronov48 let { zFrom, zTo } = sea.geometry
49 if (sea.ice) {
50 if (sea.ice.fastM > sea.ice.edgeM) zTo = Math.min(zTo, sea.ice.edgeM - ICE_CLEAR_M)
51 else zFrom = Math.max(zFrom, sea.ice.edgeM + ICE_CLEAR_M)
52 }
a088e5d5 2026-09-17 Pavel Safronov53 const width = zTo - zFrom - 2 * LANE_MARGIN_M
54 if (width <= 0) return []
55 return Array.from({ length: sea.ships }, (_, i) => {
56 const r = mulberry32(hash3(worldSeed, sea.seed, 0x5419 + i))
57 // Most craft are small: lengths skew toward the short end, 8 m to 45 m.
58 const lengthM = 8 + 37 * r() ** 2.2
59 return {
60 sea: sea.id,
61 z0: zFrom + LANE_MARGIN_M + width * (0.1 + 0.8 * r()),
62 weaveM: 200 + 900 * r(),
63 weaveLengthM: 9000 + 30_000 * r(),
64 weavePhase: r() * Math.PI * 2,
65 a0: r() * CIRCUMFERENCE_M,
66 dir: r() < 0.5 ? 1 : -1,
67 // 3 to 6 m/s: 6 to 12 knots, a sailing vessel's cruise.
68 speedMS: 3 + 3 * r(),
69 lengthM,
70 }
71 })
72}
73
74export interface ShipFix {
75 z: number
76 /** Around, metres, in [0, circumference). */
77 a: number
78 /** Heading, radians from deepward toward spinward. */
79 headingRad: number
80}
81
82/** Where a lane's ship is at `hours`. */
83export function shipAt(lane: ShipLane, hours: number): ShipFix {
84 const travelled = lane.dir * lane.speedMS * hours * 3600
85 const a = (((lane.a0 + travelled) % CIRCUMFERENCE_M) + CIRCUMFERENCE_M) % CIRCUMFERENCE_M
86 const k = (2 * Math.PI) / lane.weaveLengthM
87 const phase = k * a + lane.weavePhase
88 const z = lane.z0 + lane.weaveM * Math.sin(phase)
89 // d(z)/d(a) along the lane, and the direction of travel around.
90 const dzda = lane.weaveM * k * Math.cos(phase)
91 return { z, a, headingRad: Math.atan2(lane.dir, lane.dir * dzda) }
92}
93
94/** Every ship on the water at `hours`: lanes on seas with no published gale or storm. */
95export function shipsAt(world: World, hours: number): { lane: ShipLane; fix: ShipFix }[] {
96 const out: { lane: ShipLane; fix: ShipFix }[] = []
97 for (const f of world.features) {
98 if (f.kind !== 'sea' || inHarbour(world.weather, f.id, hours)) continue
99 for (const lane of lanesOf(f, world.seed)) out.push({ lane, fix: shipAt(lane, hours) })
100 }
101 return out
102}
103
104/**
105 * The wave sum the water shader draws (interior-model.ts WAVES_GLSL), in TypeScript, so a
106 * ship rides the same waves that are drawn under it. Keep the two in step: the shader's
107 * constants are generated from these.
108 */
109// The last two are the wind's own short sea on top of the swell: without them a storm's
110// 600 m waves read as a gentle roll.
111export const WAVE_LEN = [1.0, 0.62, 0.41, 0.27, 0.13, 0.07] as const
112export const WAVE_AMP = [1.0, 0.55, 0.35, 0.22, 0.09, 0.045] as const
113export const WAVE_TURN = [0.0, 0.45, -0.38, 0.85, -0.9, 0.3] as const
114export const WAVE_PHASE = [0.0, 1.7, 4.1, 2.6, 5.3, 0.8] as const
115
116/**
117 * Height of the water at (z, a) metres and its slope along z and a, at `timeS` view
118 * seconds. `a` is taken in (-πR, πR], as the shader takes it from atan.
119 */
120export function waveAt(ampM: number, lengthM: number, dirRad: number, z: number, a: number, timeS: number):
121 { h: number; gz: number; ga: number } {
122 const out = { h: 0, gz: 0, ga: 0 }
123 if (ampM <= 0 || lengthM <= 0) return out
124 const aa = a > CIRCUMFERENCE_M / 2 ? a - CIRCUMFERENCE_M : a
125 const t = timeS % 600
126 for (let i = 0; i < WAVE_LEN.length; i++) {
127 const L = lengthM * WAVE_LEN[i]!
128 const amp = ampM * WAVE_AMP[i]!
129 const th = dirRad + WAVE_TURN[i]!
130 const dz = Math.cos(th), da = Math.sin(th)
131 const k = (2 * Math.PI) / L
132 const w = Math.sqrt(G0 * k)
133 const ph = k * (dz * z + da * aa) - w * t + WAVE_PHASE[i]!
134 out.h += amp * Math.cos(ph)
135 out.gz -= amp * k * Math.sin(ph) * dz
136 out.ga -= amp * k * Math.sin(ph) * da
137 }
138 return out
139}
140
141/**
142 * How far a sail swings out from the centreline for a wind blowing toward `windDirRad`:
143 * all the way out running before it, square along the centreline heading into it, and
144 * always to the side the wind blows toward.
145 */
146export function boomAngle(headingRad: number, windDirRad: number): number {
147 let rel = windDirRad - headingRad
148 rel = Math.atan2(Math.sin(rel), Math.cos(rel))
149 return Math.sign(rel || 1) * (Math.PI - Math.abs(rel)) / 2
150}