nordic-skating / commit
fix(motion): steering changes the autopilot's plan instead of interrupting it
index.html | 2 +- src/skater.ts | 93 ++++++++++++++++++++++++++++++++++++------------- test/skater.test.ts | 99 +++++++++++++++++++++++++++++++++++++---------------- 3 files changed, 140 insertions(+), 54 deletions(-) diff --git a/index.html b/index.html index 70cee1d..6c77cd4 100644 --- a/index.html +++ b/index.html @@ -33,7 +33,7 @@ <h2>Controls</h2> <dl> <dt>Steer</dt> - <dd>Drag left or right from wherever you touch, or use ← → or A D; or, on a phone, press Tilt at the bottom left and lean it. 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> + <dd>Drag left or right from wherever you touch, or use ← → or A D; or, on a phone, press Tilt at the bottom left and lean it. Let go and the skater carries on the way you sent it, curving gently on from there; it does not go back to where it was. 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>A touch towards it on the way up to the fork is enough. Do nothing and the skater picks one.</dd> <dt>Islets</dt> diff --git a/src/skater.ts b/src/skater.ts index 0869b08..379bc4b 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -7,8 +7,10 @@ import { type Channel, type Lake, channelAt, mulberry32 } from './world/lake.js' * * Before this is turned back on, the autopilot needs a line it can be sure of. It carves * across the channel now, and nothing keeps thin ice off its path. + * + * And whether the autopilot carves at all, or skates a straight line where it is put. */ -export const RULES = { falling: false } +export const RULES = { falling: false, carve: true } /** The speed the skater settles around, m/s: about 25 km/h, an easy pace on good ice. */ export const SPEED = 7 @@ -63,14 +65,18 @@ const EDGE_PULL = 6 */ const CHANGE_BY = 3 -/** Seconds without steering before the autopilot starts easing back to its own line. */ -export const IDLE_BEFORE_RETURN = 1.5 -/** Time constant of that easing, seconds. */ -export const RETURN_TIME = 3 /** How far ahead of itself the autopilot aims, metres: about two and a half seconds. */ const LOOK_AHEAD = 18 -/** The most the autopilot's line strays from the middle of the channel, metres. */ +/** + * The autopilot's line swings from side to side of where it is centred: by `CARVE` metres + * each way when it has been left to itself for a while, over about `CARVE_LENGTH` metres + * of lake for one whole swing, and by as much as `MOST_SWING` just after a skater has + * sent it off at an angle. It settles from the one to the other over `SETTLE` seconds. + */ const CARVE = 6 +const CARVE_LENGTH = 190 +const MOST_SWING = 14 +const SETTLE = 20 /** From this far before a fork, metres, steering one way is asking to go that way at it. */ const FORK_ASK = 200 /** And from this far before an island, likewise; nearer than this the side is settled. */ @@ -98,10 +104,19 @@ export interface Skater { /** m/s along the direction of travel. */ speed: number /** - * Where across the channel the autopilot is to keep the skater, metres from its middle: - * where they were last steered to, easing back to 0 once they are left alone. + * The autopilot's plan: a line that swings from side to side as it goes down the lake. + * `hold` is where across the channel the line is centred, metres from the channel's + * middle; `swing` how far it goes to either side of that; and `phase` where in a swing + * it now is, radians, 0 being on the centre and heading right. + * + * The plan is the skater's as much as the autopilot's. Steering rewrites it: the line + * is re-centred on where the skater has got to, and its swing begun again in the + * direction they are going. So the autopilot carries on from what the skater did, + * and does not take them back to what it was doing before. */ hold: number + swing: number + phase: number /** Seconds since the skater was last steered. */ idle: number /** @@ -125,7 +140,7 @@ export function createSkater(lake: Lake, s = 0): Skater { const channel = channelAt(lake, s) return { x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED, - hold: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0, + hold: 0, swing: 0, phase: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0, } } @@ -200,9 +215,11 @@ const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / * that results is an arc. Nothing moves the skater sideways. * * Left alone, the autopilot does the steering, by the same means and within the same - * limits. It aims at a point `LOOK_AHEAD` metres on, on a line that wanders gently - * across the channel (`Lake.carve`), which makes long S-curves of the way down the lake. - * After being steered it first keeps to where it was put, then eases back to that line. + * limits. It aims at a point `LOOK_AHEAD` metres on, on a line that swings gently from + * side to side, which makes long S-curves of the way down the lake. The line is a plan, + * and steering changes the plan (`Skater.hold`): let go, and the autopilot goes on the + * way the skater was going, from where they had got to, and curves gently on from there. + * It does not go back to where it was, or to the middle of the lake. * * 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. An islet in the way is @@ -216,8 +233,9 @@ const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / * already well over to, or else the side the island prefers; alongside it, they are on * the side they are on. * - * At a fork, a skater who has steered one way on the way up to it goes that way, and one - * who has not goes the way the fork prefers. A touch is enough: the steering is taken as + * At a fork, a skater who has steered one way on the way up to it goes that way. One who + * has not goes the way they are already over towards, if they are well to one side, and + * otherwise 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. @@ -247,6 +265,9 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul const [left, right] = room(k, k.s) // What turn is wanted: the steering's, or the autopilot's. + // How fast the line swings, radians for each metre of lake: not quite regularly, so + // that no two swings are the same length. + const rate = (2 * Math.PI) / (CARVE_LENGTH * (1 + 0.3 * lake.carve(k.s))) let wanted: number if (Math.abs(steer) > 0.05) { k.idle = 0 @@ -255,26 +276,48 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul 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 - k.channel.centre(k.s) + // And this rewrites the autopilot's plan. Its line is centred on where the skater + // now is, and is at the middle of a swing in the direction they are going, as wide a + // swing as makes the line leave here at their angle. Let go, and the autopilot is + // already on its line and already pointing along it. + const off = wrap(k.heading - along) + const angle = Math.tan(clamp(off, -1.2, 1.2)) + k.hold = k.x - k.channel.centre(k.s) + // (A line that does not swing cannot leave here at an angle: it is put, instead, + // where the skater's angle will have taken them by the time they have straightened.) + if (!rules.carve) k.hold += Math.sin(off) * k.speed + k.swing = rules.carve ? Math.min(MOST_SWING, Math.abs(angle) / rate) : 0 + k.phase = angle >= 0 ? 0 : Math.PI } else { k.idle += dt - const settled = clamp((k.idle - IDLE_BEFORE_RETURN) / 5, 0, 1) - if (k.idle > IDLE_BEFORE_RETURN) k.hold *= Math.exp(-dt / RETURN_TIME) const ahead = k.s + LOOK_AHEAD - // No carving through a fork, from a little before it until the island has begun: the - // way taken there is the fork's to prefer or the skater's to choose, not the line's. + // On along the line, by as much of the lake as has gone by. + k.phase += rate * k.speed * Math.cos(k.heading - along) * dt + // The swing settles to its usual size, and to nothing through a fork, from a little + // before it until the island has begun: the way taken there is the fork's to prefer + // or the skater's to choose, not the line's. const open = k.s < channel.end.s - 150 && !(channel.birth && k.s < channel.birth.tip + 50) - const carve = open ? settled * Math.min(CARVE, 0.4 * reach(channel, ahead)) * lake.carve(ahead) : 0 + const usual = rules.carve && open ? Math.min(CARVE, 0.4 * reach(channel, ahead)) : 0 + k.swing += (usual - k.swing) * (1 - Math.exp(-dt / (open ? SETTLE : 3))) const [farLeft, farRight] = room(k, ahead) // Aimed clear of the edges, or down the middle where there is not the room for that. const clear = Math.min(EDGE, (farRight - farLeft) / 2) - // Past an island, down the middle of the water on its side of it. - const line = channel.island(ahead) > 0 ? (farLeft + farRight) / 2 + carve / 2 : channel.centre(ahead) + k.hold + carve - const aim = clamp(line, farLeft + clear, farRight - clear) + const swung = k.swing * Math.sin(k.phase + rate * LOOK_AHEAD) + // Past an island the line is down the middle of the water on its side of it, and + // stays where that leaves it afterwards. + if (channel.island(ahead) > 0) k.hold = (farLeft + farRight) / 2 - channel.centre(ahead) + // The line's centre keeps inside the room there is for it: a lake that narrows moves + // it in, and one that widens again leaves it where it was moved to. + const most = Math.max(0, reach(channel, ahead) - clear) + k.hold = clamp(k.hold, -most, most) + const aim = clamp(channel.centre(ahead) + k.hold + swung, farLeft + clear, farRight - clear) wanted = clamp(wrap(Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN) } + // Which channel the line is now measured from, should the skater be in another by the + // end of this step. + const measured = k.channel + // 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 @@ -329,6 +372,8 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul 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 if (beside && nearerBy > (alongside(k) ? CHANGE_BY : 0)) k.channel = beside + // The line is where it was on the ice, though it is now measured from another middle. + if (k.channel !== measured) k.hold += measured.centre(k.s) - k.channel.centre(k.s) // Kept on the ice, whatever else. A skater past the edge of their room is brought back // to it and turned along it, firmly but not at a jump: there are two metres between diff --git a/test/skater.test.ts b/test/skater.test.ts index 5d2e22f..36a1303 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from 'vitest' import { BREAKING_THICKNESS } from '../src/ice/strength.js' import { - IDLE_BEFORE_RETURN, ISLET_MARGIN, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, - type Skater, createSkater, stepSkater, strideSeconds, + ISLET_MARGIN, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, + RULES, type Skater, createSkater, stepSkater, strideSeconds, } from '../src/skater.js' import { type Lake, createLake, mulberry32 } from '../src/world/lake.js' const DT = 1 / 60 const lake = createLake(1) -/** The same lake with an autopilot that keeps to the middle, for trying what is not about its line. */ -const straight: Lake = { ...lake, carve: () => 0 } +/** An autopilot that skates a straight line where it is put, for trying what is not about its carving. */ +const STRAIGHT = { falling: false, carve: false } -function run(k: Skater, steer: number, seconds: number, on = straight): void { +function run(k: Skater, steer: number, seconds: number, rules = STRAIGHT, on = lake): void { const steps = Math.round(seconds / DT) - for (let i = 0; i < steps; i++) stepSkater(k, on, steer, DT) + for (let i = 0; i < steps; i++) stepSkater(k, on, steer, DT, rules) } /** 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) @@ -83,9 +83,11 @@ describe('skater', () => { expect(clearOfIsland(k)).toBeGreaterThan(SHORE_MARGIN / 2) if (k.channel.island(k.s) > 0) islands.add(Math.sign(off(k))) if (k.channel !== channel) { - // Doing nothing takes the way the fork prefers. - expect(k.channel === channel.end.preferred).toBe(true) - forks++ + // One or other way at every fork, whichever the skater was over towards; or, just + // past one, across to its other channel while there is still open ice between. + const taken = k.channel === channel.end.left || k.channel === channel.end.right + expect(taken || k.channel.birth === channel.birth).toBe(true) + if (taken) forks++ } slowest = Math.min(slowest, k.speed) fastest = Math.max(fastest, k.speed) @@ -139,7 +141,7 @@ describe('skater', () => { const steer = fidget(seed) let nearest = Infinity for (let t = 0; t < 1200; t += DT) { - stepSkater(k, straight, steer(t), DT) + stepSkater(k, lake, steer(t), DT, STRAIGHT) if (alone(k)) { expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.5) } @@ -161,14 +163,14 @@ describe('skater', () => { 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) + stepSkater(k, lake, 1, DT, STRAIGHT) 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) + for (let i = 0; i < 1200 && Math.cos(out(k)) > -0.9; i++) stepSkater(k, lake, 1, DT, STRAIGHT) expect(Math.cos(out(k))).toBeLessThan(-0.85) run(k, 0, 25) expect(Math.abs(out(k))).toBeLessThan(0.1) @@ -177,18 +179,24 @@ describe('skater', () => { expect(k.s - then).toBeGreaterThan(SPEED * 5 * 0.8) }) + it('down the middle and left alone, takes the way a fork prefers', () => { + const fork = lake.start.end + const k = createSkater(lake, fork.s - 100) + while (k.s < fork.tip + 50) stepSkater(k, lake, 0, DT, STRAIGHT) + expect(k.channel === fork.preferred).toBe(true) + }) + 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) // 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) + while (k.s < fork.tip + 50) stepSkater(k, lake, 0, DT, STRAIGHT) expect(k.channel === wanted).toBe(true) - // And from there on left alone, stays in it, and comes back to its middle. + // And from there on left alone, stays in it. run(k, 0, 60) expect(k.channel === wanted).toBe(true) - expect(Math.abs(off(k))).toBeLessThan(0.5) } }) @@ -200,7 +208,7 @@ describe('skater', () => { run(k, 0, 1) expect(k.channel === fork.preferred).toBe(true) run(k, towards, 1.2) - while (k.s < fork.tip + 5) stepSkater(k, straight, 0, DT) + while (k.s < fork.tip + 5) stepSkater(k, lake, 0, DT, STRAIGHT) 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) @@ -216,20 +224,53 @@ describe('skater', () => { while (channelAtIsland(start).island(start) > 0) start -= 5 const k = createSkater(lake, start - 100) run(k, steer, 0.6) - while (k.s < middle) stepSkater(k, straight, 0, DT) + while (k.s < middle) stepSkater(k, lake, 0, DT, STRAIGHT) expect(Math.sign(off(k))).toBe(steer) expect(Math.abs(off(k))).toBeGreaterThan(k.channel.island(k.s) + SHORE_MARGIN) } }) - it('keeps to where it was steered for a moment, then eases back to the middle', () => { - const k = createSkater(lake) + it('left alone after being steered, goes on the way it was going, and does not go back', () => { + // Out at sea, where there is the room. The autopilot has been carving for a minute. + const k = createSkater(lake, 3300) + run(k, 0, 60, RULES) + const before = off(k) + // Steered right for a couple of seconds, and let go. + run(k, 1, 2, RULES) + const letGo = off(k) + expect(out(k)).toBeGreaterThan(0.3) + expect(letGo).toBeGreaterThan(before + 2) + // It does not fight: half a second on it is still heading right, and it never turns + // back harder than a gentle carve does. + let hardest = 0 + for (let i = 0; i < Math.round(4 / DT); i++) { + stepSkater(k, lake, 0, DT, RULES) + if (i === Math.round(0.5 / DT)) expect(out(k)).toBeGreaterThan(0.2) + hardest = Math.min(hardest, k.turn) + } + expect(hardest).toBeGreaterThan(-0.32) + // Four seconds on it has gone further right still. + expect(off(k)).toBeGreaterThan(letGo + 4) + // And for the next two minutes it carves about where it was sent, not where it had been. + let sum = 0 + let count = 0 + for (let t = 0; t < 120; t += DT) { + stepSkater(k, lake, 0, DT, RULES) + sum += off(k) + count++ + } + expect(sum / count).toBeGreaterThan(letGo - 3) + expect(sum / count - before).toBeGreaterThan(5) + }) + + it('with carving off, stays on the line it was steered to', () => { + const k = createSkater(lake, 3300) run(k, 1, 1.5) - run(k, 0, IDLE_BEFORE_RETURN) - // Straightened up, and still well out to the side it was steered to. - expect(off(k)).toBeGreaterThan(2) - run(k, 0, 40) - expect(Math.abs(off(k))).toBeLessThan(0.3) + run(k, 0, 15) + const settled = off(k) + expect(settled).toBeGreaterThan(3) + run(k, 0, 30) + expect(off(k)).toBeCloseTo(settled, 0) expect(Math.abs(out(k))).toBeLessThan(0.02) }) @@ -266,20 +307,20 @@ describe('skater', () => { it('does not go through the ice, however thin, unless the rules say it can', () => { // A lake that would give way anywhere 5 m to the right of the middle. - const thin: Lake = { ...straight, thickness: (x, s) => (x - lake.start.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } + const thin: Lake = { ...lake, thickness: (x, s) => (x - lake.start.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } const safe = createSkater(lake) - run(safe, 1, 20, thin) + run(safe, 1, 20, STRAIGHT, thin) expect(off(safe)).toBeGreaterThan(5) expect(safe.under).toBe(0) const k = createSkater(lake) - while (k.under === 0) stepSkater(k, thin, 1, DT, { falling: true }) + while (k.under === 0) stepSkater(k, thin, 1, DT, { falling: true, carve: false }) expect(off(k)).toBeGreaterThan(5) const where = k.s - for (let i = 0; i < Math.round(UNDER_SECONDS / DT) - 2; i++) stepSkater(k, thin, 1, DT, { falling: true }) + for (let i = 0; i < Math.round(UNDER_SECONDS / DT) - 2; i++) stepSkater(k, thin, 1, DT, { falling: true, carve: false }) expect(k.under).toBeGreaterThan(0) expect(k.s).toBe(where) - for (let i = 0; i < 4; i++) stepSkater(k, thin, 0, DT, { falling: true }) + for (let i = 0; i < 4; i++) stepSkater(k, thin, 0, DT, { falling: true, carve: false }) expect(k.under).toBe(0) expect(off(k)).toBeCloseTo(0, 1) expect(k.s).toBeGreaterThan(where)