nordic-skating / commit
feat: islets of reeds to skate round, and a skater who can turn right round
index.html | 6 ++- src/main.ts | 9 ++-- src/render/ice.ts | 18 ++++++-- src/render/shores.ts | 128 +++++++++++++++++++++++++++++++++++++++++++-------- src/skater.ts | 127 +++++++++++++++++++++++++++++++++++++++----------- src/world/lake.ts | 53 +++++++++++++++++++++ test/lake.test.ts | 31 +++++++++++++ test/skater.test.ts | 100 +++++++++++++++++++++++++++++++--------- 8 files changed, 395 insertions(+), 77 deletions(-) diff --git a/index.html b/index.html index 5becc4b..89fa5ed 100644 --- a/index.html +++ b/index.html @@ -31,9 +31,11 @@ <h2>Controls</h2> <dl> <dt>Steer</dt> - <dd>Drag left or right from wherever you touch, or use ← → or A D. Let go and the skater straightens up, then drifts back to its own line.</dd> + <dd>Drag left or right from wherever you touch, or use ← → or A D. Let go and the skater straightens up, then drifts back to its own line. Hold it over and the skater turns right round: there is room for a circle wherever the water is wide.</dd> <dt>Choose a way at a fork</dt> - <dd>Steer towards it before the island begins. Do nothing and the skater picks one.</dd> + <dd>A touch towards it on the way up to the fork is enough. Do nothing and the skater picks one.</dd> + <dt>Islets</dt> + <dd>Tufts of reeds standing in the ice. The skater goes round them unasked; you can weave between them, or circle one.</dd> <dt>Sound on or off</dt> <dd>The button at the top right, or M.</dd> <dt>Another lake</dt> diff --git a/src/main.ts b/src/main.ts index 1ed6585..78e7aa6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -313,10 +313,13 @@ renderer.setAnimationLoop(() => { // The eyes lead where the lake goes, and turn most of the way into the skater's own // direction: a skater cutting towards a shore still watches the lake ahead. const along = Math.atan(skater.channel.slope(skater.s)) - const look = along + 0.75 * (skater.heading - along) + // (By the sine of the angle between them, which does the same for a skater who has + // turned right round and is, for the moment, heading back up it.) + const look = skater.heading - 0.25 * Math.sin(skater.heading - along) camera.rotation.set(-pitch, -look, -0.02 * skater.turn * skater.speed) - ice.update(skater.x, skater.s) - shores.update(skater.x, skater.s, skater.channel) + const facing = Math.cos(look - along) + ice.update(skater.x, skater.s, facing) + shores.update(skater.x, skater.s, skater.channel, facing) renderer.render(scene, camera) }) diff --git a/src/render/ice.ts b/src/render/ice.ts index 99e1a01..c6e4bac 100644 --- a/src/render/ice.ts +++ b/src/render/ice.ts @@ -20,6 +20,7 @@ const SIZE = 3200 */ const MAP_ACROSS = 1536 const MAP_ALONG = 768 +/** How much of it is behind a skater who looks down the lake; the rest is ahead. */ const MAP_BEHIND = 96 const MAP_STEP_ACROSS = 12 const MAP_STEP = 4 @@ -160,14 +161,20 @@ export class Ice { ;(this.mesh.material as THREE.MeshLambertMaterial).emissiveIntensity = strength } - /** Put the right patch of ice under a skater who is at `x` across and `s` along. */ - update(x: number, s: number): void { + /** + * Put the right patch of ice under a skater who is at `x` across and `s` along. + * `facing` is 1 for a skater looking down the lake, -1 for one looking back up it. + */ + update(x: number, s: number, facing = 1): void { // Only the fraction of a tile matters, and taking it here, in doubles, is what keeps // the picture sharp at any distance. // (The sheet is a whole, even number of tiles across, so its middle is on a tile edge.) this.texture.offset.set((x / TILE) % 1, (s / TILE) % 1) - if (!(s - MAP_BEHIND - this.mapS < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s) + // The map lies ahead of the skater whichever way that is, and round them when they + // look across the lake. It is redrawn when where it should start has moved on. + const behind = facing > 0.5 ? MAP_BEHIND : facing < -0.5 ? MAP_ALONG - MAP_BEHIND : MAP_ALONG / 2 + if (!(Math.abs(s - behind - this.mapS) < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s - behind) // The map stays where it was drawn while the sheet moves on with the skater. this.map.offset.set( (x - this.mapX + MAP_ACROSS / 2 - SIZE / 2) / MAP_ACROSS, @@ -175,9 +182,10 @@ export class Ice { ) } - private drawMap(x: number, s: number): void { + /** Draw the map for a skater at `x` across, starting `from` metres along. */ + private drawMap(x: number, from: number): void { // On whole steps, so that a redraw lands every value exactly where it was before. - this.mapS = Math.floor((s - MAP_BEHIND) / MAP_STEP) * MAP_STEP + this.mapS = Math.floor(from / MAP_STEP) * MAP_STEP this.mapX = Math.round(x / MAP_STEP_ACROSS) * MAP_STEP_ACROSS const data = this.map.image.data as Uint8Array let p = 0 diff --git a/src/render/shores.ts b/src/render/shores.ts index e8b1b37..64be6fe 100644 --- a/src/render/shores.ts +++ b/src/render/shores.ts @@ -10,16 +10,30 @@ const ROWS = CHUNK / STEP const INLAND = [0, 2, 6, 14, 30, 60, 120, 220, 400] /** How far across an island each column of its mesh is, as a fraction of its width. */ const ACROSS = [0, 0.04, 0.12, 0.25, 0.5, 0.75, 0.88, 0.96, 1] -/** Stretches kept behind the skater and made ahead, metres. The fog ends at 600. */ -const BEHIND = 100 +/** + * How far the land is made in the direction the skater faces, and how far the other way, + * metres. The fog ends at 600. Facing across the lake, it is made that far both ways. + */ const AHEAD = 700 +const BEHIND = 100 +/** Stretches made in one frame at most, so that turning round does not make them all at once. */ +const MADE_AT_ONCE = 2 const TREES_PER_BANK = 60 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 const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 }) const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true }) // A spruce is a cone standing on its base. const cone = new THREE.ConeGeometry(1.7, 8, 6).translate(0, 4, 0) +// An islet is a low hump of frozen ground, white with frost, with reeds standing in it: +// dry, straw-coloured, a metre or two tall, and thin. +const frost = new THREE.MeshStandardMaterial({ color: 0xd9d6c8, roughness: 1 }) +const straw = new THREE.MeshStandardMaterial({ color: 0xb59a62, roughness: 1 }) +const hump = new THREE.SphereGeometry(1, 12, 5, 0, Math.PI * 2, 0, Math.PI / 2).scale(1, 0.14, 1) +const reed = new THREE.ConeGeometry(0.045, 1, 3).translate(0, 0.5, 0) interface Chunk { /** Where the chunk's own origin is: across the lake, and along it. */ @@ -95,13 +109,42 @@ function land(columns: number, x0: number, s0: number, place: (s: number, col: n return geometry } +/** + * How high a strip of land's mesh is at `s` along and `at` across, where `columns` is + * what its columns are placed by and `height` the height it was given at a row and a + * column. Not the height of the land itself there: the mesh is flat between its rows and + * columns and the land curves, so the two differ by a metre or more far inland, where + * the columns are far apart. Whatever stands on the land stands on its mesh. + */ +function meshHeight(s0: number, s: number, columns: readonly number[], at: number, height: (s: number, column: number) => number): number { + let col = 0 + while (col < columns.length - 2 && columns[col + 1]! < at) col++ + const across = Math.max(0, Math.min(1, (at - columns[col]!) / (columns[col + 1]! - columns[col]!))) + const row = Math.floor((s - s0) / STEP) + const along = (s - s0) / STEP - row + const atRow = (r: number): number => { + const rowS = s0 + r * STEP + return height(rowS, columns[col]!) * (1 - across) + height(rowS, columns[col + 1]!) * across + } + return atRow(row) * (1 - along) + atRow(row + 1) * along +} + +/** 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) + // 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)) +} + function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh { const random = mulberry32(seed ^ Math.imul(index, 0x9e3779b1)) const mesh = new THREE.InstancedMesh(cone, spruce, TREES_PER_BANK * 2 + TREES_PER_ISLAND) const at = new THREE.Object3D() let n = 0 - const plant = (x: number, s: number, d: number, size: number): void => { - at.position.set(x - x0, lake.bankHeight(1, s, d) - 0.3, -(s - s0)) + const plant = (x: number, s: number, ground: number, size: number): void => { + // A little into the ground, so that no tree shows its base on a slope. + at.position.set(x - x0, ground - 0.3, -(s - s0)) at.rotation.y = random() * Math.PI at.scale.set(size, size * (0.8 + random() * 0.5), size) at.updateMatrix() @@ -113,7 +156,8 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu const shore = shoreline(channel, s) // Thick along the shore and thinning out up the hill. const d = 5 + random() ** 2 * 170 - plant((side < 0 ? shore.left : shore.right) + side * d, s, d, 0.7 + random() * 0.9) + const ground = meshHeight(s0, s, INLAND, d, (rowS, inland) => lake.bankHeight(side, rowS, inland)) + plant((side < 0 ? shore.left : shore.right) + side * d, s, ground, 0.7 + random() * 0.9) } } for (let i = 0; i < TREES_PER_ISLAND; i++) { @@ -121,14 +165,52 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu const { islandLeft, islandRight } = 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, Math.min(x - islandLeft, islandRight - x), width > 8 ? 0.7 + random() * 0.9 : 0) + plant(x, s, ground, width > 8 ? 0.7 + random() * 0.9 : 0) } return mesh } +/** 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) + if (found.length === 0) return [] + const reeds = found.map((islet) => Math.min(MOST_REEDS, Math.round(REEDS_PER_M2 * Math.PI * islet.radius ** 2))) + const humps = new THREE.InstancedMesh(hump, frost, found.length) + const stand = new THREE.InstancedMesh(reed, straw, reeds.reduce((a, b) => a + b)) + const at = new THREE.Object3D() + let n = 0 + for (const [i, islet] of found.entries()) { + const random = mulberry32(islet.seed) + at.position.set(islet.x - x0, 0, -(islet.s - s0)) + at.rotation.set(0, 0, 0) + at.scale.set(islet.radius, islet.radius, islet.radius) + at.updateMatrix() + humps.setMatrixAt(i, at.matrix) + for (let r = 0; r < reeds[i]!; r++) { + // Thickest in the middle; none on the last of the hump, where the ice begins. + const out = Math.sqrt(random()) * 0.85 + const round = random() * Math.PI * 2 + at.position.set( + islet.x - x0 + Math.cos(round) * out * islet.radius, + // The hump is part of a sphere, squashed: this is how high it is that far out. + 0.14 * islet.radius * Math.sqrt(1 - out * out) - 0.05, + -(islet.s - s0) + Math.sin(round) * out * islet.radius, + ) + // Each leaning a little its own way. + at.rotation.set((random() - 0.5) * 0.35, random() * Math.PI, (random() - 0.5) * 0.35) + const tall = 0.9 + random() * 1.1 + at.scale.set(1, tall, 1) + at.updateMatrix() + stand.setMatrixAt(n++, at.matrix) + } + } + return [humps, stand] +} + /** - * The land of the lake: both banks, and the island wherever it has forked. It is made + * The land of the lake: both banks, the island wherever it has forked, and the islets. It is made * ahead of the skater and thrown away behind, and each stretch is placed relative to the * skater every frame, so the skater is always at the origin of what is drawn and the lake * can run on without losing precision. @@ -136,6 +218,8 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu export class Shores { readonly group = new THREE.Group() private readonly chunks = new Map<number, Chunk>() + /** Whether nothing of this lake has been made yet. */ + private starting = true constructor(private lake: Lake, private seed: number) {} @@ -145,6 +229,7 @@ export class Shores { this.seed = seed for (const chunk of this.chunks.values()) this.drop(chunk) this.chunks.clear() + this.starting = true } private drop(chunk: Chunk): void { @@ -158,24 +243,34 @@ export class Shores { /** * Make, drop and place the stretches for a skater at `x` across and `s` along, who is - * in `channel`. + * in `channel`. `facing` is 1 for a skater looking down the lake, -1 for one looking + * back up it, and between for one looking across. * * A stretch is made once, with whichever channel the skater was in at the time, and * that is safe: a stretch near a fork has both of the fork's channels in it whichever * of them the skater takes, for `FORK_ZONE` metres, and the skater has taken one long * before stretches further off than that are made. */ - update(x: number, s: number, channel: Channel): void { - const first = Math.floor((s - BEHIND) / CHUNK) - const last = Math.floor((s + AHEAD) / CHUNK) + update(x: number, s: number, channel: Channel, facing = 1): void { + const first = Math.floor((s - (facing > 0.5 ? BEHIND : AHEAD)) / CHUNK) + const last = Math.floor((s + (facing < -0.5 ? BEHIND : AHEAD)) / CHUNK) for (const [index, chunk] of this.chunks) { if (index >= first && index <= last) continue this.drop(chunk) this.chunks.delete(index) } - for (let index = first; index <= last; index++) { - if (!this.chunks.has(index)) this.chunks.set(index, this.make(index, channel)) + // Nearest first, and only so many in a frame; the rest are for the frames after. + const here = Math.floor(s / CHUNK) + let made = 0 + for (let away = 0; made < MADE_AT_ONCE && (here - away >= first || here + away <= last); away++) { + for (const index of away === 0 ? [here] : [here + away, here - away]) { + if (index < first || index > last || this.chunks.has(index) || made >= MADE_AT_ONCE) continue + this.chunks.set(index, this.make(index, channel)) + // The very first frame makes everything: there is nothing yet to look at meanwhile. + if (this.chunks.size > 1 && !this.starting) made++ + } } + this.starting = false for (const chunk of this.chunks.values()) { chunk.group.position.set(chunk.x - x, 0, -(chunk.s - s)) } @@ -199,13 +294,10 @@ export class Shores { }), snow)) group.add(new THREE.Mesh(land(ACROSS.length, x0, s0, (s, col) => { const { islandLeft, islandRight } = shoreline(channel, s) - const width = islandRight - islandLeft const t = ACROSS[col]! - // An island rises from both its shores, so its height is that of land as far - // inland as its nearer shore. - return [islandLeft + t * width, lake.bankHeight(1, s, Math.min(t, 1 - t) * width)] + return [islandLeft + t * (islandRight - islandLeft), islandHeight(lake, channel, s, t)] }), snow)) - group.add(trees(lake, channel, this.seed, index, x0, s0)) + group.add(trees(lake, channel, this.seed, index, x0, s0), ...islets(channel, x0, s0)) this.group.add(group) return { x: x0, s: s0, group } } diff --git a/src/skater.ts b/src/skater.ts index 76cee11..b881eef 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -34,11 +34,16 @@ export const MAX_TURN = 0.45 /** Seconds for the turn to follow the steering: the weight of a body leaning over. */ const TURN_LAG = 0.3 /** - * The furthest off the line of the channel the skater may point, radians: 50 degrees. - * Nothing in the lake needs this, which is why it is a rule of its own: lift it and the - * skater can turn right round, as the rest of this model already allows. + * The furthest towards a shore the skater points when they are at the edge of the room + * to do it in, radians: 50 degrees. In open water there is no such limit. A skater held + * hard over turns right round, and can circle an islet, as the skater in the film does. */ export const MAX_HEADING = 0.87 +/** How close the skater may come to the edge of an islet, metres. */ +export const ISLET_MARGIN = 1.5 +/** How far ahead an islet in the way is steered round, metres, and by how much it is cleared. */ +const ISLET_AHEAD = 30 +const ISLET_BERTH = 3.5 /** The autopilot never lets the skater closer to a shore than this, metres. */ export const SHORE_MARGIN = 2 @@ -66,6 +71,8 @@ export const RETURN_TIME = 3 const LOOK_AHEAD = 18 /** The most the autopilot's line strays from the middle of the channel, metres. */ 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 /** How long stride number `index` lasts, seconds: the same every time it is asked. */ export function strideSeconds(index: number): number { @@ -94,6 +101,11 @@ export interface Skater { hold: number /** Seconds since the skater was last steered. */ idle: number + /** + * Which way the skater last steered while coming up to a fork, -1 or 1, and 0 if they + * have not: the way they have asked to go at it. + */ + wish: 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. */ @@ -105,7 +117,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, stride: 0, under: 0, + hold: 0, idle: Infinity, wish: 0, stride: 0, under: 0, } } @@ -125,14 +137,15 @@ function alongside(k: Skater): Channel | undefined { } /** - * How far from the middle of `channel` a skater at `s` may be, metres. It looks ahead: - * where the lake narrows faster than a skater can be steered clear, the room starts - * closing early, by no more than half a metre for each metre forward. + * How far from the middle of `channel` a skater at `s` may be, metres. It looks both ways + * along the lake, since a skater may be going either: where the lake narrows faster than + * a skater can be steered clear, the room starts closing early, by no more than half a + * metre for each metre towards the narrows. */ function reach(channel: Channel, s: number): number { let room = MAX_REACH - for (let ahead = 0; ahead <= 200; ahead += 20) { - room = Math.min(room, channel.halfWidth(s + ahead) - SHORE_MARGIN + 0.5 * ahead) + for (let off = -200; off <= 200; off += 20) { + room = Math.min(room, channel.halfWidth(s + off) - SHORE_MARGIN + 0.5 * Math.abs(off)) } return room } @@ -154,6 +167,16 @@ function room(k: Skater, s: number): [number, number] { } const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v)) +/** + * How far towards an edge of their room the skater may point when it is `room` metres + * off, radians: not at all at the edge itself, `MAX_HEADING` a little way from it, and + * straight at it from three times that far, which is as good as no limit. + */ +const mayPoint = (room: number): number => + clamp(room / EDGE, 0, 1) * MAX_HEADING + clamp((room - EDGE) / (2 * EDGE), 0, 1) * (Math.PI / 2 - MAX_HEADING) + +/** An angle as the same angle between a half turn left and a half turn right. */ +const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI)) /** * Move the skater on by `dt` seconds. `steer` is -1 (hard left) to 1 (hard right). @@ -168,11 +191,18 @@ const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.m * After being steered it first keeps to where it was put, then eases back to that line. * * Either way the skater is kept on the ice. Near the edge of their room they are turned - * to run along it, and at the edge itself they are held there. + * to run along it, and at the edge itself they are held there. An islet in the way is + * steered round: early and gently by the autopilot, and at the last moment for a skater + * who is steering at it. + * + * 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. * - * At a fork the skater is put in the channel the fork prefers, and until the island - * between the two begins they are in whichever channel's middle they are nearer. So - * doing nothing takes the preferred way, and steering across takes the other. + * 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 + * begins the skater can still change, by steering the other way or by simply being + * nearer the middle of the other. */ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rules = RULES): void { if (k.under > 0) { @@ -195,8 +225,12 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul if (Math.abs(steer) > 0.05) { k.idle = 0 wanted = clamp(steer, -1, 1) * MAX_TURN + // Coming up to a fork, or between its two channels, this is asking for a side of it. + const side = alongside(k) + if (k.s > channel.end.s - FORK_ASK) k.wish = Math.sign(steer) + else if (side && (steer < 0 ? channel.birth!.left : channel.birth!.right) === side) k.channel = side // Where this is taking the skater is where the autopilot will keep them afterwards. - k.hold = k.x + Math.sin(k.heading - along) * k.speed * 1 - channel.centre(k.s) + k.hold = k.x + Math.sin(k.heading - along) * k.speed - k.channel.centre(k.s) } else { k.idle += dt const settled = clamp((k.idle - IDLE_BEFORE_RETURN) / 5, 0, 1) @@ -210,18 +244,45 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul // 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) - wanted = clamp((Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN) + wanted = clamp(wrap(Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN) } + + // An islet in the way. The turn that would just clear it is the one that moves the + // skater sideways by what they are short of, in the distance there is to do it in. + // The autopilot makes that turn as soon as it is one; a skater who is steering is left + // to it until the turn needed is a sharp one. + const ahead = [Math.sin(k.heading), Math.cos(k.heading)] as const + let sharpest = Math.abs(steer) > 0.05 ? 0.6 * MAX_TURN : 0 + for (const islet of channel.islets(k.s - ISLET_AHEAD - 10, k.s + ISLET_AHEAD + 10)) { + const dx = islet.x - k.x + const ds = islet.s - k.s + const along = dx * ahead[0] + ds * ahead[1] + // How far to the right of the skater's line its middle is. + const aside = dx * ahead[1] - ds * ahead[0] + const short = islet.radius + ISLET_BERTH - Math.abs(aside) + if (along < 0 || along > ISLET_AHEAD + islet.radius || short <= 0) continue + const needed = Math.min(2 * MAX_TURN, (2.6 * short * k.speed) / Math.max(along - islet.radius, 2) ** 2) + if (needed > sharpest) { + sharpest = needed + // Round whichever side of it the skater is already on. + wanted = aside >= 0 ? Math.min(wanted, -needed) : Math.max(wanted, needed) + } + } + // Near the edge of the ice the skater may point less and less towards it, down to - // not at all at the edge itself, and is turned back if they point further than that. - const out = k.heading - along - const mayRight = clamp((right - k.x) / EDGE, 0, 1) * MAX_HEADING - const mayLeft = clamp((k.x - left) / EDGE, 0, 1) * MAX_HEADING - if (out > mayRight) wanted = Math.min(wanted, clamp((mayRight - out) / 0.2, -2 * MAX_TURN, 0)) - if (out < -mayLeft) wanted = Math.max(wanted, clamp((-mayLeft - out) / 0.2, 0, 2 * MAX_TURN)) + // not at all at the edge itself (`mayPoint`), and is turned away if they point further: + // whichever way round is the shorter, which for a skater heading back up the lake is + // the other way. + const out = wrap(k.heading - along) + const towards = Math.sin(out) + const away = Math.cos(out) >= 0 ? 1 : -1 + const mayRight = Math.sin(mayPoint(right - k.x)) + const mayLeft = Math.sin(mayPoint(k.x - left)) + if (towards > mayRight) wanted = -away * clamp((towards - mayRight) / 0.2, Math.max(0, -away * wanted), 2 * MAX_TURN) + if (towards < -mayLeft) wanted = away * clamp((-mayLeft - towards) / 0.2, Math.max(0, away * wanted), 2 * MAX_TURN) k.turn += (wanted - k.turn) * (1 - Math.exp(-dt / TURN_LAG)) - k.heading = clamp(k.heading + k.turn * dt, along - MAX_HEADING, along + MAX_HEADING) + k.heading += k.turn * dt const pushing = sincePush(k) < PUSH_SECONDS k.speed += ((pushing ? PUSH : 0) - DRAG * (k.speed / SPEED) ** 2) * dt @@ -231,7 +292,11 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul k.s += k.speed * Math.cos(k.heading) * dt k.x += k.speed * Math.sin(k.heading) * dt k.stride += dt / strideSeconds(Math.floor(k.stride)) - if (k.s >= k.channel.end.s) k.channel = k.channel.end.preferred + if (k.s >= k.channel.end.s) { + const fork = k.channel.end + k.channel = k.wish < 0 ? fork.left : k.wish > 0 ? fork.right : fork.preferred + k.wish = 0 + } // At the island's tip the matter is settled by which side of it the skater is on. const beside = alongside(k) ?? was const nearerBy = beside ? Math.abs(k.x - k.channel.centre(k.s)) - Math.abs(k.x - beside.centre(k.s)) : 0 @@ -244,9 +309,19 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul if (k.x < edgeLeft || k.x > edgeRight) { const over = k.x < edgeLeft k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -EDGE_PULL * dt, EDGE_PULL * dt) - const now = Math.atan(k.channel.slope(k.s)) - const most = 2 * MAX_TURN * dt - k.heading += over ? clamp(now - k.heading, 0, most) : clamp(now - k.heading, -most, 0) + // Turned along it, the nearer way round, as fast as a skater can be turned. + const off = wrap(k.heading - Math.atan(k.channel.slope(k.s))) + const pointing = Math.sin(off) * (over ? -1 : 1) + if (pointing > 0) k.heading += (over ? 1 : -1) * (Math.cos(off) >= 0 ? 1 : -1) * Math.min(2 * MAX_TURN * dt, Math.abs(off)) + } + // And off the islets: a skater who has got inside the margin of one is moved out of it. + for (const islet of k.channel.islets(k.s - 20, k.s + 20)) { + const far = Math.hypot(k.x - islet.x, k.s - islet.s) + const inside = islet.radius + ISLET_MARGIN - far + if (inside <= 0 || far === 0) continue + const push = Math.min(inside, EDGE_PULL * dt) / far + k.x += (k.x - islet.x) * push + k.s += (k.s - islet.s) * push } if (rules.falling && lake.thickness(k.x, k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS diff --git a/src/world/lake.ts b/src/world/lake.ts index 0d90431..f815f36 100644 --- a/src/world/lake.ts +++ b/src/world/lake.ts @@ -41,12 +41,31 @@ export interface Channel { slope(s: number): number /** From the middle to either shore, metres. */ halfWidth(s: number): number + /** + * The islets standing in the ice between `from` and `to` metres along, in the order + * they are come to. + */ + islets(from: number, to: number): Islet[] /** The fork that made this channel; the lake's first channel has none. */ readonly birth?: Fork /** The fork this channel ends in. */ readonly end: Fork } +/** + * A tuft of reeds standing in the ice, on a hump of frozen ground: something to skate + * round. In the film this is modelled on they stand in clusters, and the skater weaves + * between them. + */ +export interface Islet { + /** Where its middle is, across the lake and along it, and how far its edge is from that, metres. */ + x: number + s: number + radius: number + /** A number of its own, for whatever is drawn on it. */ + seed: number +} + /** Where one channel becomes two. */ export interface Fork { /** Where the channels part, and, further on, where the island between them begins. */ @@ -102,6 +121,17 @@ const CALM_FADE = 250 */ export const FORK_ZONE = 1200 const CALM_AFTER = FORK_ZONE +/** + * Islets come in beds: stretches of the lake some hundreds of metres long that have + * them, with long stretches between that have none. Within a bed there is room for one + * every `ISLET_EVERY` metres, and most of those have one. An islet is `ISLET_RADIUS` + * across its middle, keeps `ISLET_CLEAR` metres of ice between itself and a shore, and + * is not found where the water is narrower than `ISLET_WATER` to a side, nor near a fork. + */ +const ISLET_EVERY = 50 +const ISLET_RADIUS = [2.5, 7.5] as const +const ISLET_CLEAR = 9 +const ISLET_WATER = 16 /** 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 @@ -177,11 +207,34 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce: } const centre = (s: number): number => wander(s) + mix(parentShift, shift, away(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 + 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) + // Anywhere across the water that leaves ice to skate on both sides of it; out at + // sea, within sight of the middle. + const span = Math.min(width, 110) - radius - ISLET_CLEAR + return { x: centre(s) + (2 * hash(lane, 4000 + index) - 1) * span, s, radius, seed: (lane ^ Math.imul(index, 0x85ebca6b)) >>> 0 } + } let end: Fork | undefined 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)), + 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)) : [] + for (let index = Math.floor(Math.max(from, born) / ISLET_EVERY); index * ISLET_EVERY < to; index++) { + const here = islet(index) + if (here && here.s >= from && here.s < to) found.push(here) + } + return found + }, birth, get end() { return (end ??= fork(self, lane, shift, ends)) diff --git a/test/lake.test.ts b/test/lake.test.ts index 28d4c88..f99f70d 100644 --- a/test/lake.test.ts +++ b/test/lake.test.ts @@ -180,6 +180,37 @@ describe('lake', () => { expect(changing.thickness(...found!)).toBe(BREAKING_THICKNESS) }) + it('has islets in beds, with ice to skate on all round each, and none near a fork', () => { + let count = 0 + let stretches = 0 + let withIslets = 0 + follow(300_000, 500, (channel, s) => { + const found = channel.islets(s, s + 500) + stretches++ + if (found.length) withIslets++ + count += found.length + for (const islet of found) { + expect(islet.s).toBeGreaterThanOrEqual(s) + expect(islet.s).toBeLessThan(s + 500) + expect(islet.radius).toBeGreaterThanOrEqual(2.5) + expect(islet.radius).toBeLessThanOrEqual(7.5) + // Nine metres or more of ice between it and either shore. + const room = channel.halfWidth(islet.s) - Math.abs(islet.x - channel.centre(islet.s)) - islet.radius + expect(room).toBeGreaterThanOrEqual(9 - 1e-9) + // Not where a fork's two channels are both in sight, nor coming up to a fork. + if (channel.birth) expect(islet.s).toBeGreaterThan(channel.birth.s + FORK_ZONE) + expect(islet.s).toBeLessThan(channel.end.s - 250) + } + // The same islets however they are asked for. + const again = [...channel.islets(s, s + 200), ...channel.islets(s + 200, s + 500)] + expect(again.map((i) => i.s)).toEqual(found.map((i) => i.s)) + }) + console.info(`${count} islets in 300 km, in ${((100 * withIslets) / stretches).toFixed(0)}% of its half-kilometres`) + // In beds: many of them, and most of the lake without any. + expect(count).toBeGreaterThan(200) + expect(withIslets / stretches).toBeLessThan(0.4) + }) + 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 8a7cbef..254fb0e 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { BREAKING_THICKNESS } from '../src/ice/strength.js' import { - IDLE_BEFORE_RETURN, MAX_HEADING, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, + IDLE_BEFORE_RETURN, ISLET_MARGIN, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, type Skater, createSkater, stepSkater, strideSeconds, } from '../src/skater.js' -import { type Lake, createLake } from '../src/world/lake.js' +import { type Lake, createLake, mulberry32 } from '../src/world/lake.js' const DT = 1 / 60 const lake = createLake(1) @@ -17,8 +17,27 @@ function run(k: Skater, steer: number, seconds: number, on = straight): void { } /** How far the skater is from the middle of their channel, metres; positive is right. */ const off = (k: Skater): number => k.x - k.channel.centre(k.s) -/** How far off the line of their channel they point, radians. */ -const out = (k: Skater): number => k.heading - Math.atan(k.channel.slope(k.s)) +/** How far off the line of their channel they point, radians, between a half turn either way. */ +const out = (k: Skater): number => { + const angle = k.heading - Math.atan(k.channel.slope(k.s)) + return angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI)) +} +/** 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)) +/** 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) + let until = 0 + let steer = 0 + return (t) => { + if (t >= until) { + until = t + 0.5 + 4 * random() + steer = random() < 0.3 ? 0 : random() * 2 - 1 + } + return steer + } +} /** Whether there is nothing but the skater's own channel to either side of them. */ const alone = (k: Skater): boolean => !k.channel.birth || k.s > k.channel.birth.tip // Channels are compared with `===`, never handed to `expect`: a channel leads to its fork @@ -41,6 +60,8 @@ 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. + expect(clearOfIslets(k)).toBeGreaterThan(ISLET_MARGIN) if (k.channel !== channel) { // Doing nothing takes the way the fork prefers. expect(k.channel === channel.end.preferred).toBe(true) @@ -61,18 +82,17 @@ describe('skater', () => { expect(fastest - slowest).toBeGreaterThan(1) }) - it('moves smoothly: no jumps, no jerks, and never turned further than it may be', () => { - for (const steer of [0, 1, -1]) { + it('moves smoothly, however it is steered: no jumps, no jerks', () => { + for (const seed of [1, 2, 3]) { const k = createSkater(lake) - for (let t = 0; t < 900; t += DT) { - const { x, heading, speed } = k - // Steered hard one way, with a rest every so often. - stepSkater(k, lake, Math.floor(t / 7) % 3 === 0 ? 0 : steer, DT) - expect(Math.abs(k.x - x)).toBeLessThan(0.25) + const steer = fidget(seed) + for (let t = 0; t < 600; t += DT) { + const { x, s, heading, speed } = k + stepSkater(k, lake, steer(t), DT) + expect(Math.hypot(k.x - x, k.s - s)).toBeLessThan(0.25) expect(Math.abs(k.heading - heading)).toBeLessThan(0.05) expect(Math.abs(k.speed - speed)).toBeLessThan(0.05) expect(Math.abs(k.turn)).toBeLessThanOrEqual(2 * MAX_TURN) - expect(Math.abs(out(k))).toBeLessThanOrEqual(MAX_HEADING + 0.02) } } }) @@ -91,23 +111,56 @@ describe('skater', () => { expect(out(k)).toBeLessThan(0) }) - it('cannot be steered into a shore, however wide or narrow the lake', () => { - for (const steer of [-1, 1]) { + it('cannot be steered into a shore or onto an islet, however wide or narrow the lake', () => { + for (const seed of [4, 5, 6]) { const k = createSkater(lake) - for (let t = 0; t < 1800; t += DT) { - stepSkater(k, straight, steer, DT) + const steer = fidget(seed) + let nearest = Infinity + for (let t = 0; t < 1200; t += DT) { + stepSkater(k, straight, steer(t), DT) if (alone(k)) { 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)) + // Never on one. (Steered straight at one they come inside its margin for a moment.) + expect(nearest).toBeGreaterThan(0.2) } + // And they did get in among the islets: this was not passed by keeping away. + expect(nearest).toBeLessThan(15) + } + }) + + it('held hard over, turns right round, and left alone turns back down the lake', () => { + // Out at sea, where there is the room to. + const k = createSkater(lake, 3400) + expect(k.channel.halfWidth(k.s)).toBeGreaterThan(100) + let furthestBack = 0 + const start = k.heading + for (let t = 0; t < (2 * Math.PI) / MAX_TURN + 3; t += DT) { + const before = k.s + stepSkater(k, straight, 1, DT) + furthestBack = Math.min(furthestBack, k.s - before) } + // A whole circle, part of it back up the lake. + expect(k.heading - start).toBeGreaterThan(2 * Math.PI * 0.9) + expect(furthestBack).toBeLessThan(-0.05) + // Let go part way round the next one, facing back the way they came. + for (let i = 0; i < 1200 && Math.cos(out(k)) > -0.9; i++) stepSkater(k, straight, 1, DT) + expect(Math.cos(out(k))).toBeLessThan(-0.85) + run(k, 0, 25) + expect(Math.abs(out(k))).toBeLessThan(0.1) + const then = k.s + run(k, 0, 5) + expect(k.s - then).toBeGreaterThan(SPEED * 5 * 0.8) }) it('takes the left of a fork when steered left and the right when steered right', () => { const fork = lake.start.end for (const [steer, wanted] of [[-1, fork.left], [1, fork.right]] as const) { const k = createSkater(lake, fork.s - 30) - while (k.s < fork.tip + 50) stepSkater(k, straight, steer, DT) + // A nudge that way and no more: held over, it would be a circle. + run(k, steer, 1.2) + while (k.s < fork.tip + 50) stepSkater(k, straight, 0, DT) expect(k.channel === wanted).toBe(true) // And from there on left alone, stays in it, and comes back to its middle. run(k, 0, 60) @@ -116,18 +169,19 @@ describe('skater', () => { } }) - it('can change its mind at a fork until the island begins, and not after', () => { + it('can change its mind at a fork until the island begins, and then runs along its shore', () => { const fork = lake.start.end const other = fork.preferred === fork.left ? fork.right : fork.left const towards = other === fork.right ? 1 : -1 const k = createSkater(lake, fork.s - 1) run(k, 0, 1) expect(k.channel === fork.preferred).toBe(true) - while (k.channel !== other) stepSkater(k, straight, towards, DT) - expect(k.s).toBeLessThan(fork.tip) - // Past the island's tip, steering back runs along its shore instead. - while (k.s < fork.tip + 5) stepSkater(k, straight, towards, DT) - run(k, -towards, 30) + run(k, towards, 1.2) + while (k.s < fork.tip + 5) stepSkater(k, straight, 0, DT) + expect(k.channel === other).toBe(true) + // Past the island's tip, a nudge back towards it does not cross it. + run(k, -towards, 1.5) + run(k, 0, 20) expect(k.channel === other).toBe(true) })