feat: add ride progression store and inputs

Implement ride progression logic with pure functions
(clamp01, ease, activeStop), Zustand store for state
management, and React input hook for wheel/keyboard/touch.
This commit is contained in:
L'électron rare
2026-07-09 11:19:52 +02:00
parent 4436877e45
commit ce40fa93c1
4 changed files with 113 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { activeStop, clamp01, ease } from './progress'
import { STOPS } from '../content/stops'
import { useRide } from './store'
describe('clamp01', () => {
it('clamps to [0,1]', () => {
expect(clamp01(-0.5)).toBe(0)
expect(clamp01(1.5)).toBe(1)
expect(clamp01(0.42)).toBe(0.42)
})
})
describe('ease', () => {
it('converges toward target without overshoot', () => {
let t = 0
for (let i = 0; i < 300; i++) t = ease(t, 1, 1 / 60)
expect(t).toBeGreaterThan(0.99)
expect(t).toBeLessThanOrEqual(1)
})
})
describe('activeStop', () => {
it('detects a stop within radius and none between stops', () => {
expect(activeStop(STOPS[1].t)?.id).toBe('identities')
expect(activeStop((STOPS[0].t + STOPS[1].t) / 2)).toBeNull()
})
})
describe('useRide store', () => {
it('push accumulates and clamps, frame updates t and stop', () => {
useRide.getState().reset()
useRide.getState().push(5)
expect(useRide.getState().target).toBe(1)
for (let i = 0; i < 600; i++) useRide.getState().frame(1 / 60)
expect(useRide.getState().t).toBeGreaterThan(0.99)
expect(useRide.getState().stop?.id).toBe('greetings')
useRide.getState().reset()
expect(useRide.getState().t).toBe(0)
expect(useRide.getState().stop).toBeNull()
})
})
+14
View File
@@ -0,0 +1,14 @@
import { STOPS, STOP_RADIUS, type Stop } from '../content/stops'
export function clamp01(x: number): number {
return Math.min(1, Math.max(0, x))
}
/** Exponential easing of t toward target; dt in seconds. */
export function ease(t: number, target: number, dt: number): number {
return t + (target - t) * Math.min(1, dt * 2.5)
}
export function activeStop(t: number): Stop | null {
return STOPS.find((s) => Math.abs(s.t - t) <= STOP_RADIUS) ?? null
}
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand'
import type { Stop } from '../content/stops'
import { activeStop, clamp01, ease } from './progress'
interface RideStore {
t: number
target: number
stop: Stop | null
push: (delta: number) => void
frame: (dt: number) => void
reset: () => void
}
export const useRide = create<RideStore>((set) => ({
t: 0,
target: 0,
stop: null,
push: (delta) => set((s) => ({ target: clamp01(s.target + delta) })),
frame: (dt) =>
set((s) => {
const t = ease(s.t, s.target, dt)
return { t, stop: activeStop(t) }
}),
reset: () => set({ t: 0, target: 0, stop: null }),
}))
+32
View File
@@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { useRide } from './store'
const WHEEL_GAIN = 0.00018
const KEY_STEP = 0.02
export function useRideInput(): void {
useEffect(() => {
const push = useRide.getState().push
const onWheel = (e: WheelEvent) => push(e.deltaY * WHEEL_GAIN)
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') push(KEY_STEP)
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') push(-KEY_STEP)
}
let lastY = 0
const onTouchStart = (e: TouchEvent) => { lastY = e.touches[0].clientY }
const onTouchMove = (e: TouchEvent) => {
push((lastY - e.touches[0].clientY) * 0.0006)
lastY = e.touches[0].clientY
}
window.addEventListener('wheel', onWheel, { passive: true })
window.addEventListener('keydown', onKey)
window.addEventListener('touchstart', onTouchStart, { passive: true })
window.addEventListener('touchmove', onTouchMove, { passive: true })
return () => {
window.removeEventListener('wheel', onWheel)
window.removeEventListener('keydown', onKey)
window.removeEventListener('touchstart', onTouchStart)
window.removeEventListener('touchmove', onTouchMove)
}
}, [])
}