nordic-skating / commit
feat(lake): long islands of reeds, with water both sides that is one again beyond
src/render/shores.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++-------- src/skater.ts | 32 +++++++++++++++++++++++++++++-- src/world/lake.ts | 38 +++++++++++++++++++++++++++++++++++- test/lake.test.ts | 23 ++++++++++++++++++++++ test/skater.test.ts | 39 ++++++++++++++++++++++++++++++++++++- 5 files changed, 174 insertions(+), 12 deletions(-) diff --git a/src/render/shores.ts b/src/render/shores.ts index 64be6fe..12e1e0f 100644 --- a/src/render/shores.ts +++ b/src/render/shores.ts @@ -23,6 +23,8 @@ const TREES_PER_ISLAND = 50 /** Reeds on an islet for each square metre of it, and the most on one. */ const REEDS_PER_M2 = 2.5 const MOST_REEDS = 260 +/** Reeds on each stretch of a long island. */ +const REEDS_PER_ISLAND = 1400 const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 }) const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true }) @@ -49,6 +51,8 @@ interface Shoreline { /** The island's two shores; the same number where there is no island. */ islandLeft: number islandRight: number + /** Whether the island is a low bank of reeds, as one standing in a channel is, and not wooded land. */ + reeds?: boolean } function shoreline(channel: Channel, s: number): Shoreline { @@ -57,6 +61,10 @@ function shoreline(channel: Channel, s: number): Shoreline { const last = channels[channels.length - 1]! const left = first.centre(s) - first.halfWidth(s) const right = last.centre(s) + last.halfWidth(s) + // The land in the middle is the island between a fork's two channels, or the island + // standing in one channel. + const within = channels.length === 1 ? first.island(s) : 0 + if (within > 0) return { left, right, islandLeft: first.centre(s) - within, islandRight: first.centre(s) + within, reeds: true } const islandLeft = first.centre(s) + first.halfWidth(s) const islandRight = last.centre(s) - last.halfWidth(s) // One channel, or two that have not yet drawn apart: no island, and its two shores @@ -131,10 +139,10 @@ function meshHeight(s0: number, s: number, columns: readonly number[], at: numbe /** How high the island between two channels is at `s`, a fraction `t` of the way across it. */ function islandHeight(lake: Lake, channel: Channel, s: number, t: number): number { - const { islandLeft, islandRight } = shoreline(channel, s) + const { islandLeft, islandRight, reeds } = shoreline(channel, s) // An island rises from both its shores, so its height is that of land as far inland - // as its nearer shore. - return lake.bankHeight(1, s, Math.min(t, 1 - t) * (islandRight - islandLeft)) + // as its nearer shore. A bank of reeds is a quarter as high as wooded land would be. + return lake.bankHeight(1, s, Math.min(t, 1 - t) * (islandRight - islandLeft)) * (reeds ? 0.25 : 1) } function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh { @@ -162,16 +170,46 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu } for (let i = 0; i < TREES_PER_ISLAND; i++) { const s = s0 + random() * CHUNK - const { islandLeft, islandRight } = shoreline(channel, s) + const { islandLeft, islandRight, reeds } = shoreline(channel, s) const width = islandRight - islandLeft const x = islandLeft + 2 + random() * (width - 4) - const ground = width > 8 ? meshHeight(s0, s, ACROSS, (x - islandLeft) / width, (rowS, t) => islandHeight(lake, channel, rowS, t)) : 0 - // No island here, or one too narrow to stand a tree on: a tree of no size. - plant(x, s, ground, width > 8 ? 0.7 + random() * 0.9 : 0) + const wooded = width > 8 && !reeds + const ground = wooded ? meshHeight(s0, s, ACROSS, (x - islandLeft) / width, (rowS, t) => islandHeight(lake, channel, rowS, t)) : 0 + // No island here, one too narrow to stand a tree on, or one of reeds: a tree of no size. + plant(x, s, ground, wooded ? 0.7 + random() * 0.9 : 0) } return mesh } +/** The reeds of one stretch of an island that has them, if there is one in this stretch. */ +function islandReeds(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.Object3D[] { + let any = false + for (let s = s0; s <= s0 + CHUNK && !any; s += STEP) any = shoreline(channel, s).reeds === true + if (!any) return [] + const random = mulberry32(seed ^ Math.imul(index, 0xc2b2ae35)) + const stand = new THREE.InstancedMesh(reed, straw, REEDS_PER_ISLAND) + const at = new THREE.Object3D() + for (let n = 0; n < REEDS_PER_ISLAND; n++) { + const s = s0 + random() * CHUNK + const { islandLeft, islandRight, reeds } = shoreline(channel, s) + const width = islandRight - islandLeft + // Across all of it but its very edges. + const t = 0.06 + 0.88 * random() + const here = reeds && width > 1.5 + at.position.set( + islandLeft + t * width - x0, + here ? meshHeight(s0, s, ACROSS, t, (rowS, across) => islandHeight(lake, channel, rowS, across)) - 0.05 : 0, + -(s - s0), + ) + at.rotation.set((random() - 0.5) * 0.35, random() * Math.PI, (random() - 0.5) * 0.35) + const tall = here ? 0.9 + random() * 1.3 : 0 + at.scale.set(tall > 0 ? 1 : 0, tall, tall > 0 ? 1 : 0) + at.updateMatrix() + stand.setMatrixAt(n, at.matrix) + } + return [stand] +} + /** The islets of one stretch: their humps, and the reeds on them. */ function islets(channel: Channel, x0: number, s0: number): THREE.Object3D[] { const found = channel.islets(s0, s0 + CHUNK) @@ -297,7 +335,7 @@ export class Shores { const t = ACROSS[col]! return [islandLeft + t * (islandRight - islandLeft), islandHeight(lake, channel, s, t)] }), snow)) - group.add(trees(lake, channel, this.seed, index, x0, s0), ...islets(channel, x0, s0)) + group.add(trees(lake, channel, this.seed, index, x0, s0), ...islets(channel, x0, s0), ...islandReeds(lake, channel, this.seed, index, x0, s0)) this.group.add(group) return { x: x0, s: s0, group } } diff --git a/src/skater.ts b/src/skater.ts index b881eef..0869b08 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -73,6 +73,9 @@ const LOOK_AHEAD = 18 const CARVE = 6 /** From this far before a fork, metres, steering one way is asking to go that way at it. */ const FORK_ASK = 200 +/** And from this far before an island, likewise; nearer than this the side is settled. */ +const ISLAND_ASK = 120 +const ISLAND_NEAR = 40 /** How long stride number `index` lasts, seconds: the same every time it is asked. */ export function strideSeconds(index: number): number { @@ -106,6 +109,11 @@ export interface Skater { * have not: the way they have asked to go at it. */ wish: number + /** + * Which side of an island the skater is passing, or is about to: -1 left, 1 right, and + * 0 when there is no island near. + */ + side: number /** Strides taken, with the fraction being how far through the current one. */ stride: number /** Seconds left in the water after falling through the ice; 0 while skating. */ @@ -117,7 +125,7 @@ export function createSkater(lake: Lake, s = 0): Skater { const channel = channelAt(lake, s) return { x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED, - hold: 0, idle: Infinity, wish: 0, stride: 0, under: 0, + hold: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0, } } @@ -156,6 +164,12 @@ function room(k: Skater, s: number): [number, number] { const own = reach(k.channel, s) let left = middle - own let right = middle + own + // An island in the channel: the skater has the water on the side of it they are on. + const island = k.channel.island(s) + if (island > 0) { + if ((k.side || Math.sign(k.x - middle) || 1) > 0) left = Math.max(left, middle + island + SHORE_MARGIN) + else right = Math.min(right, middle - island - SHORE_MARGIN) + } // While the other channel of a fork is still alongside, its width is theirs as well. const other = alongside(k) if (other) { @@ -198,6 +212,10 @@ const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / * The skater may point any way at all, back up the lake included. Nothing here assumes * otherwise; left alone, the autopilot turns them down the lake again. * + * An island is passed on the side the skater steered towards on the way up to it, or is + * already well over to, or else the side the island prefers; alongside it, they are on + * the side they are on. + * * At a fork, a skater who has steered one way on the way up to it goes that way, and one * who has not goes the way the fork prefers. A touch is enough: the steering is taken as * asking, and the autopilot does the rest. Until the island between the two channels @@ -218,6 +236,14 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul } const { channel } = k const along = Math.atan(channel.slope(k.s)) + // Which side of an island: alongside one, the side the skater is on; coming up to + // one, the side they asked for or are over to, or the island's own choice. + const off = k.x - channel.centre(k.s) + if (channel.island(k.s) > 0) k.side = Math.sign(off) || k.side || 1 + else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + ISLAND_NEAR) > 0) { + if (Math.abs(steer) > 0.05) k.side = Math.sign(steer) + else if (k.side === 0 && channel.island(k.s + ISLAND_NEAR) > 0) k.side = Math.abs(off) > 3 ? Math.sign(off) : channel.islandSide(k.s + ISLAND_NEAR) + } else k.side = 0 const [left, right] = room(k, k.s) // What turn is wanted: the steering's, or the autopilot's. @@ -243,7 +269,9 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul const [farLeft, farRight] = room(k, ahead) // Aimed clear of the edges, or down the middle where there is not the room for that. const clear = Math.min(EDGE, (farRight - farLeft) / 2) - const aim = clamp(channel.centre(ahead) + k.hold + carve, farLeft + clear, farRight - clear) + // Past an island, down the middle of the water on its side of it. + const line = channel.island(ahead) > 0 ? (farLeft + farRight) / 2 + carve / 2 : channel.centre(ahead) + k.hold + carve + const aim = clamp(line, farLeft + clear, farRight - clear) wanted = clamp(wrap(Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN) } diff --git a/src/world/lake.ts b/src/world/lake.ts index f815f36..543b807 100644 --- a/src/world/lake.ts +++ b/src/world/lake.ts @@ -41,6 +41,15 @@ export interface Channel { slope(s: number): number /** From the middle to either shore, metres. */ halfWidth(s: number): number + /** + * How far from the middle of the channel to either shore of the island standing in + * it, metres; 0 where there is none. An island is a long, low bank of reeds with water + * both sides: the channel parts round it and is one again beyond it. `halfWidth` has + * the island in it, so the water either side is as wide as the channel would have been. + */ + island(s: number): number + /** Which side of that island a skater who does nothing is taken: -1 left, 1 right. */ + islandSide(s: number): -1 | 1 /** * The islets standing in the ice between `from` and `to` metres along, in the order * they are come to. @@ -132,6 +141,15 @@ const ISLET_EVERY = 50 const ISLET_RADIUS = [2.5, 7.5] as const const ISLET_CLEAR = 9 const ISLET_WATER = 16 +/** + * Islands: there is room for one every `ISLAND_EVERY` metres, and most of those that + * are not near a fork have one. It is `ISLAND_LENGTH` long and, at its middle, `ISLAND_HALF` from its own + * middle to either shore, coming to a point at both ends. None near a fork, nor in water + * as narrow as a pass. + */ +const ISLAND_EVERY = 900 +const ISLAND_LENGTH = [180, 480] as const +const ISLAND_HALF = [6, 16] as const /** The first fork, and then how soon and how late after one fork the next comes, metres. */ const FIRST_FORK = 700 const NEXT_FORK = [1800, 4200] as const @@ -207,12 +225,28 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce: } const centre = (s: number): number => wander(s) + mix(parentShift, shift, away(s)) + /** The island at `s`, if it is this channel's own to have one there: half its width. */ + const ownIsland = (s: number): number => { + const index = Math.floor(s / ISLAND_EVERY) + if (hash(lane, 8000 + index) > 0.9) return 0 + // Somewhere in its place that leaves room for the longest island before the next place. + const from = (index + 0.05 + 0.35 * hash(lane, 5000 + index)) * ISLAND_EVERY + const length = mix(ISLAND_LENGTH[0], ISLAND_LENGTH[1], hash(lane, 6000 + index)) + if (s <= from || s >= from + length) return 0 + if (from < born + FORK_ZONE + 200 || from + length > ends - 400) return 0 + // Not in a pass, nor anywhere the lake is on its way into or out of one. + for (let part = 0; part <= 8; part++) if (ownWidth(from + (length * part) / 8) < 15) return 0 + return mix(ISLAND_HALF[0], ISLAND_HALF[1], hash(lane, 7000 + index)) * Math.sin((Math.PI * (s - from)) / length) + } + const island = (s: number): number => (parent && s < born ? parent.island(s) : s < born + FORK_RAMP ? 0 : ownIsland(s)) /** The islet in the `index`th place there is room for one, if there is one in it. */ const islet = (index: number): Islet | undefined => { const s = (index + 0.2 + 0.6 * hash(lane, 1000 + index)) * ISLET_EVERY // None while the other channel of a fork is still in sight, nor coming up to one. if (s < born + FORK_ZONE + 100 || s > ends - 300) return undefined if (noise(s / 900, key + 300) < 0.25 || hash(lane, 2000 + index) > 0.7) return undefined + // Nor beside an island, nor just off either end of one. + if (ownIsland(s) > 0 || ownIsland(s - 40) > 0 || ownIsland(s + 40) > 0) return undefined const width = self.halfWidth(s) if (width < ISLET_WATER) return undefined const radius = mix(ISLET_RADIUS[0], ISLET_RADIUS[1], hash(lane, 3000 + index) ** 2) @@ -225,7 +259,9 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce: const self: Channel = { centre, slope: (s) => centre(s + 0.5) - centre(s - 0.5), - halfWidth: (s) => (parent && s < born + FORK_RAMP ? mix(parent.halfWidth(s), ownWidth(s), away(s)) : ownWidth(s)), + halfWidth: (s) => (parent && s < born + FORK_RAMP ? mix(parent.halfWidth(s), ownWidth(s), away(s)) : ownWidth(s) + ownIsland(s)), + island, + islandSide: (s) => (parent && s < born ? parent.islandSide(s) : hash(lane, 9000 + Math.floor(s / ISLAND_EVERY)) < 0.5 ? -1 : 1), islets: (from, to) => { // Before the fork that made it, a channel is the one it forked from, islets and all. const found = parent && from < born ? parent.islets(from, Math.min(to, born)) : [] diff --git a/test/lake.test.ts b/test/lake.test.ts index f99f70d..c6efa1e 100644 --- a/test/lake.test.ts +++ b/test/lake.test.ts @@ -211,6 +211,29 @@ describe('lake', () => { expect(withIslets / stretches).toBeLessThan(0.4) }) + it('has long islands with water both sides, clear of forks and of islets', () => { + let islands = 0 + let within = false + let widest = 0 + follow(300_000, 5, (channel, s) => { + const island = channel.island(s) + if (island > 0 && !within) islands++ + within = island > 0 + if (island === 0) return + widest = Math.max(widest, island) + expect(island).toBeLessThanOrEqual(16) + // Water either side about as wide as the narrowest ordinary channel is to a side. + expect(channel.halfWidth(s) - island).toBeGreaterThanOrEqual(ORDINARY_HALF_WIDTH[0] - 2) + expect([-1, 1]).toContain(channel.islandSide(s)) + if (channel.birth) expect(s).toBeGreaterThan(channel.birth.s + FORK_ZONE) + expect(s).toBeLessThan(channel.end.s - 300) + expect(channel.islets(s - 30, s + 30)).toEqual([]) + }) + console.info(`${islands} islands in 300 km`) + expect(islands).toBeGreaterThan(40) + expect(widest).toBeGreaterThan(12) + }) + it('gives the autopilot a line that wanders slowly from side to side', () => { let leftmost = 0 let rightmost = 0 diff --git a/test/skater.test.ts b/test/skater.test.ts index 254fb0e..5d2e22f 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -25,6 +25,23 @@ const out = (k: Skater): number => { /** How much ice there is between the skater and the nearest islet's edge, metres. */ const clearOfIslets = (k: Skater): number => Math.min(Infinity, ...k.channel.islets(k.s - 30, k.s + 30).map((i) => Math.hypot(k.x - i.x, k.s - i.s) - i.radius)) +/** How much water there is between the skater and the island in their channel, metres; all there could be where there is none. */ +const clearOfIsland = (k: Skater): number => (k.channel.island(k.s) > 0 ? Math.abs(off(k)) - k.channel.island(k.s) : Infinity) +/** Where the skater's channel next has an island at its widest, from `from` on. */ +function nextIsland(from: number): number { + let channel = lake.start + let s = from + for (; ; s += 5) { + while (channel.end.s <= s) channel = channel.end.preferred + if (channel.island(s) > 5 && channel.island(s + 5) < channel.island(s)) return s + } +} +/** The channel a skater who does nothing is in at `s`. */ +function channelAtIsland(s: number): Skater['channel'] { + let channel = lake.start + while (channel.end.s <= s) channel = channel.end.preferred + return channel +} /** Someone steering this way and that, and sometimes not at all: the same someone every time. */ function fidget(seed: number): (t: number) => number { const random = mulberry32(seed) @@ -51,6 +68,7 @@ describe('skater', () => { let slowest = Infinity let fastest = 0 let widest = 0 + const islands = new Set<number>() const start = k.s for (let t = 0; t < 3600; t += DT) { const before = k.s @@ -60,8 +78,10 @@ describe('skater', () => { expect(k.under).toBe(0) // On the ice, always: never on the shore side of the two metres kept clear of it. if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(k.channel.halfWidth(k.s) - 0.5) - // Round every islet, with room to spare. + // Round every islet, with room to spare, and down one side of every island. expect(clearOfIslets(k)).toBeGreaterThan(ISLET_MARGIN) + expect(clearOfIsland(k)).toBeGreaterThan(SHORE_MARGIN / 2) + if (k.channel.island(k.s) > 0) islands.add(Math.sign(off(k))) if (k.channel !== channel) { // Doing nothing takes the way the fork prefers. expect(k.channel === channel.end.preferred).toBe(true) @@ -72,6 +92,8 @@ describe('skater', () => { widest = Math.max(widest, Math.abs(off(k))) } expect(forks).toBeGreaterThan(5) + // Some islands passed on the left and some on the right, as each prefers. + expect([...islands].sort()).toEqual([-1, 1]) // It carves: well off the middle at times, where there is the room. expect(widest).toBeGreaterThan(4) // The speed breathes with the stride, around its cruising speed and never far from it. @@ -122,6 +144,7 @@ describe('skater', () => { expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.5) } nearest = Math.min(nearest, clearOfIslets(k)) + expect(clearOfIsland(k)).toBeGreaterThan(0.5) // Never on one. (Steered straight at one they come inside its margin for a moment.) expect(nearest).toBeGreaterThan(0.2) } @@ -185,6 +208,20 @@ describe('skater', () => { expect(k.channel === other).toBe(true) }) + it('passes an island on the side it was steered towards on the way up to it', () => { + const middle = nextIsland(2000) + for (const steer of [-1, 1]) { + // From 150 m short of the island's near end: a touch, and then nothing. + let start = middle + while (channelAtIsland(start).island(start) > 0) start -= 5 + const k = createSkater(lake, start - 100) + run(k, steer, 0.6) + while (k.s < middle) stepSkater(k, straight, 0, DT) + expect(Math.sign(off(k))).toBe(steer) + expect(Math.abs(off(k))).toBeGreaterThan(k.channel.island(k.s) + SHORE_MARGIN) + } + }) + it('keeps to where it was steered for a moment, then eases back to the middle', () => { const k = createSkater(lake) run(k, 1, 1.5)