nordic-skating / commit
feat: five paces, from a meander to an Olympic sprint; and pictures on the buttons
assets/sound-off.png | Bin 0 -> 10565 bytes assets/sound-on.png | Bin 0 -> 11144 bytes assets/tilt-off.png | Bin 0 -> 5198 bytes assets/tilt-on.png | Bin 0 -> 17118 bytes index.html | 16 ++++++- src/main.ts | 99 ++++++++++++++++++++++++++++++++++++++----- src/settings.ts | 6 ++- src/skater.ts | 115 +++++++++++++++++++++++++++++++++++--------------- src/style.css | 37 ++++++++++++++++ test/settings.test.ts | 2 +- test/skater.test.ts | 72 ++++++++++++++++++++++++++++++- tools/icons.mjs | 3 +- vite.config.ts | 4 +- 13 files changed, 301 insertions(+), 53 deletions(-) diff --git a/assets/sound-off.png b/assets/sound-off.png new file mode 100644 index 0000000..4853d36 Binary files /dev/null and b/assets/sound-off.png differ diff --git a/assets/sound-on.png b/assets/sound-on.png new file mode 100644 index 0000000..7d887b3 Binary files /dev/null and b/assets/sound-on.png differ diff --git a/assets/tilt-off.png b/assets/tilt-off.png new file mode 100644 index 0000000..9e3057f Binary files /dev/null and b/assets/tilt-off.png differ diff --git a/assets/tilt-on.png b/assets/tilt-on.png new file mode 100644 index 0000000..51b95f2 Binary files /dev/null and b/assets/tilt-on.png differ diff --git a/index.html b/index.html index 214c149..9fe94ef 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,7 @@ <button id="randomize" type="button">Randomize</button> </nav> <button id="sound" type="button"></button> +<button id="pace-button" type="button"></button> <!-- Shown only on what can be tilted (main.ts). --> <button id="tilt-button" type="button" hidden></button> <dialog id="about-dialog" aria-labelledby="about-title"> @@ -38,10 +39,12 @@ <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>Go faster or slower</dt> + <dd>The button at the bottom right steps through five paces, from a meander to an Olympic sprint; or use ↑ ↓ or W S.</dd> <dt>Sound on or off</dt> <dd>The button at the top right, or M.</dd> <dt>With a game controller</dt> - <dd>Either stick or the direction buttons steer. Y or triangle goes to another lake; X or square turns the sound on and off.</dd> + <dd>Either stick or the direction buttons steer; up and down on the direction buttons, or the shoulder buttons, change pace. Y or triangle goes to another lake; X or square turns the sound on and off.</dd> <dt>Another lake</dt> <dd>Randomize, at the top left, or R. The browser’s Back button returns to the lake you left.</dd> <dt>Time of day, and more</dt> @@ -67,7 +70,7 @@ <p>Inspired by National Geographic’s film <a href="https://www.youtube.com/watch?v=v3O9vNi-dkA" target="_blank" rel="noopener">Hear the Otherworldly Sounds of Skating on Thin Ice</a>.</p> <p><a href="https://gitlab.com/PavelSafronov/nordic-skating/-/blob/main/README.md" target="_blank" rel="noopener">How it works, and credits</a> · <a href="https://gitlab.com/PavelSafronov/nordic-skating" target="_blank" rel="noopener">Source</a></p> <!-- The Flaticon free licence requires the credit wherever the artwork is used. --> - <p><small>App icon by <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Icongeek26</a>, from Flaticon.</small></p> + <p><small>Icons by <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Icongeek26</a>, from Flaticon.</small></p> <form method="dialog"><button id="about-close" class="done">Skate on</button></form> </dialog> @@ -83,6 +86,15 @@ <label><input type="radio" name="time" value="aurora" /> Aurora <small>the northern lights</small></label> </fieldset> + <fieldset> + <legend>Pace</legend> + <label><input type="radio" name="pace" value="meander" /> Meander <small>13 km/h, in long lazy strides</small></label> + <label><input type="radio" name="pace" value="cruise" /> Cruise <small>25 km/h</small></label> + <label><input type="radio" name="pace" value="brisk" /> Brisk <small>36 km/h</small></label> + <label><input type="radio" name="pace" value="racing" /> Racing <small>45 km/h</small></label> + <label><input type="radio" name="pace" value="olympic" /> Olympic <small>54 km/h, as if training for the Games</small></label> + </fieldset> + <fieldset> <legend>Weather</legend> <label><input type="radio" name="weather" value="surprise" /> Surprise me <small>each lake has its own</small></label> diff --git a/src/main.ts b/src/main.ts index ce11826..67387f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,9 @@ import './style.css' +import skateIcon from '../assets/skate-shoes.png' +import soundOffIcon from '../assets/sound-off.png' +import soundOnIcon from '../assets/sound-on.png' +import tiltOffIcon from '../assets/tilt-off.png' +import tiltOnIcon from '../assets/tilt-on.png' import * as THREE from 'three' import { IceSound } from './audio/iceSound.js' import { Input } from './input.js' @@ -10,7 +15,7 @@ import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js' import { Snow } from './render/snow.js' import { WEATHER, WEATHERS, type Weather, anyLake, loadSettings, saveSettings, timeOf, weatherOf, windOf } from './settings.js' import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js' -import { PUSH_SECONDS, UNDER_SECONDS, createSkater, sincePush, stepSkater } from './skater.js' +import { PACES, PACE_NAMES, type Pace, UNDER_SECONDS, createSkater, pushSeconds, sincePush, stepSkater } from './skater.js' import { createLake } from './world/lake.js' // Eye height of a skater leaning into a stride, in metres. @@ -104,6 +109,10 @@ const askedWeather = params.get('weather') as Weather | null function timeNow(): TimeName { return asked && TIME_NAMES.includes(asked) ? asked : settings.time === 'surprise' ? timeOf(seed) : settings.time } +// `?pace=olympic` asks for a pace whatever the settings say: taken up for this visit, +// and kept only if the skater goes on to change it. +const askedPace = params.get('pace') as Pace | null +if (askedPace && PACE_NAMES.includes(askedPace)) settings.pace = askedPace /** And the weather, likewise: `?weather=snow` asks for one whatever the settings say. */ function weatherNow(): Weather { if (askedWeather && WEATHERS.includes(askedWeather)) return askedWeather @@ -113,6 +122,8 @@ showWorld() /** How far below level the skater looks, radians. */ let pitch = 0 +/** The field of view at a cruising pace, degrees; it widens with speed. */ +let baseFov = 65 function resize(): void { const { clientWidth: w, clientHeight: h } = canvas @@ -120,7 +131,8 @@ function resize(): void { camera.aspect = w / h // The field of view is the vertical one. A phone held upright needs a wide one, or the // view across is so narrow that both shores fall outside it. - camera.fov = w < h ? 90 : 65 + baseFov = w < h ? 90 : 65 + camera.fov = baseFov // And held upright there is far more height than the lake needs: look further down, at // the ice coming, rather than fill the top half of the screen with sky. pitch = w < h ? 0.3 : 0.12 @@ -152,10 +164,23 @@ try { /** Whether the ice can be heard at this moment. */ const sounding = (): boolean => audio?.state === 'running' +/** + * A picture for a button, from line art that is black on nothing: only its shape is + * used, filled with whatever colour the button's words are, so that it is white on a + * dark button and dark on a white one (style.css). + */ +function icon(picture: string): HTMLSpanElement { + const span = document.createElement('span') + span.className = 'icon' + span.style.setProperty('--icon', `url(${picture})`) + return span +} +const soundIcon = soundButton.appendChild(icon(soundOffIcon)) + function showSound(): void { const on = audio !== null && !muted // The picture says which way it is set; the words are for whoever cannot see it. - soundButton.textContent = on ? '🔊' : '🔇' + soundIcon.style.setProperty('--icon', `url(${on ? soundOnIcon : soundOffIcon})`) soundButton.setAttribute('aria-label', on ? 'Sound on' : muted ? 'Sound off' : 'Tap for sound') soundButton.title = soundButton.getAttribute('aria-label')! soundButton.setAttribute('aria-pressed', String(on)) @@ -187,7 +212,7 @@ function setMuted(to: boolean): void { showSound() // A touch of the button must not also start the sound, since it has just been asked to // do the opposite; nor a touch of a link, which is someone on their way out. -const aside = (e: Event): boolean => e.target === soundButton || links.contains(e.target as Node) +const aside = (e: Event): boolean => soundButton.contains(e.target as Node) || links.contains(e.target as Node) // A box over the lake stops it while the box is open: the skater, and the sound with // them. The sky goes on, and the settings can be seen taking effect behind their box. @@ -210,9 +235,37 @@ for (const [button, box] of [[aboutButton, about], [settingsButton, settingsBox] // The settings box: each thing in it takes effect as it is touched, and is remembered. const impossible = settingsBox.querySelector<HTMLInputElement>('#impossible')! const tiltButton = document.querySelector<HTMLButtonElement>('#tilt-button')! +const paceButton = document.querySelector<HTMLButtonElement>('#pace-button')! +/** A pace's name as it is shown, and its speed as people say speeds. */ +const paceName = (pace: Pace): string => pace[0]!.toUpperCase() + pace.slice(1) +const paceSpeed = (pace: Pace): string => `${Math.round(PACES[pace].speed * 3.6)} km/h` +/** Keep a pace: the skater works up to it, or eases down, over the next few strides. */ +function setPace(pace: Pace): void { + settings.pace = skater.pace = pace + saveSettings(settings) + showSettings() + // Said where the hint is, for long enough to read. + hint.textContent = `${paceName(pace)} · ${paceSpeed(pace)}` + hint.classList.remove('gone') + clearTimeout(paceSaid) + paceSaid = window.setTimeout(() => hint.classList.add('gone'), 2500) +} +let paceSaid = 0 +/** One pace faster or slower; `round` goes from the fastest back to the slowest. */ +function stepPace(by: number, round = false): void { + const at = PACE_NAMES.indexOf(settings.pace) + by + const to = round ? (at + PACE_NAMES.length) % PACE_NAMES.length : Math.max(0, Math.min(PACE_NAMES.length - 1, at)) + if (PACE_NAMES[to] !== settings.pace) setPace(PACE_NAMES[to]!) +} +// The skate is the app's own icon, as on the browser's tab. +paceButton.append(icon(skateIcon)) +const paceLabel = paceButton.appendChild(document.createElement('span')) +paceButton.addEventListener('click', () => stepPace(1, true)) // Steering by tilt is offered only on what can be tilted, by a button of its own at the // foot of the screen. tiltButton.hidden = !Input.canTilt +const tiltIcon = tiltButton.appendChild(icon(tiltOffIcon)) +const tiltLabel = tiltButton.appendChild(document.createElement('span')) // A touch of the button is the touch an iPhone wants before it will give its leave. tiltButton.addEventListener('click', () => void setTilt(!settings.tilt)) /** Whether the page was put into full screen in order to hold the screen's rotation. */ @@ -271,17 +324,21 @@ async function setTilt(on: boolean): Promise<void> { if (settings.tilt && Input.canTilt) { // (Unless that touch is of the Tilt button, which is someone turning it off.) if (Input.tiltNeedsTouch) { - window.addEventListener('pointerdown', (e) => void (e.target !== tiltButton && setTilt(true)), { once: true }) + window.addEventListener('pointerdown', (e) => void (!tiltButton.contains(e.target as Node) && setTilt(true)), { once: true }) } else void setTilt(true) } const impossibleOnly = document.querySelectorAll<HTMLElement>('.impossible-only') function showSettings(): void { for (const radio of settingsBox.querySelectorAll<HTMLInputElement>('input[type=radio]')) { - radio.checked = radio.value === settings[radio.name as 'time' | 'weather'] + radio.checked = radio.value === settings[radio.name as 'time' | 'weather' | 'pace'] } impossible.checked = settings.impossibleIce - tiltButton.textContent = settings.tilt ? '📱 Tilt on' : '📱 Tilt off' + paceLabel.textContent = paceName(settings.pace) + paceButton.setAttribute('aria-label', `Pace: ${paceName(settings.pace)}, ${paceSpeed(settings.pace)}. Press for the next.`) + // A phone in a hand when tilting it steers, and a phone by itself when it does not. + tiltIcon.style.setProperty('--icon', `url(${settings.tilt ? tiltOnIcon : tiltOffIcon})`) + tiltLabel.textContent = settings.tilt ? 'Tilt on' : 'Tilt off' tiltButton.setAttribute('aria-pressed', String(settings.tilt)) tiltButton.classList.toggle('on', settings.tilt) hint.textContent = settings.tilt ? 'Tilt to steer — or just watch' : 'Drag left or right to steer — or just watch' @@ -291,6 +348,7 @@ function showSettings(): void { showSettings() settingsBox.addEventListener('change', (e) => { const changed = e.target as HTMLInputElement + if (changed.name === 'pace') return setPace(changed.value as Pace) if (changed.name === 'time' || changed.name === 'weather') { ;(settings[changed.name] as string) = changed.value showWorld() @@ -314,6 +372,7 @@ function enterLake(to: number): void { ice.show(lake) shores.show(lake, seed) skater = createSkater(lake) + skater.pace = settings.pace marks.clear(skater.x, skater.s) showWorld() showLake() @@ -350,6 +409,8 @@ window.addEventListener('pointerdown', (e) => { window.addEventListener('keydown', (e) => { if (e.code === 'KeyM') setMuted(audio !== null && !muted) else if (e.code === 'KeyR' && !waiting() && !e.metaKey && !e.ctrlKey) randomize() + else if ((e.code === 'ArrowUp' || e.code === 'KeyW') && !waiting()) stepPace(1) + else if ((e.code === 'ArrowDown' || e.code === 'KeyS') && !waiting() && !e.metaKey && !e.ctrlKey) stepPace(-1) else if (!aside(e)) startSound() }) // Frames stop while the page is hidden, but sound would not: the hum would drone on. @@ -366,6 +427,7 @@ keepAwake() document.addEventListener('visibilitychange', keepAwake) window.addEventListener('pointerdown', keepAwake, { once: true }) marks.clear(skater.x, skater.s) +skater.pace = settings.pace /** Seconds until the ice next cracks somewhere off across the lake. */ let untilCrack = 3 @@ -376,6 +438,9 @@ renderer.setAnimationLoop(() => { // and X or square turns the sound on and off. for (const button of input.pollPads()) { if (button === 3 && !waiting()) randomize() + // Up on the direction buttons or the right shoulder: faster. Down, or the left: slower. + else if (button === 12 || button === 5) stepPace(1) + else if (button === 13 || button === 4) stepPace(-1) else if (button === 2) setMuted(audio !== null && !muted) // Any button is a touch, as far as a browser's leave to make sound goes. else startSound() @@ -416,7 +481,10 @@ renderer.setAnimationLoop(() => { // In the water for all but the last second, which is the climb back out. water.classList.toggle('under', skater.under > 1) - if (sound && sounding()) sound.wind(blowing / WEATHER.blizzard.wind[1]) + // The wind in the skater's ears is the weather's and their own: at a racing pace the + // air rushes past on the stillest day. + const rush = 0.5 * Math.min(1.2, skater.speed / PACES.olympic.speed) ** 2.5 + if (sound && sounding()) sound.wind(Math.max(blowing / WEATHER.blizzard.wind[1], rush)) if (sound && sounding() && skater.under === 0) { // One strike of a skate at the start of each stride, no two quite alike. // Snow under the blade: the strike is muffled by as much as there is of it, and the @@ -444,10 +512,21 @@ renderer.setAnimationLoop(() => { // A skater's head is the stillest part of them: it does not swing from leg to leg. // It rises a little with each push, and leans into a turn as the body under it does, // by as much as the turn presses sideways. - const push = skater.under === 0 && sincePush(skater) < PUSH_SECONDS ? Math.sin((Math.PI * sincePush(skater)) / PUSH_SECONDS) : 0 + const pushFor = pushSeconds(skater.pace) + const push = skater.under === 0 && sincePush(skater) < pushFor ? Math.sin((Math.PI * sincePush(skater)) / pushFor) : 0 + // A racer is down in a crouch and an ambler stands tall; the eyes move between the + // two as the speed does. And the faster the skater, the wider the view, which is most + // of what makes speed look like speed. + const hurry = (skater.speed - PACES.cruise.speed) / (PACES.olympic.speed - PACES.cruise.speed) + const crouch = hurry > 0 ? PACES.olympic.crouch * hurry : PACES.meander.crouch * Math.min(1, -hurry * 2.3) + const fov = baseFov + 12 * Math.max(-0.4, Math.min(1.1, hurry)) + if (Math.abs(camera.fov - fov) > 0.05) { + camera.fov = fov + camera.updateProjectionMatrix() + } // Going through the ice drops the view to the water in the first third of a second. const sunk = skater.under > 0 ? Math.min(1, (UNDER_SECONDS - skater.under) / 0.3) : 0 - camera.position.y = EYE + 0.03 * push - 1.3 * sunk + camera.position.y = EYE - crouch + 0.03 * push - 1.3 * sunk // 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)) diff --git a/src/settings.ts b/src/settings.ts index 696d371..c01d8f0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,5 @@ import { TIME_NAMES, type TimeName } from './render/sky.js' +import { PACE_NAMES, type Pace } from './skater.js' import { mulberry32 } from './world/lake.js' /** The weathers a lake can have. */ @@ -22,13 +23,15 @@ export interface Settings { time: TimeName | 'surprise' /** The weather, or `surprise` for whichever the lake comes with. */ weather: Weather | 'surprise' + /** The pace the skater keeps (skater.ts). */ + pace: Pace /** Whether the lake has ice thinner than any ice could be (world/lake.ts). */ impossibleIce: boolean /** Whether tilting the phone steers (input.ts). */ tilt: boolean } -const DEFAULTS: Settings = { time: 'surprise', weather: 'surprise', impossibleIce: false, tilt: false } +const DEFAULTS: Settings = { time: 'surprise', weather: 'surprise', pace: 'cruise', impossibleIce: false, tilt: false } const KEY = 'settings' export function loadSettings(): Settings { @@ -37,6 +40,7 @@ export function loadSettings(): Settings { return { time: kept.time === 'surprise' || TIME_NAMES.includes(kept.time as TimeName) ? kept.time! : DEFAULTS.time, weather: kept.weather === 'surprise' || WEATHERS.includes(kept.weather as Weather) ? kept.weather! : DEFAULTS.weather, + pace: PACE_NAMES.includes(kept.pace as Pace) ? kept.pace! : DEFAULTS.pace, impossibleIce: kept.impossibleIce === true, tilt: kept.tilt === true, } diff --git a/src/skater.ts b/src/skater.ts index 3d6c333..8a3c2a7 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -12,8 +12,28 @@ import { type Channel, type Lake, channelAt, mulberry32 } from './world/lake.js' */ 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. */ +/** The speed the skater settles around at an easy pace, m/s: about 25 km/h. */ export const SPEED = 7 + +/** + * The paces a skater can keep. `speed` is the speed they settle around, m/s. `strides` + * is how long their strides are against a cruising skater's: an ambler takes long, + * lazy ones, and a racer short, quick ones, down in a crouch (`crouch`: how much lower + * their eyes are, metres). + * + * Olympic is what it says: 15 m/s is 54 km/h, the pace of a long-track speed skater in + * a 1500 m, kept up for ever by someone who is in no hurry to get anywhere. + */ +export const PACES = { + meander: { speed: 3.5, strides: 1.4, crouch: -0.1 }, + cruise: { speed: SPEED, strides: 1, crouch: 0 }, + brisk: { speed: 10, strides: 0.75, crouch: 0.1 }, + racing: { speed: 12.5, strides: 0.6, crouch: 0.22 }, + olympic: { speed: 15, strides: 0.45, crouch: 0.35 }, +} as const +export type Pace = keyof typeof PACES +/** Slowest first. */ +export const PACE_NAMES = Object.keys(PACES) as Pace[] /** * A stride is a push and then a glide. The push lasts `PUSH_SECONDS` and speeds the * skater up by `PUSH` m/s²; all the while the ice and the air slow them, by `DRAG` m/s² @@ -22,6 +42,8 @@ export const SPEED = 7 * short ones faster, and the speed breathes with the stride. */ export const PUSH_SECONDS = 0.5 +/** How long the push of a stride lasts at a pace, seconds: the same share of a shorter or longer stride. */ +export const pushSeconds = (pace: Pace): number => PUSH_SECONDS * PACES[pace].strides const DRAG = 0.42 /** How much harder than ice a skate is dragged at in the middle of a snow drift, less one. */ const SNOW_DRAG = 3 @@ -35,6 +57,12 @@ const PUSH = (DRAG * (MIN_STRIDE + MAX_STRIDE)) / 2 / PUSH_SECONDS * radius and a third of a g sideways: a firm carve on a long blade, not a hockey stop. */ export const MAX_TURN = 0.45 +/** + * And the hardest they are pressed sideways by a turn, m/s²: half a g. At an easy pace + * the limit above is reached first; a racer is held to this one, and turns in a wider + * circle for it, 45 m at Olympic pace. + */ +const MAX_SIDEWAYS = 5 /** Seconds for the turn to follow the steering: the weight of a body leaning over. */ const TURN_LAG = 0.3 /** @@ -45,8 +73,8 @@ const TURN_LAG = 0.3 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 +/** 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. */ +const ISLET_SECONDS = 4.3 const ISLET_BERTH = 3.5 /** The autopilot never lets the skater closer to a shore than this, metres. */ @@ -59,7 +87,7 @@ export const MAX_REACH = 150 /** Within this of the edge of their room, metres, the skater is turned to run along it. */ const EDGE = 8 /** How fast a skater who has got past that edge is brought back to it, m/s. */ -const EDGE_PULL = 6 +const EDGE_PULL = 8 /** * At a fork the skater changes channel once they are this much nearer the middle of the * other, metres: a little past half way, so that being a hair to one side as the two @@ -67,8 +95,8 @@ const EDGE_PULL = 6 */ const CHANGE_BY = 3 -/** How far ahead of itself the autopilot aims, metres: about two and a half seconds. */ -const LOOK_AHEAD = 18 +/** How far ahead of itself the autopilot aims, in seconds of skating, and never less than 12 m. */ +const LOOK_SECONDS = 2.6 /** * 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 @@ -85,9 +113,9 @@ const FORK_ASK = 200 const ISLAND_ASK = 120 const ISLAND_NEAR = 40 -/** How long stride number `index` lasts, seconds: the same every time it is asked. */ -export function strideSeconds(index: number): number { - return MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)() +/** How long stride number `index` lasts at a pace, seconds: the same every time it is asked. */ +export function strideSeconds(index: number, pace: Pace = 'cruise'): number { + return (MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()) * PACES[pace].strides } /** Seconds between going through the ice and skating on from the middle of the channel. */ @@ -105,6 +133,8 @@ export interface Skater { turn: number /** m/s along the direction of travel. */ speed: number + /** The pace they are keeping, or are working up or easing down to. */ + pace: Pace /** * 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 @@ -141,14 +171,14 @@ export interface Skater { 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, + x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED, pace: 'cruise', hold: 0, swing: 0, phase: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0, } } /** Seconds since the push that began the skater's current stride. */ export function sincePush(k: Skater): number { - return (k.stride % 1) * strideSeconds(Math.floor(k.stride)) + return (k.stride % 1) * strideSeconds(Math.floor(k.stride), k.pace) } /** @@ -203,8 +233,8 @@ const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.m * 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) +const mayPoint = (room: number, edge: 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)) @@ -256,24 +286,35 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul } const { channel } = k const along = Math.atan(channel.slope(k.s)) + // What the skater's speed makes of the limits and the distances: a faster skater + // turns less sharply, looks further ahead, and keeps further from the edges. + const pace = PACES[k.pace] + const maxTurn = Math.min(MAX_TURN, MAX_SIDEWAYS / Math.max(k.speed, 1)) + const lookAhead = Math.max(12, LOOK_SECONDS * k.speed) + const isletAhead = Math.max(24, ISLET_SECONDS * k.speed) + // (The room it takes to turn away from an edge goes up faster than the speed does.) + const edge = clamp(EDGE * (k.speed / SPEED) ** 1.5, 6, 28) + const edgePull = EDGE_PULL * Math.max(1, k.speed / SPEED) + const islandNear = (ISLAND_NEAR * Math.max(k.speed, SPEED)) / SPEED // Which side of an island: alongside one, the side the skater is on; coming up to // one, the side they asked for or are over to, or the island's own choice. const off = k.x - channel.centre(k.s) if (channel.island(k.s) > 0) k.side = Math.sign(off) || k.side || 1 - else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + ISLAND_NEAR) > 0) { + else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + islandNear) > 0) { if (Math.abs(steer) > 0.05) k.side = Math.sign(steer) - else if (k.side === 0 && channel.island(k.s + ISLAND_NEAR) > 0) k.side = Math.abs(off) > 3 ? Math.sign(off) : channel.islandSide(k.s + ISLAND_NEAR) + else if (k.side === 0 && channel.island(k.s + islandNear) > 0) k.side = Math.abs(off) > 3 ? Math.sign(off) : channel.islandSide(k.s + islandNear) } else k.side = 0 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))) + // A faster skater's swings are longer, though not in proportion: more of them go by in a minute. + const rate = (2 * Math.PI) / (CARVE_LENGTH * (pace.speed / SPEED) ** 0.7 * (1 + 0.3 * lake.carve(k.s))) let wanted: number if (Math.abs(steer) > 0.05) { k.idle = 0 - wanted = clamp(steer, -1, 1) * MAX_TURN + wanted = clamp(steer, -1, 1) * maxTurn // 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) @@ -292,7 +333,7 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul k.phase = angle >= 0 ? 0 : Math.PI } else { k.idle += dt - const ahead = k.s + LOOK_AHEAD + const ahead = k.s + lookAhead // 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 @@ -303,8 +344,8 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul 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) - const swung = k.swing * Math.sin(k.phase + rate * LOOK_AHEAD) + const clear = Math.min(edge, (farRight - farLeft) / 2) + const swung = k.swing * Math.sin(k.phase + rate * lookAhead) // 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) @@ -313,7 +354,7 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul 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) + wanted = clamp(wrap(Math.atan2(aim - k.x, lookAhead) - k.heading) / 0.8, -maxTurn, maxTurn) } // Which channel the line is now measured from, should the skater be in another by the @@ -325,16 +366,16 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul // 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)) { + let sharpest = Math.abs(steer) > 0.05 ? 0.6 * maxTurn : 0 + for (const islet of channel.islets(k.s - isletAhead - 10, k.s + isletAhead + 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 (along < 0 || along > isletAhead + islet.radius || short <= 0) continue + const needed = Math.min(2 * maxTurn, (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. @@ -349,25 +390,29 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul 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) + const mayRight = Math.sin(mayPoint(right - k.x, edge)) + const mayLeft = Math.sin(mayPoint(k.x - left, edge)) + if (towards > mayRight) wanted = -away * clamp((towards - mayRight) / 0.2, Math.max(0, -away * wanted), 2 * maxTurn) + if (towards < -mayLeft) wanted = away * clamp((-mayLeft - towards) / 0.2, Math.max(0, away * wanted), 2 * maxTurn) k.turn += (wanted - k.turn) * (1 - Math.exp(-dt / TURN_LAG)) k.heading += k.turn * dt - const pushing = sincePush(k) < PUSH_SECONDS + const pushing = sincePush(k) < pushSeconds(k.pace) // Snow on the ice drags at the blade: in the middle of a drift, four times as hard as // ice does, which brings a skater down to about half their speed. const drag = DRAG * (1 + SNOW_DRAG * lake.snow(k.x, k.s)) - k.speed += ((pushing ? PUSH : 0) - drag * (k.speed / SPEED) ** 2) * dt + // The push and the drag are balanced at the pace's own speed, whatever it is, and + // scaled so that the speed breathes by the same share of it at every pace. A skater + // who changes pace works up to the new one, or eases down to it, over a few strides. + const effort = pace.speed / SPEED / pace.strides + k.speed += effort * ((pushing ? PUSH : 0) - drag * (k.speed / pace.speed) ** 2) * dt // Which channel the skater is in can change; where they are on the ice does not. const was = alongside(k) 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)) + k.stride += dt / strideSeconds(Math.floor(k.stride), k.pace) 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 @@ -386,18 +431,18 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul const [edgeLeft, edgeRight] = room(k, k.s) 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) + k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -edgePull * dt, edgePull * dt) // 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)) + if (pointing > 0) k.heading += (over ? 1 : -1) * (Math.cos(off) >= 0 ? 1 : -1) * Math.min(2 * maxTurn * 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 + const push = Math.min(inside, edgePull * dt) / far k.x += (k.x - islet.x) * push k.s += (k.s - islet.s) * push } diff --git a/src/style.css b/src/style.css index 57a44b9..2f81ea1 100644 --- a/src/style.css +++ b/src/style.css @@ -269,3 +269,40 @@ dialog button.done { #tilt-button:not([hidden]) ~ #hint { bottom: calc(4.5rem + env(safe-area-inset-bottom)); } + +/* The pace, and the way to the next one: opposite the tilt button. */ +#pace-button { + position: fixed; + right: calc(0.75rem + env(safe-area-inset-right)); + bottom: calc(0.75rem + env(safe-area-inset-bottom)); + min-height: 2.75rem; + padding: 0 0.9rem; + border: 2px solid #fff; + border-radius: 2rem; + background: rgb(8 24 34 / 85%); + color: #fff; + font: 600 0.95rem/1 system-ui, sans-serif; + touch-action: manipulation; + cursor: pointer; +} + +/* The hint keeps clear of the buttons at the foot of the screen. */ +#hint { + bottom: calc(4.5rem + env(safe-area-inset-bottom)); +} + +/* A picture on a button: the shape of a piece of line art, in the colour of the button's words. */ +.icon { + display: inline-block; + width: 1.4em; + height: 1.4em; + vertical-align: -0.35em; + background: currentColor; + -webkit-mask: var(--icon) center / contain no-repeat; + mask: var(--icon) center / contain no-repeat; +} + +#pace-button .icon, +#tilt-button .icon { + margin-right: 0.45em; +} diff --git a/test/settings.test.ts b/test/settings.test.ts index 55bbeca..9209fb5 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -5,7 +5,7 @@ import { WEATHER, WEATHERS, anyLake, loadSettings, timeOf, weatherOf, windOf } f describe('settings', () => { it('starts with a surprise for the time of day, no impossible ice, and steering by touch', () => { // There is no storage here, which is also what a private window is like. - expect(loadSettings()).toEqual({ time: 'surprise', weather: 'surprise', impossibleIce: false, tilt: false }) + expect(loadSettings()).toEqual({ time: 'surprise', weather: 'surprise', pace: 'cruise', impossibleIce: false, tilt: false }) }) it('picks a lake from a million', () => { diff --git a/test/skater.test.ts b/test/skater.test.ts index b3aa9be..b6def37 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { BREAKING_THICKNESS } from '../src/ice/strength.js' import { ISLET_MARGIN, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, - RULES, type Skater, createSkater, stepSkater, strideSeconds, + PACES, PACE_NAMES, RULES, type Skater, createSkater, stepSkater, strideSeconds, } from '../src/skater.js' import { type Lake, createLake, mulberry32 } from '../src/world/lake.js' @@ -143,7 +143,7 @@ describe('skater', () => { for (let t = 0; t < 1200; 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) + expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.3) } nearest = Math.min(nearest, clearOfIslets(k)) expect(clearOfIsland(k)).toBeGreaterThan(0.5) @@ -310,6 +310,74 @@ describe('skater', () => { expect(k.speed).toBeGreaterThan(SPEED * 0.85) }) + it('keeps each pace: settles round its speed, in strides to match, and breathes by the same share', () => { + for (const pace of PACE_NAMES) { + const k = createSkater(lake, 3300) + k.pace = pace + run(k, 0, 40) + let sum = 0 + let slowest = Infinity + let fastest = 0 + const strides = k.stride + const steps = Math.round(120 / DT) + for (let i = 0; i < steps; i++) { + stepSkater(k, lake, 0, DT, STRAIGHT) + sum += k.speed + slowest = Math.min(slowest, k.speed) + fastest = Math.max(fastest, k.speed) + } + const wanted = PACES[pace].speed + expect(sum / steps / wanted).toBeGreaterThan(0.93) + expect(sum / steps / wanted).toBeLessThan(1.07) + // Between a tenth and a third of its speed between the slowest and fastest of a stride. + expect((fastest - slowest) / wanted).toBeGreaterThan(0.1) + expect((fastest - slowest) / wanted).toBeLessThan(0.35) + // Strides as long as the pace takes them: about 2.3 s at a cruise. + expect(120 / (k.stride - strides) / PACES[pace].strides).toBeCloseTo(2.3, 0) + } + }) + + it('works up to a faster pace and eases down to a slower one over a few strides', () => { + const k = createSkater(lake, 3300) + k.pace = 'olympic' + run(k, 0, 2) + // Not at once. + expect(k.speed).toBeLessThan(13) + run(k, 0, 25) + expect(k.speed).toBeGreaterThan(13) + k.pace = 'meander' + run(k, 0, 3) + expect(k.speed).toBeGreaterThan(5) + run(k, 0, 40) + expect(k.speed).toBeLessThan(4.5) + }) + + it('at a racing pace turns in a wider circle, and still keeps to the ice and clear of everything', () => { + const k = createSkater(lake) + k.pace = 'olympic' + const steer = fidget(11) + let hardest = 0 + for (let t = 0; t < 1200; t += DT) { + stepSkater(k, lake, steer(t), DT, RULES) + // Never pressed sideways much harder than half a g, turning away from an edge excepted. + hardest = Math.max(hardest, Math.abs(k.turn) * k.speed) + if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.3) + expect(clearOfIslets(k)).toBeGreaterThan(0) + expect(clearOfIsland(k)).toBeGreaterThan(0.3) + } + expect(hardest).toBeLessThan(11) + const auto = createSkater(lake) + auto.pace = 'olympic' + for (let t = 0; t < 1200; t += DT) { + stepSkater(auto, lake, 0, DT, RULES) + if (alone(auto)) expect(Math.abs(off(auto))).toBeLessThan(auto.channel.halfWidth(auto.s) - 0.5) + expect(clearOfIslets(auto)).toBeGreaterThan(0.5) + expect(clearOfIsland(auto)).toBeGreaterThan(0.5) + } + // Twenty minutes at that pace is the better part of eighteen kilometres. + expect(auto.s).toBeGreaterThan(15 * 1200 * 0.85) + }) + it('skates the same far down the lake as at its start', () => { const k = createSkater(lake, 1e6) run(k, 0, 10) diff --git a/tools/icons.mjs b/tools/icons.mjs index cc7b9b5..4248769 100644 --- a/tools/icons.mjs +++ b/tools/icons.mjs @@ -5,7 +5,8 @@ * * npm run icons * - * The artwork is a skate by Icongeek26, from Flaticon, used under the Flaticon free + * The artwork is a skate by Icongeek26, from Flaticon, as are the pictures on the app's + * buttons (assets/), all used under the Flaticon free * licence, which requires attribution (README, Attribution). It is black line on nothing; * only its shape is used. On a home screen it is drawn pale on the colour of black ice. * On a browser's tab it is as it was drawn: black, on nothing, so that the tab shows diff --git a/vite.config.ts b/vite.config.ts index 5d98529..3837e2c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,9 @@ export default defineConfig({ { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ], }, - workbox: { cleanupOutdatedCaches: true }, + // The pictures on the buttons are kept on the phone with the rest: without them + // named here they would be fetched, and missing with no network. + workbox: { cleanupOutdatedCaches: true, globPatterns: ['**/*.{js,css,html,png}'] }, // Not while developing: a service worker handing back yesterday's code is the last // thing wanted there. devOptions: { enabled: false },