nordic-skating / commit
feat: the skater leaves tracks on the ice, and cracks where it is thin
src/main.ts | 34 +++++++++--- src/render/marks.ts | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index adfda40..90c62bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import * as THREE from 'three' import { IceSound } from './audio/iceSound.js' import { Input } from './input.js' import { Ice } from './render/ice.js' +import { Marks } from './render/marks.js' import { Shores } from './render/shores.js' import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js' import { Snow } from './render/snow.js' @@ -64,10 +65,11 @@ const ice = new Ice(renderer, lake) const shores = new Shores(lake, seed) const sky = new Sky() const snow = new Snow() +const marks = new Marks() // The light of the whole sky, and the sun or moon, which gives the banks and trees a lit side. const skyLight = new THREE.HemisphereLight() const sun = new THREE.DirectionalLight() -scene.add(sky.mesh, ice.mesh, shores.group, snow.points, skyLight, sun) +scene.add(sky.mesh, ice.mesh, marks.mesh, shores.group, snow.points, skyLight, sun) /** Put the lake under the sky, in the light and in the weather it is to have. */ function showWorld(): void { @@ -306,6 +308,7 @@ function enterLake(to: number): void { ice.show(lake) shores.show(lake, seed) skater = createSkater(lake) + marks.clear(skater.x, skater.s) showWorld() showLake() // One lake does not turn into another: the haze closes in and clears on the new one. @@ -356,6 +359,7 @@ function keepAwake(): void { keepAwake() document.addEventListener('visibilitychange', keepAwake) window.addEventListener('pointerdown', keepAwake, { once: true }) +marks.clear(skater.x, skater.s) /** Seconds until the ice next cracks somewhere off across the lake. */ let untilCrack = 3 @@ -377,6 +381,21 @@ renderer.setAnimationLoop(() => { const wasUnder = skater.under > 0 stepSkater(skater, lake, steer, dt) const h = lake.thickness(skater.x, skater.s) + // What the skates leave on the ice: their line, and a scratch at each push, off the + // left skate and the right in turn. + marks.follow(skater.x, skater.s) + if (dt > 0) marks.track(skater.x, skater.s) + const pushed = Math.floor(skater.stride) !== strides + if (pushed) marks.push(skater.x, skater.s, skater.heading, Math.floor(skater.stride) % 2 ? 1 : -1) + // Ice near its limit cracks under the skater: the thinner, the more often and the + // nearer, until at the breaking thickness it is cracking every second or so. Thinner + // than that it is not real ice, and does not crack at all (world/lake.ts). + const strain = h < BREAKING_THICKNESS ? 0 : Math.max(0, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS)) + // A push on such ice starts cracks from under the skate, which is seen whether or not + // there is sound to hear it by. + if (pushed && strain > 0.2) { + for (let n = 0; n < 1 + 3 * strain; n++) marks.crack(skater.x, skater.s, 1 + 5 * strain * Math.random()) + } const fell = skater.under > 0 && !wasUnder // Nothing is asked of the sound while it is off: it would all play at once on its return. if (fell && sounding()) sound?.fall(h) @@ -389,15 +408,18 @@ renderer.setAnimationLoop(() => { if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, 0.75 + 0.5 * Math.random()) // The push is over half a second into a stride; the rest of it is glide. sound.glide(h, Math.exp(-sincePush(skater) / 0.5)) - // Ice near its limit cracks under the skater: the thinner, the more often and the - // nearer, until at the breaking thickness it is cracking every second or so. - // Thinner than that it is not real ice, and does not crack at all (world/lake.ts). - const strain = h < BREAKING_THICKNESS ? 0 : Math.max(0, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS)) untilCrack -= dt * (1 + 14 * strain ** 2) if (untilCrack <= 0) { // On sound ice: mostly near, now and then from far down the lake. const distance = strain > 0 ? 2 + 30 * (1 - strain) * Math.random() : 40 + 560 * Math.random() ** 2 - sound.crack(h, distance, undefined, Math.random() * 2 - 1) + const pan = Math.random() * 2 - 1 + sound.crack(h, distance, undefined, pan) + // A crack near enough to see is drawn where it was heard from: that far off, and + // to the side of the skater it sounded on. + if (distance < 40) { + const towards = skater.heading + pan * 1.4 + marks.crack(skater.x + distance * Math.sin(towards), skater.s + distance * Math.cos(towards), 3 + 9 * Math.random()) + } untilCrack = 3 + 12 * Math.random() } } diff --git a/src/render/marks.ts b/src/render/marks.ts new file mode 100644 index 0000000..9b1cc60 --- /dev/null +++ b/src/render/marks.ts @@ -0,0 +1,153 @@ +import * as THREE from 'three' + +/** Side of the square of ice round the skater that keeps its marks, metres, and its picture's side in pixels. */ +const SIZE = 100 +const PIXELS = 1024 +const PER_METRE = PIXELS / SIZE +/** When the skater is this far from the middle of the square, metres, it is moved to be round them again. */ +const RECENTRE = 22 +/** The line of the skates is drawn on in stretches this long, metres. */ +const TRACK_STEP = 0.7 + +/** + * What the skater leaves on the ice: the lines of their skates, the scratch of each push, + * and, on ice thin enough to crack, the cracks. + * + * They are drawn on a picture laid over the ice round the skater, a hundred metres square, + * which stays where it is on the ice while the skater moves about on it. So a skater who + * turns back, or circles an islet, comes on their own tracks, as in the film. When the + * skater nears its edge the picture is moved along, and what was at its far side is lost: + * the lake does not remember further than that. + */ +export class Marks { + readonly mesh: THREE.Mesh + private readonly canvas = document.createElement('canvas') + private readonly pen: CanvasRenderingContext2D + private readonly texture: THREE.CanvasTexture + /** Where the middle of the square is on the lake: across, and along. */ + private midX = 0 + private midS = 0 + /** Where the line of the skates was last drawn to; nowhere when the pen is up. */ + private last: [number, number] | null = null + + constructor() { + this.canvas.width = this.canvas.height = PIXELS + this.pen = this.canvas.getContext('2d')! + this.pen.lineCap = 'round' + this.pen.lineJoin = 'round' + this.texture = new THREE.CanvasTexture(this.canvas) + this.texture.colorSpace = THREE.SRGBColorSpace + this.mesh = new THREE.Mesh( + new THREE.PlaneGeometry(SIZE, SIZE), + // Lit like the ice it lies on, so that the marks are pale by day and dim by night. + new THREE.MeshLambertMaterial({ map: this.texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }), + ) + this.mesh.rotation.x = -Math.PI / 2 + this.mesh.renderOrder = 1 + } + + /** Where a place on the lake is in the picture, in pixels: its top is the far end. */ + private at(x: number, s: number): [number, number] { + return [(x - this.midX + SIZE / 2) * PER_METRE, (SIZE / 2 - (s - this.midS)) * PER_METRE] + } + + /** Another lake, or a skater set down somewhere else: nothing has been marked here. */ + clear(x: number, s: number): void { + this.pen.clearRect(0, 0, PIXELS, PIXELS) + this.midX = x + this.midS = s + this.last = null + this.texture.needsUpdate = true + } + + /** Keep the picture where it lies on the ice, for a skater at `x` across and `s` along. */ + follow(x: number, s: number): void { + if (Math.abs(x - this.midX) > RECENTRE || Math.abs(s - this.midS) > RECENTRE) { + // Moved by whole pixels, so that what is kept is not blurred by the move. + const dx = Math.round((x - this.midX) * PER_METRE) + const ds = Math.round((s - this.midS) * PER_METRE) + this.pen.save() + this.pen.globalCompositeOperation = 'copy' + this.pen.drawImage(this.canvas, -dx, ds) + this.pen.restore() + this.midX += dx / PER_METRE + this.midS += ds / PER_METRE + this.texture.needsUpdate = true + } + this.mesh.position.set(this.midX - x, 0, -(this.midS - s)) + } + + /** The line of the skates, on to where the skater now is. */ + track(x: number, s: number): void { + // A stretch at a time, not a frame at a time: every stretch drawn is the whole + // picture sent to the graphics card again. + if (this.last && Math.hypot(x - this.last[0], s - this.last[1]) < TRACK_STEP) return + const to = this.at(x, s) + if (this.last) { + const from = this.at(...this.last) + this.pen.strokeStyle = 'rgba(214, 232, 244, 0.55)' + this.pen.lineWidth = 1.1 + this.pen.beginPath() + this.pen.moveTo(...from) + this.pen.lineTo(...to) + this.pen.stroke() + this.texture.needsUpdate = true + } + this.last = [x, s] + } + + /** + * The scratch of a push: a short line off to one side of the way the skater is going, + * `foot` being -1 for a push off the left skate and 1 for the right. + */ + push(x: number, s: number, heading: number, foot: number): void { + // The skate is a little to that side of the skater, and is pushed out and back: a + // hundred degrees round from the way they are going. + const footX = x + foot * 0.25 * Math.cos(heading) + const footS = s - foot * 0.25 * Math.sin(heading) + const out = heading + foot * 1.75 + const [fromX, fromY] = this.at(footX, footS) + const [toX, toY] = this.at(footX + 1.3 * Math.sin(out), footS + 1.3 * Math.cos(out)) + this.pen.strokeStyle = 'rgba(232, 242, 250, 0.7)' + this.pen.lineWidth = 1.6 + this.pen.beginPath() + this.pen.moveTo(fromX, fromY) + this.pen.lineTo(toX, toY) + this.pen.stroke() + this.texture.needsUpdate = true + } + + /** + * A crack opening in the ice at `x`, `s`: a jagged line about `length` metres long that + * forks once or twice, running whichever way it runs. + */ + crack(x: number, s: number, length: number): void { + const run = (fromX: number, fromS: number, way: number, far: number, wide: number): void => { + let px = fromX + let ps = fromS + this.pen.strokeStyle = 'rgba(236, 246, 252, 0.8)' + this.pen.lineWidth = wide + this.pen.beginPath() + this.pen.moveTo(...this.at(px, ps)) + for (let gone = 0; gone < far; ) { + const step = 0.3 + Math.random() * 0.7 + // Kinked at every step, but about the one way it runs: a crack does not curl. + const kinked = way + (Math.random() - 0.5) * 1.2 + px += Math.sin(kinked) * step + ps += Math.cos(kinked) * step + gone += step + this.pen.lineTo(...this.at(px, ps)) + // A fork: a shorter, finer crack off to one side, drawn when this one is done. + if (wide > 1 && Math.random() < 0.12) forks.push([px, ps, way + (Math.random() < 0.5 ? 0.9 : -0.9), (far - gone) * 0.6]) + } + this.pen.stroke() + } + const forks: [number, number, number, number][] = [] + const way = Math.random() * Math.PI * 2 + // Both ways from where it starts. + run(x, s, way, length / 2, 1.5) + run(x, s, way + Math.PI, length / 2, 1.5) + for (const [fx, fs, fway, far] of forks) run(fx, fs, fway, far, 0.9) + this.texture.needsUpdate = true + } +}