fuzzyworld

nordic-skating / mistral-medium-35-littlejs / files / src / skater.ts

Branch mistral-medium-35-littlejs · default branch · all branches

skater.ts

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

6216d5cb 2026-10-09 Pavel Safronov1import { BREAKING_THICKNESS } from './ice/strength.js'
1cb1fefc 2026-10-09 Pavel Safronov2import { type Channel, type Lake, channelAt, mulberry32 } from './world/lake.js'
8b9467ca 2026-10-09 Pavel Safronov3
a8fd3f13 2026-10-10 Pavel Safronov4/**
5 * Whether ice too thin to hold a skater gives way under one. Not for now: the lake is
6 * somewhere to skate, and how thick the ice is only decides how it sounds.
7 *
8 * Before this is turned back on, the autopilot needs a line it can be sure of. It carves
9 * across the channel now, and nothing keeps thin ice off its path.
c2870e67 2026-10-10 Pavel Safronov10 *
11 * And whether the autopilot carves at all, or skates a straight line where it is put.
a8fd3f13 2026-10-10 Pavel Safronov12 */
c2870e67 2026-10-10 Pavel Safronov13export const RULES = { falling: false, carve: true }
a8fd3f13 2026-10-10 Pavel Safronov14
1ee103b4 2026-10-11 Pavel Safronov15/** The speed the skater settles around at an easy pace, m/s: about 25 km/h. */
8b9467ca 2026-10-09 Pavel Safronov16export const SPEED = 7
1ee103b4 2026-10-11 Pavel Safronov17
18/**
19 * The paces a skater can keep. `speed` is the speed they settle around, m/s. `strides`
20 * is how long their strides are against a cruising skater's: an ambler takes long,
21 * lazy ones, and a racer short, quick ones, down in a crouch (`crouch`: how much lower
22 * their eyes are, metres).
23 *
24 * Olympic is what it says: 15 m/s is 54 km/h, the pace of a long-track speed skater in
25 * a 1500 m, kept up for ever by someone who is in no hurry to get anywhere.
26 */
27export const PACES = {
28 meander: { speed: 3.5, strides: 1.4, crouch: -0.1 },
29 cruise: { speed: SPEED, strides: 1, crouch: 0 },
30 brisk: { speed: 10, strides: 0.75, crouch: 0.1 },
31 racing: { speed: 12.5, strides: 0.6, crouch: 0.22 },
32 olympic: { speed: 15, strides: 0.45, crouch: 0.35 },
33} as const
34export type Pace = keyof typeof PACES
35/** Slowest first. */
36export const PACE_NAMES = Object.keys(PACES) as Pace[]
a8fd3f13 2026-10-10 Pavel Safronov37/**
38 * A stride is a push and then a glide. The push lasts `PUSH_SECONDS` and speeds the
39 * skater up by `PUSH` m/s²; all the while the ice and the air slow them, by `DRAG` m/s²
40 * at `SPEED` and as the square of the speed otherwise. The two are balanced so that a
41 * stride of average length ends at the speed it began with: long glides run slower,
42 * short ones faster, and the speed breathes with the stride.
43 */
44export const PUSH_SECONDS = 0.5
1ee103b4 2026-10-11 Pavel Safronov45/** How long the push of a stride lasts at a pace, seconds: the same share of a shorter or longer stride. */
46export const pushSeconds = (pace: Pace): number => PUSH_SECONDS * PACES[pace].strides
a8fd3f13 2026-10-10 Pavel Safronov47const DRAG = 0.42
8bb08e4a 2026-10-10 Pavel Safronov48/** How much harder than ice a skate is dragged at in the middle of a snow drift, less one. */
49const SNOW_DRAG = 3
a8fd3f13 2026-10-10 Pavel Safronov50/** The shortest and longest a stride lasts, seconds, and so the most it speeds and slows. */
51export const MIN_STRIDE = 1.4
52export const MAX_STRIDE = 3.2
53const PUSH = (DRAG * (MIN_STRIDE + MAX_STRIDE)) / 2 / PUSH_SECONDS
54
55/**
56 * The sharpest the skater turns, radians a second. At `SPEED` that is a circle 16 m in
57 * radius and a third of a g sideways: a firm carve on a long blade, not a hockey stop.
58 */
59export const MAX_TURN = 0.45
1ee103b4 2026-10-11 Pavel Safronov60/**
61 * And the hardest they are pressed sideways by a turn, m/s²: half a g. At an easy pace
62 * the limit above is reached first; a racer is held to this one, and turns in a wider
63 * circle for it, 45 m at Olympic pace.
64 */
65const MAX_SIDEWAYS = 5
a8fd3f13 2026-10-10 Pavel Safronov66/** Seconds for the turn to follow the steering: the weight of a body leaning over. */
67const TURN_LAG = 0.3
68/**
4f816cf0 2026-10-10 Pavel Safronov69 * The furthest towards a shore the skater points when they are at the edge of the room
70 * to do it in, radians: 50 degrees. In open water there is no such limit. A skater held
71 * hard over turns right round, and can circle an islet, as the skater in the film does.
a8fd3f13 2026-10-10 Pavel Safronov72 */
73export const MAX_HEADING = 0.87
4f816cf0 2026-10-10 Pavel Safronov74/** How close the skater may come to the edge of an islet, metres. */
75export const ISLET_MARGIN = 1.5
1ee103b4 2026-10-11 Pavel Safronov76/** How far ahead an islet in the way is steered round, in seconds of skating (and never less than 24 m), and by how much it is cleared, metres. */
77const ISLET_SECONDS = 4.3
4f816cf0 2026-10-10 Pavel Safronov78const ISLET_BERTH = 3.5
a8fd3f13 2026-10-10 Pavel Safronov79
8b9467ca 2026-10-09 Pavel Safronov80/** The autopilot never lets the skater closer to a shore than this, metres. */
81export const SHORE_MARGIN = 2
1cb1fefc 2026-10-09 Pavel Safronov82/**
83 * Nor further from the middle of the channel than this, metres, which only matters out
84 * at sea, where the shores are far out of sight.
85 */
86export const MAX_REACH = 150
a8fd3f13 2026-10-10 Pavel Safronov87/** Within this of the edge of their room, metres, the skater is turned to run along it. */
88const EDGE = 8
89/** How fast a skater who has got past that edge is brought back to it, m/s. */
1ee103b4 2026-10-11 Pavel Safronov90const EDGE_PULL = 8
1cb1fefc 2026-10-09 Pavel Safronov91/**
a8fd3f13 2026-10-10 Pavel Safronov92 * At a fork the skater changes channel once they are this much nearer the middle of the
93 * other, metres: a little past half way, so that being a hair to one side as the two
94 * part does not decide it.
1cb1fefc 2026-10-09 Pavel Safronov95 */
a8fd3f13 2026-10-10 Pavel Safronov96const CHANGE_BY = 3
97
1ee103b4 2026-10-11 Pavel Safronov98/** How far ahead of itself the autopilot aims, in seconds of skating, and never less than 12 m. */
99const LOOK_SECONDS = 2.6
c2870e67 2026-10-10 Pavel Safronov100/**
101 * The autopilot's line swings from side to side of where it is centred: by `CARVE` metres
102 * each way when it has been left to itself for a while, over about `CARVE_LENGTH` metres
103 * of lake for one whole swing, and by as much as `MOST_SWING` just after a skater has
104 * sent it off at an angle. It settles from the one to the other over `SETTLE` seconds.
105 */
a8fd3f13 2026-10-10 Pavel Safronov106const CARVE = 6
c2870e67 2026-10-10 Pavel Safronov107const CARVE_LENGTH = 190
108const MOST_SWING = 14
109const SETTLE = 20
4f816cf0 2026-10-10 Pavel Safronov110/** From this far before a fork, metres, steering one way is asking to go that way at it. */
111const FORK_ASK = 200
26ace8f5 2026-10-10 Pavel Safronov112/** And from this far before an island, likewise; nearer than this the side is settled. */
113const ISLAND_ASK = 120
114const ISLAND_NEAR = 40
9ff4c3fc 2026-10-09 Pavel Safronov115
1ee103b4 2026-10-11 Pavel Safronov116/** How long stride number `index` lasts at a pace, seconds: the same every time it is asked. */
117export function strideSeconds(index: number, pace: Pace = 'cruise'): number {
118 return (MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()) * PACES[pace].strides
9ff4c3fc 2026-10-09 Pavel Safronov119}
8b9467ca 2026-10-09 Pavel Safronov120
1cb1fefc 2026-10-09 Pavel Safronov121/** Seconds between going through the ice and skating on from the middle of the channel. */
6216d5cb 2026-10-09 Pavel Safronov122export const UNDER_SECONDS = 4
123
8b9467ca 2026-10-09 Pavel Safronov124export interface Skater {
a8fd3f13 2026-10-10 Pavel Safronov125 /** Where the skater is: metres across the lake, and metres along it. */
126 x: number
8b9467ca 2026-10-09 Pavel Safronov127 s: number
1cb1fefc 2026-10-09 Pavel Safronov128 /** The channel of the lake the skater is in. */
129 channel: Channel
a8fd3f13 2026-10-10 Pavel Safronov130 /** Direction of travel, radians: 0 is straight down the lake, positive is to the right. */
8b9467ca 2026-10-09 Pavel Safronov131 heading: number
a8fd3f13 2026-10-10 Pavel Safronov132 /** How fast that direction is changing, radians a second. */
133 turn: number
134 /** m/s along the direction of travel. */
135 speed: number
1ee103b4 2026-10-11 Pavel Safronov136 /** The pace they are keeping, or are working up or easing down to. */
137 pace: Pace
a8fd3f13 2026-10-10 Pavel Safronov138 /**
c2870e67 2026-10-10 Pavel Safronov139 * The autopilot's plan: a line that swings from side to side as it goes down the lake.
140 * `hold` is where across the channel the line is centred, metres from the channel's
141 * middle; `swing` how far it goes to either side of that; and `phase` where in a swing
142 * it now is, radians, 0 being on the centre and heading right.
143 *
144 * The plan is the skater's as much as the autopilot's. Steering rewrites it: the line
145 * is re-centred on where the skater has got to, and its swing begun again in the
146 * direction they are going. So the autopilot carries on from what the skater did,
147 * and does not take them back to what it was doing before.
a8fd3f13 2026-10-10 Pavel Safronov148 */
149 hold: number
c2870e67 2026-10-10 Pavel Safronov150 swing: number
151 phase: number
8b9467ca 2026-10-09 Pavel Safronov152 /** Seconds since the skater was last steered. */
153 idle: number
4f816cf0 2026-10-10 Pavel Safronov154 /**
155 * Which way the skater last steered while coming up to a fork, -1 or 1, and 0 if they
156 * have not: the way they have asked to go at it.
157 */
158 wish: number
26ace8f5 2026-10-10 Pavel Safronov159 /**
160 * Which side of an island the skater is passing, or is about to: -1 left, 1 right, and
161 * 0 when there is no island near.
162 */
163 side: number
8b9467ca 2026-10-09 Pavel Safronov164 /** Strides taken, with the fraction being how far through the current one. */
165 stride: number
6216d5cb 2026-10-09 Pavel Safronov166 /** Seconds left in the water after falling through the ice; 0 while skating. */
167 under: number
8b9467ca 2026-10-09 Pavel Safronov168}
169
1cb1fefc 2026-10-09 Pavel Safronov170/** A skater `s` metres down the lake, having let the autopilot choose at every fork. */
171export function createSkater(lake: Lake, s = 0): Skater {
a8fd3f13 2026-10-10 Pavel Safronov172 const channel = channelAt(lake, s)
173 return {
1ee103b4 2026-10-11 Pavel Safronov174 x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED, pace: 'cruise',
c2870e67 2026-10-10 Pavel Safronov175 hold: 0, swing: 0, phase: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0,
a8fd3f13 2026-10-10 Pavel Safronov176 }
1cb1fefc 2026-10-09 Pavel Safronov177}
178
a8fd3f13 2026-10-10 Pavel Safronov179/** Seconds since the push that began the skater's current stride. */
180export function sincePush(k: Skater): number {
1ee103b4 2026-10-11 Pavel Safronov181 return (k.stride % 1) * strideSeconds(Math.floor(k.stride), k.pace)
8b9467ca 2026-10-09 Pavel Safronov182}
183
1cb1fefc 2026-10-09 Pavel Safronov184/**
185 * The other channel of the fork the skater has just come through, for as long as there
186 * is open ice between the two: until the island begins.
187 */
188function alongside(k: Skater): Channel | undefined {
189 const fork = k.channel.birth
190 if (!fork || k.s < fork.s || k.s >= fork.tip) return undefined
191 return k.channel === fork.left ? fork.right : fork.left
8b9467ca 2026-10-09 Pavel Safronov192}
193
a8fd3f13 2026-10-10 Pavel Safronov194/**
4f816cf0 2026-10-10 Pavel Safronov195 * How far from the middle of `channel` a skater at `s` may be, metres. It looks both ways
196 * along the lake, since a skater may be going either: where the lake narrows faster than
197 * a skater can be steered clear, the room starts closing early, by no more than half a
198 * metre for each metre towards the narrows.
a8fd3f13 2026-10-10 Pavel Safronov199 */
200function reach(channel: Channel, s: number): number {
201 let room = MAX_REACH
4f816cf0 2026-10-10 Pavel Safronov202 for (let off = -200; off <= 200; off += 20) {
203 room = Math.min(room, channel.halfWidth(s + off) - SHORE_MARGIN + 0.5 * Math.abs(off))
a8fd3f13 2026-10-10 Pavel Safronov204 }
205 return room
206}
207
208/** The skater's room at `s`, as the furthest left and right they may be across the lake. */
209function room(k: Skater, s: number): [number, number] {
210 const middle = k.channel.centre(s)
211 const own = reach(k.channel, s)
212 let left = middle - own
213 let right = middle + own
26ace8f5 2026-10-10 Pavel Safronov214 // An island in the channel: the skater has the water on the side of it they are on.
215 const island = k.channel.island(s)
216 if (island > 0) {
217 if ((k.side || Math.sign(k.x - middle) || 1) > 0) left = Math.max(left, middle + island + SHORE_MARGIN)
218 else right = Math.min(right, middle - island - SHORE_MARGIN)
219 }
a8fd3f13 2026-10-10 Pavel Safronov220 // While the other channel of a fork is still alongside, its width is theirs as well.
221 const other = alongside(k)
222 if (other) {
223 const far = reach(other, s)
224 left = Math.min(left, other.centre(s) - far)
225 right = Math.max(right, other.centre(s) + far)
226 }
227 return [left, right]
228}
229
230const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v))
4f816cf0 2026-10-10 Pavel Safronov231/**
232 * How far towards an edge of their room the skater may point when it is `room` metres
233 * off, radians: not at all at the edge itself, `MAX_HEADING` a little way from it, and
234 * straight at it from three times that far, which is as good as no limit.
235 */
1ee103b4 2026-10-11 Pavel Safronov236const mayPoint = (room: number, edge: number): number =>
237 clamp(room / edge, 0, 1) * MAX_HEADING + clamp((room - edge) / (2 * edge), 0, 1) * (Math.PI / 2 - MAX_HEADING)
4f816cf0 2026-10-10 Pavel Safronov238
239/** An angle as the same angle between a half turn left and a half turn right. */
240const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI))
a8fd3f13 2026-10-10 Pavel Safronov241
8b9467ca 2026-10-09 Pavel Safronov242/**
243 * Move the skater on by `dt` seconds. `steer` is -1 (hard left) to 1 (hard right).
244 *
a8fd3f13 2026-10-10 Pavel Safronov245 * The skater has a place, a direction and a speed, and steering is turning: the stick
246 * sets how fast the direction changes, the body takes a moment to follow, and the path
247 * that results is an arc. Nothing moves the skater sideways.
248 *
249 * Left alone, the autopilot does the steering, by the same means and within the same
c2870e67 2026-10-10 Pavel Safronov250 * limits. It aims at a point `LOOK_AHEAD` metres on, on a line that swings gently from
251 * side to side, which makes long S-curves of the way down the lake. The line is a plan,
252 * and steering changes the plan (`Skater.hold`): let go, and the autopilot goes on the
253 * way the skater was going, from where they had got to, and curves gently on from there.
254 * It does not go back to where it was, or to the middle of the lake.
a8fd3f13 2026-10-10 Pavel Safronov255 *
256 * Either way the skater is kept on the ice. Near the edge of their room they are turned
4f816cf0 2026-10-10 Pavel Safronov257 * to run along it, and at the edge itself they are held there. An islet in the way is
258 * steered round: early and gently by the autopilot, and at the last moment for a skater
259 * who is steering at it.
260 *
261 * The skater may point any way at all, back up the lake included. Nothing here assumes
262 * otherwise; left alone, the autopilot turns them down the lake again.
6216d5cb 2026-10-09 Pavel Safronov263 *
26ace8f5 2026-10-10 Pavel Safronov264 * An island is passed on the side the skater steered towards on the way up to it, or is
265 * already well over to, or else the side the island prefers; alongside it, they are on
266 * the side they are on.
267 *
c2870e67 2026-10-10 Pavel Safronov268 * At a fork, a skater who has steered one way on the way up to it goes that way. One who
269 * has not goes the way they are already over towards, if they are well to one side, and
270 * otherwise the way the fork prefers. A touch is enough: the steering is taken as
4f816cf0 2026-10-10 Pavel Safronov271 * asking, and the autopilot does the rest. Until the island between the two channels
272 * begins the skater can still change, by steering the other way or by simply being
273 * nearer the middle of the other.
8b9467ca 2026-10-09 Pavel Safronov274 */
a8fd3f13 2026-10-10 Pavel Safronov275export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rules = RULES): void {
6216d5cb 2026-10-09 Pavel Safronov276 if (k.under > 0) {
277 k.under = Math.max(0, k.under - dt)
a8fd3f13 2026-10-10 Pavel Safronov278 k.turn = 0
6216d5cb 2026-10-09 Pavel Safronov279 if (k.under === 0) {
a8fd3f13 2026-10-10 Pavel Safronov280 k.x = k.channel.centre(k.s)
1cb1fefc 2026-10-09 Pavel Safronov281 k.heading = Math.atan(k.channel.slope(k.s))
a8fd3f13 2026-10-10 Pavel Safronov282 k.hold = 0
6216d5cb 2026-10-09 Pavel Safronov283 k.idle = Infinity
284 }
285 return
286 }
a8fd3f13 2026-10-10 Pavel Safronov287 const { channel } = k
288 const along = Math.atan(channel.slope(k.s))
1ee103b4 2026-10-11 Pavel Safronov289 // What the skater's speed makes of the limits and the distances: a faster skater
290 // turns less sharply, looks further ahead, and keeps further from the edges.
291 const pace = PACES[k.pace]
292 const maxTurn = Math.min(MAX_TURN, MAX_SIDEWAYS / Math.max(k.speed, 1))
293 const lookAhead = Math.max(12, LOOK_SECONDS * k.speed)
294 const isletAhead = Math.max(24, ISLET_SECONDS * k.speed)
295 // (The room it takes to turn away from an edge goes up faster than the speed does.)
296 const edge = clamp(EDGE * (k.speed / SPEED) ** 1.5, 6, 28)
297 const edgePull = EDGE_PULL * Math.max(1, k.speed / SPEED)
298 const islandNear = (ISLAND_NEAR * Math.max(k.speed, SPEED)) / SPEED
26ace8f5 2026-10-10 Pavel Safronov299 // Which side of an island: alongside one, the side the skater is on; coming up to
300 // one, the side they asked for or are over to, or the island's own choice.
301 const off = k.x - channel.centre(k.s)
302 if (channel.island(k.s) > 0) k.side = Math.sign(off) || k.side || 1
1ee103b4 2026-10-11 Pavel Safronov303 else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + islandNear) > 0) {
26ace8f5 2026-10-10 Pavel Safronov304 if (Math.abs(steer) > 0.05) k.side = Math.sign(steer)
1ee103b4 2026-10-11 Pavel Safronov305 else if (k.side === 0 && channel.island(k.s + islandNear) > 0) k.side = Math.abs(off) > 3 ? Math.sign(off) : channel.islandSide(k.s + islandNear)
26ace8f5 2026-10-10 Pavel Safronov306 } else k.side = 0
a8fd3f13 2026-10-10 Pavel Safronov307 const [left, right] = room(k, k.s)
308
309 // What turn is wanted: the steering's, or the autopilot's.
c2870e67 2026-10-10 Pavel Safronov310 // How fast the line swings, radians for each metre of lake: not quite regularly, so
311 // that no two swings are the same length.
1ee103b4 2026-10-11 Pavel Safronov312 // A faster skater's swings are longer, though not in proportion: more of them go by in a minute.
313 const rate = (2 * Math.PI) / (CARVE_LENGTH * (pace.speed / SPEED) ** 0.7 * (1 + 0.3 * lake.carve(k.s)))
a8fd3f13 2026-10-10 Pavel Safronov314 let wanted: number
8b9467ca 2026-10-09 Pavel Safronov315 if (Math.abs(steer) > 0.05) {
316 k.idle = 0
1ee103b4 2026-10-11 Pavel Safronov317 wanted = clamp(steer, -1, 1) * maxTurn
4f816cf0 2026-10-10 Pavel Safronov318 // Coming up to a fork, or between its two channels, this is asking for a side of it.
319 const side = alongside(k)
320 if (k.s > channel.end.s - FORK_ASK) k.wish = Math.sign(steer)
321 else if (side && (steer < 0 ? channel.birth!.left : channel.birth!.right) === side) k.channel = side
c2870e67 2026-10-10 Pavel Safronov322 // And this rewrites the autopilot's plan. Its line is centred on where the skater
323 // now is, and is at the middle of a swing in the direction they are going, as wide a
324 // swing as makes the line leave here at their angle. Let go, and the autopilot is
325 // already on its line and already pointing along it.
326 const off = wrap(k.heading - along)
327 const angle = Math.tan(clamp(off, -1.2, 1.2))
328 k.hold = k.x - k.channel.centre(k.s)
329 // (A line that does not swing cannot leave here at an angle: it is put, instead,
330 // where the skater's angle will have taken them by the time they have straightened.)
331 if (!rules.carve) k.hold += Math.sin(off) * k.speed
332 k.swing = rules.carve ? Math.min(MOST_SWING, Math.abs(angle) / rate) : 0
333 k.phase = angle >= 0 ? 0 : Math.PI
8b9467ca 2026-10-09 Pavel Safronov334 } else {
335 k.idle += dt
1ee103b4 2026-10-11 Pavel Safronov336 const ahead = k.s + lookAhead
c2870e67 2026-10-10 Pavel Safronov337 // On along the line, by as much of the lake as has gone by.
338 k.phase += rate * k.speed * Math.cos(k.heading - along) * dt
339 // The swing settles to its usual size, and to nothing through a fork, from a little
340 // before it until the island has begun: the way taken there is the fork's to prefer
341 // or the skater's to choose, not the line's.
a8fd3f13 2026-10-10 Pavel Safronov342 const open = k.s < channel.end.s - 150 && !(channel.birth && k.s < channel.birth.tip + 50)
c2870e67 2026-10-10 Pavel Safronov343 const usual = rules.carve && open ? Math.min(CARVE, 0.4 * reach(channel, ahead)) : 0
344 k.swing += (usual - k.swing) * (1 - Math.exp(-dt / (open ? SETTLE : 3)))
a8fd3f13 2026-10-10 Pavel Safronov345 const [farLeft, farRight] = room(k, ahead)
346 // Aimed clear of the edges, or down the middle where there is not the room for that.
1ee103b4 2026-10-11 Pavel Safronov347 const clear = Math.min(edge, (farRight - farLeft) / 2)
348 const swung = k.swing * Math.sin(k.phase + rate * lookAhead)
c2870e67 2026-10-10 Pavel Safronov349 // Past an island the line is down the middle of the water on its side of it, and
350 // stays where that leaves it afterwards.
351 if (channel.island(ahead) > 0) k.hold = (farLeft + farRight) / 2 - channel.centre(ahead)
352 // The line's centre keeps inside the room there is for it: a lake that narrows moves
353 // it in, and one that widens again leaves it where it was moved to.
354 const most = Math.max(0, reach(channel, ahead) - clear)
355 k.hold = clamp(k.hold, -most, most)
356 const aim = clamp(channel.centre(ahead) + k.hold + swung, farLeft + clear, farRight - clear)
1ee103b4 2026-10-11 Pavel Safronov357 wanted = clamp(wrap(Math.atan2(aim - k.x, lookAhead) - k.heading) / 0.8, -maxTurn, maxTurn)
1cb1fefc 2026-10-09 Pavel Safronov358 }
4f816cf0 2026-10-10 Pavel Safronov359
c2870e67 2026-10-10 Pavel Safronov360 // Which channel the line is now measured from, should the skater be in another by the
361 // end of this step.
362 const measured = k.channel
363
4f816cf0 2026-10-10 Pavel Safronov364 // An islet in the way. The turn that would just clear it is the one that moves the
365 // skater sideways by what they are short of, in the distance there is to do it in.
366 // The autopilot makes that turn as soon as it is one; a skater who is steering is left
367 // to it until the turn needed is a sharp one.
368 const ahead = [Math.sin(k.heading), Math.cos(k.heading)] as const
1ee103b4 2026-10-11 Pavel Safronov369 let sharpest = Math.abs(steer) > 0.05 ? 0.6 * maxTurn : 0
370 for (const islet of channel.islets(k.s - isletAhead - 10, k.s + isletAhead + 10)) {
4f816cf0 2026-10-10 Pavel Safronov371 const dx = islet.x - k.x
372 const ds = islet.s - k.s
373 const along = dx * ahead[0] + ds * ahead[1]
374 // How far to the right of the skater's line its middle is.
375 const aside = dx * ahead[1] - ds * ahead[0]
376 const short = islet.radius + ISLET_BERTH - Math.abs(aside)
1ee103b4 2026-10-11 Pavel Safronov377 if (along < 0 || along > isletAhead + islet.radius || short <= 0) continue
378 const needed = Math.min(2 * maxTurn, (2.6 * short * k.speed) / Math.max(along - islet.radius, 2) ** 2)
4f816cf0 2026-10-10 Pavel Safronov379 if (needed > sharpest) {
380 sharpest = needed
381 // Round whichever side of it the skater is already on.
382 wanted = aside >= 0 ? Math.min(wanted, -needed) : Math.max(wanted, needed)
383 }
384 }
385
a8fd3f13 2026-10-10 Pavel Safronov386 // Near the edge of the ice the skater may point less and less towards it, down to
4f816cf0 2026-10-10 Pavel Safronov387 // not at all at the edge itself (`mayPoint`), and is turned away if they point further:
388 // whichever way round is the shorter, which for a skater heading back up the lake is
389 // the other way.
390 const out = wrap(k.heading - along)
391 const towards = Math.sin(out)
392 const away = Math.cos(out) >= 0 ? 1 : -1
1ee103b4 2026-10-11 Pavel Safronov393 const mayRight = Math.sin(mayPoint(right - k.x, edge))
394 const mayLeft = Math.sin(mayPoint(k.x - left, edge))
395 if (towards > mayRight) wanted = -away * clamp((towards - mayRight) / 0.2, Math.max(0, -away * wanted), 2 * maxTurn)
396 if (towards < -mayLeft) wanted = away * clamp((-mayLeft - towards) / 0.2, Math.max(0, away * wanted), 2 * maxTurn)
a8fd3f13 2026-10-10 Pavel Safronov397
398 k.turn += (wanted - k.turn) * (1 - Math.exp(-dt / TURN_LAG))
4f816cf0 2026-10-10 Pavel Safronov399 k.heading += k.turn * dt
a8fd3f13 2026-10-10 Pavel Safronov400
1ee103b4 2026-10-11 Pavel Safronov401 const pushing = sincePush(k) < pushSeconds(k.pace)
8bb08e4a 2026-10-10 Pavel Safronov402 // Snow on the ice drags at the blade: in the middle of a drift, four times as hard as
403 // ice does, which brings a skater down to about half their speed.
404 const drag = DRAG * (1 + SNOW_DRAG * lake.snow(k.x, k.s))
1ee103b4 2026-10-11 Pavel Safronov405 // The push and the drag are balanced at the pace's own speed, whatever it is, and
406 // scaled so that the speed breathes by the same share of it at every pace. A skater
407 // who changes pace works up to the new one, or eases down to it, over a few strides.
408 const effort = pace.speed / SPEED / pace.strides
409 k.speed += effort * ((pushing ? PUSH : 0) - drag * (k.speed / pace.speed) ** 2) * dt
1cb1fefc 2026-10-09 Pavel Safronov410
411 // Which channel the skater is in can change; where they are on the ice does not.
a8fd3f13 2026-10-10 Pavel Safronov412 const was = alongside(k)
413 k.s += k.speed * Math.cos(k.heading) * dt
414 k.x += k.speed * Math.sin(k.heading) * dt
1ee103b4 2026-10-11 Pavel Safronov415 k.stride += dt / strideSeconds(Math.floor(k.stride), k.pace)
4f816cf0 2026-10-10 Pavel Safronov416 if (k.s >= k.channel.end.s) {
417 const fork = k.channel.end
418 k.channel = k.wish < 0 ? fork.left : k.wish > 0 ? fork.right : fork.preferred
419 k.wish = 0
420 }
a8fd3f13 2026-10-10 Pavel Safronov421 // At the island's tip the matter is settled by which side of it the skater is on.
422 const beside = alongside(k) ?? was
423 const nearerBy = beside ? Math.abs(k.x - k.channel.centre(k.s)) - Math.abs(k.x - beside.centre(k.s)) : 0
424 if (beside && nearerBy > (alongside(k) ? CHANGE_BY : 0)) k.channel = beside
c2870e67 2026-10-10 Pavel Safronov425 // The line is where it was on the ice, though it is now measured from another middle.
426 if (k.channel !== measured) k.hold += measured.centre(k.s) - k.channel.centre(k.s)
a8fd3f13 2026-10-10 Pavel Safronov427
428 // Kept on the ice, whatever else. A skater past the edge of their room is brought back
429 // to it and turned along it, firmly but not at a jump: there are two metres between
430 // that edge and the shore to do it in.
431 const [edgeLeft, edgeRight] = room(k, k.s)
432 if (k.x < edgeLeft || k.x > edgeRight) {
433 const over = k.x < edgeLeft
1ee103b4 2026-10-11 Pavel Safronov434 k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -edgePull * dt, edgePull * dt)
4f816cf0 2026-10-10 Pavel Safronov435 // Turned along it, the nearer way round, as fast as a skater can be turned.
436 const off = wrap(k.heading - Math.atan(k.channel.slope(k.s)))
437 const pointing = Math.sin(off) * (over ? -1 : 1)
1ee103b4 2026-10-11 Pavel Safronov438 if (pointing > 0) k.heading += (over ? 1 : -1) * (Math.cos(off) >= 0 ? 1 : -1) * Math.min(2 * maxTurn * dt, Math.abs(off))
4f816cf0 2026-10-10 Pavel Safronov439 }
440 // And off the islets: a skater who has got inside the margin of one is moved out of it.
441 for (const islet of k.channel.islets(k.s - 20, k.s + 20)) {
442 const far = Math.hypot(k.x - islet.x, k.s - islet.s)
443 const inside = islet.radius + ISLET_MARGIN - far
444 if (inside <= 0 || far === 0) continue
1ee103b4 2026-10-11 Pavel Safronov445 const push = Math.min(inside, edgePull * dt) / far
4f816cf0 2026-10-10 Pavel Safronov446 k.x += (k.x - islet.x) * push
447 k.s += (k.s - islet.s) * push
1cb1fefc 2026-10-09 Pavel Safronov448 }
449
a8fd3f13 2026-10-10 Pavel Safronov450 if (rules.falling && lake.thickness(k.x, k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS
8b9467ca 2026-10-09 Pavel Safronov451}