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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }),
|
||||
}))
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
Reference in New Issue
Block a user