tower / files / src / terrain / ships.ts
ships.ts
| 1 | /** |
| 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 | */ |
| 14 | import { CIRCUMFERENCE_M, G0 } from '../canon/constants.js' |
| 15 | import { hash3, mulberry32 } from './noise.js' |
| 16 | import type { Sea, World } from '../world/schema.js' |
| 17 | import { inHarbour } from '../world/weather.js' |
| 18 | |
| 19 | export 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. */ |
| 34 | export const LANE_MARGIN_M = 4000 |
| 35 | |
| 36 | /** |
| 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 | */ |
| 40 | export 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 | */ |
| 46 | export function lanesOf(sea: Sea, worldSeed: number): ShipLane[] { |
| 47 | if (sea.geometry.type !== 'band' || sea.ships <= 0) return [] |
| 48 | 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 | } |
| 53 | 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 | |
| 74 | export 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`. */ |
| 83 | export 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. */ |
| 95 | export 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. |
| 111 | export const WAVE_LEN = [1.0, 0.62, 0.41, 0.27, 0.13, 0.07] as const |
| 112 | export const WAVE_AMP = [1.0, 0.55, 0.35, 0.22, 0.09, 0.045] as const |
| 113 | export const WAVE_TURN = [0.0, 0.45, -0.38, 0.85, -0.9, 0.3] as const |
| 114 | export 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 | */ |
| 120 | export 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 | */ |
| 146 | export 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 | } |