nordic-skating / local-littlejs / files / src / skater.ts
Branch local-littlejs · default branch · all branches
skater.ts
| 1 | import { BREAKING_THICKNESS } from './ice/strength.js' |
| 2 | import { type Channel, type Lake, channelAt, mulberry32 } from './world/lake.js' |
| 3 | |
| 4 | /** |
| 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. |
| 10 | * |
| 11 | * And whether the autopilot carves at all, or skates a straight line where it is put. |
| 12 | */ |
| 13 | export const RULES = { falling: false, carve: true } |
| 14 | |
| 15 | /** The speed the skater settles around at an easy pace, m/s: about 25 km/h. */ |
| 16 | export const SPEED = 7 |
| 17 | |
| 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 | */ |
| 27 | export 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 |
| 34 | export type Pace = keyof typeof PACES |
| 35 | /** Slowest first. */ |
| 36 | export const PACE_NAMES = Object.keys(PACES) as Pace[] |
| 37 | /** |
| 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 | */ |
| 44 | export const PUSH_SECONDS = 0.5 |
| 45 | /** How long the push of a stride lasts at a pace, seconds: the same share of a shorter or longer stride. */ |
| 46 | export const pushSeconds = (pace: Pace): number => PUSH_SECONDS * PACES[pace].strides |
| 47 | const DRAG = 0.42 |
| 48 | /** How much harder than ice a skate is dragged at in the middle of a snow drift, less one. */ |
| 49 | const SNOW_DRAG = 3 |
| 50 | /** The shortest and longest a stride lasts, seconds, and so the most it speeds and slows. */ |
| 51 | export const MIN_STRIDE = 1.4 |
| 52 | export const MAX_STRIDE = 3.2 |
| 53 | const 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 | */ |
| 59 | export const MAX_TURN = 0.45 |
| 60 | /** |
| 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 | */ |
| 65 | const MAX_SIDEWAYS = 5 |
| 66 | /** Seconds for the turn to follow the steering: the weight of a body leaning over. */ |
| 67 | const TURN_LAG = 0.3 |
| 68 | /** |
| 69 | * 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. |
| 72 | */ |
| 73 | export const MAX_HEADING = 0.87 |
| 74 | /** How close the skater may come to the edge of an islet, metres. */ |
| 75 | export const ISLET_MARGIN = 1.5 |
| 76 | /** 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. */ |
| 77 | const ISLET_SECONDS = 4.3 |
| 78 | const ISLET_BERTH = 3.5 |
| 79 | |
| 80 | /** The autopilot never lets the skater closer to a shore than this, metres. */ |
| 81 | export const SHORE_MARGIN = 2 |
| 82 | /** |
| 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 | */ |
| 86 | export const MAX_REACH = 150 |
| 87 | /** Within this of the edge of their room, metres, the skater is turned to run along it. */ |
| 88 | const EDGE = 8 |
| 89 | /** How fast a skater who has got past that edge is brought back to it, m/s. */ |
| 90 | const EDGE_PULL = 8 |
| 91 | /** |
| 92 | * 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. |
| 95 | */ |
| 96 | const CHANGE_BY = 3 |
| 97 | |
| 98 | /** How far ahead of itself the autopilot aims, in seconds of skating, and never less than 12 m. */ |
| 99 | const LOOK_SECONDS = 2.6 |
| 100 | /** |
| 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 | */ |
| 106 | const CARVE = 6 |
| 107 | const CARVE_LENGTH = 190 |
| 108 | const MOST_SWING = 14 |
| 109 | const SETTLE = 20 |
| 110 | /** From this far before a fork, metres, steering one way is asking to go that way at it. */ |
| 111 | const FORK_ASK = 200 |
| 112 | /** And from this far before an island, likewise; nearer than this the side is settled. */ |
| 113 | const ISLAND_ASK = 120 |
| 114 | const ISLAND_NEAR = 40 |
| 115 | |
| 116 | /** How long stride number `index` lasts at a pace, seconds: the same every time it is asked. */ |
| 117 | export function strideSeconds(index: number, pace: Pace = 'cruise'): number { |
| 118 | return (MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()) * PACES[pace].strides |
| 119 | } |
| 120 | |
| 121 | /** Seconds between going through the ice and skating on from the middle of the channel. */ |
| 122 | export const UNDER_SECONDS = 4 |
| 123 | |
| 124 | export interface Skater { |
| 125 | /** Where the skater is: metres across the lake, and metres along it. */ |
| 126 | x: number |
| 127 | s: number |
| 128 | /** The channel of the lake the skater is in. */ |
| 129 | channel: Channel |
| 130 | /** Direction of travel, radians: 0 is straight down the lake, positive is to the right. */ |
| 131 | heading: number |
| 132 | /** How fast that direction is changing, radians a second. */ |
| 133 | turn: number |
| 134 | /** m/s along the direction of travel. */ |
| 135 | speed: number |
| 136 | /** The pace they are keeping, or are working up or easing down to. */ |
| 137 | pace: Pace |
| 138 | /** |
| 139 | * 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. |
| 148 | */ |
| 149 | hold: number |
| 150 | swing: number |
| 151 | phase: number |
| 152 | /** Seconds since the skater was last steered. */ |
| 153 | idle: number |
| 154 | /** |
| 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 |
| 159 | /** |
| 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 |
| 164 | /** Strides taken, with the fraction being how far through the current one. */ |
| 165 | stride: number |
| 166 | /** Seconds left in the water after falling through the ice; 0 while skating. */ |
| 167 | under: number |
| 168 | } |
| 169 | |
| 170 | /** A skater `s` metres down the lake, having let the autopilot choose at every fork. */ |
| 171 | export function createSkater(lake: Lake, s = 0): Skater { |
| 172 | const channel = channelAt(lake, s) |
| 173 | return { |
| 174 | x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED, pace: 'cruise', |
| 175 | hold: 0, swing: 0, phase: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0, |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | /** Seconds since the push that began the skater's current stride. */ |
| 180 | export function sincePush(k: Skater): number { |
| 181 | return (k.stride % 1) * strideSeconds(Math.floor(k.stride), k.pace) |
| 182 | } |
| 183 | |
| 184 | /** |
| 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 | */ |
| 188 | function 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 |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * 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. |
| 199 | */ |
| 200 | function reach(channel: Channel, s: number): number { |
| 201 | let room = MAX_REACH |
| 202 | for (let off = -200; off <= 200; off += 20) { |
| 203 | room = Math.min(room, channel.halfWidth(s + off) - SHORE_MARGIN + 0.5 * Math.abs(off)) |
| 204 | } |
| 205 | return room |
| 206 | } |
| 207 | |
| 208 | /** The skater's room at `s`, as the furthest left and right they may be across the lake. */ |
| 209 | function 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 |
| 214 | // 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 | } |
| 220 | // 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 | |
| 230 | const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v)) |
| 231 | /** |
| 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 | */ |
| 236 | const 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) |
| 238 | |
| 239 | /** An angle as the same angle between a half turn left and a half turn right. */ |
| 240 | const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI)) |
| 241 | |
| 242 | /** |
| 243 | * Move the skater on by `dt` seconds. `steer` is -1 (hard left) to 1 (hard right). |
| 244 | * |
| 245 | * 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 |
| 250 | * 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. |
| 255 | * |
| 256 | * Either way the skater is kept on the ice. Near the edge of their room they are turned |
| 257 | * 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. |
| 263 | * |
| 264 | * 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 | * |
| 268 | * 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 |
| 271 | * 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. |
| 274 | */ |
| 275 | export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rules = RULES): void { |
| 276 | if (k.under > 0) { |
| 277 | k.under = Math.max(0, k.under - dt) |
| 278 | k.turn = 0 |
| 279 | if (k.under === 0) { |
| 280 | k.x = k.channel.centre(k.s) |
| 281 | k.heading = Math.atan(k.channel.slope(k.s)) |
| 282 | k.hold = 0 |
| 283 | k.idle = Infinity |
| 284 | } |
| 285 | return |
| 286 | } |
| 287 | const { channel } = k |
| 288 | const along = Math.atan(channel.slope(k.s)) |
| 289 | // 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 |
| 299 | // 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 |
| 303 | else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + islandNear) > 0) { |
| 304 | if (Math.abs(steer) > 0.05) k.side = Math.sign(steer) |
| 305 | 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) |
| 306 | } else k.side = 0 |
| 307 | const [left, right] = room(k, k.s) |
| 308 | |
| 309 | // What turn is wanted: the steering's, or the autopilot's. |
| 310 | // How fast the line swings, radians for each metre of lake: not quite regularly, so |
| 311 | // that no two swings are the same length. |
| 312 | // 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))) |
| 314 | let wanted: number |
| 315 | if (Math.abs(steer) > 0.05) { |
| 316 | k.idle = 0 |
| 317 | wanted = clamp(steer, -1, 1) * maxTurn |
| 318 | // 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 |
| 322 | // 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 |
| 334 | } else { |
| 335 | k.idle += dt |
| 336 | const ahead = k.s + lookAhead |
| 337 | // 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. |
| 342 | const open = k.s < channel.end.s - 150 && !(channel.birth && k.s < channel.birth.tip + 50) |
| 343 | 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))) |
| 345 | const [farLeft, farRight] = room(k, ahead) |
| 346 | // Aimed clear of the edges, or down the middle where there is not the room for that. |
| 347 | const clear = Math.min(edge, (farRight - farLeft) / 2) |
| 348 | const swung = k.swing * Math.sin(k.phase + rate * lookAhead) |
| 349 | // 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) |
| 357 | wanted = clamp(wrap(Math.atan2(aim - k.x, lookAhead) - k.heading) / 0.8, -maxTurn, maxTurn) |
| 358 | } |
| 359 | |
| 360 | // 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 | |
| 364 | // 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 |
| 369 | 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)) { |
| 371 | 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) |
| 377 | 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) |
| 379 | 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 | |
| 386 | // Near the edge of the ice the skater may point less and less towards it, down to |
| 387 | // 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 |
| 393 | 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) |
| 397 | |
| 398 | k.turn += (wanted - k.turn) * (1 - Math.exp(-dt / TURN_LAG)) |
| 399 | k.heading += k.turn * dt |
| 400 | |
| 401 | const pushing = sincePush(k) < pushSeconds(k.pace) |
| 402 | // 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)) |
| 405 | // 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 |
| 410 | |
| 411 | // Which channel the skater is in can change; where they are on the ice does not. |
| 412 | const was = alongside(k) |
| 413 | k.s += k.speed * Math.cos(k.heading) * dt |
| 414 | k.x += k.speed * Math.sin(k.heading) * dt |
| 415 | k.stride += dt / strideSeconds(Math.floor(k.stride), k.pace) |
| 416 | 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 | } |
| 421 | // 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 |
| 425 | // 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) |
| 427 | |
| 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 |
| 434 | k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -edgePull * dt, edgePull * dt) |
| 435 | // 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) |
| 438 | if (pointing > 0) k.heading += (over ? 1 : -1) * (Math.cos(off) >= 0 ? 1 : -1) * Math.min(2 * maxTurn * dt, Math.abs(off)) |
| 439 | } |
| 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 |
| 445 | const push = Math.min(inside, edgePull * dt) / far |
| 446 | k.x += (k.x - islet.x) * push |
| 447 | k.s += (k.s - islet.s) * push |
| 448 | } |
| 449 | |
| 450 | if (rules.falling && lake.thickness(k.x, k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS |
| 451 | } |