diff --git a/packages/pool/__tests__/pool-field.test.ts b/packages/pool/__tests__/pool-field.test.ts index 6b9a945..841858f 100644 --- a/packages/pool/__tests__/pool-field.test.ts +++ b/packages/pool/__tests__/pool-field.test.ts @@ -3,6 +3,7 @@ import { presets } from '@wavegrid/layout/client'; import { cannonPoints, CURRENT_N, + ROTATE_LAP_SECONDS, DEFAULT_POOL_SETTINGS, type Hsb, OutputSmoother, @@ -402,3 +403,46 @@ describe('the current', () => { }); }); }); + +describe('rotate', () => { + const posOfFirst = (field: PoolField) => { + let first: { x: number; y: number } | null = null; + field.forEachSource((s) => { + if (!first) first = { x: s.x, y: s.y }; + }); + return first!; + }; + + it('turns a held spot about the centre at one lap per ROTATE_LAP_SECONDS, eased in', () => { + const field = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 0, rotate: 1 }); + field.pointerDown(1, 0.8, 0.5, COLOR); + field.step(DT); + field.pointerUp(1); + const p0 = posOfFirst(field); + run(field, ROTATE_LAP_SECONDS / 4); + const p1 = posOfFirst(field); + const a0 = Math.atan2(p0.y - 0.5, p0.x - 0.5); + const a1 = Math.atan2(p1.y - 0.5, p1.x - 0.5); + const turned = ((a1 - a0 + 3 * Math.PI) % (2 * Math.PI)) - Math.PI; + // A quarter lap, less the ~3 s ease-in; clockwise on screen (y down). + expect(turned).toBeGreaterThan(Math.PI / 2 - 1.2); + expect(turned).toBeLessThan(Math.PI / 2); + expect(Math.hypot(p1.x - 0.5, p1.y - 0.5)).toBeCloseTo(0.3, 2); + }); + + it('negative rotate turns the other way; 0 leaves a still pool still', () => { + const ccw = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 0, rotate: -0.5 }); + ccw.pointerDown(1, 0.8, 0.5, COLOR); + ccw.step(DT); + ccw.pointerUp(1); + run(ccw, 5); + expect(posOfFirst(ccw).y).toBeLessThan(0.49); + + const still = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 0, rotate: 0 }); + still.pointerDown(1, 0.8, 0.5, COLOR); + still.step(DT); + still.pointerUp(1); + run(still, 5); + expect(posOfFirst(still).y).toBeCloseTo(0.5, 3); + }); +}); diff --git a/packages/pool/src/pool-field.ts b/packages/pool/src/pool-field.ts index 6e4fef5..2971b5b 100644 --- a/packages/pool/src/pool-field.ts +++ b/packages/pool/src/pool-field.ts @@ -36,6 +36,11 @@ export interface PoolSettings { * `persistence`. */ hold?: boolean; + /** + * -1..1 — a constant, slow turn of the whole pool about the layout centre, + * under whatever else is happening. 0 is still; sign is direction. + */ + rotate?: number; } export const DEFAULT_POOL_SETTINGS: PoolSettings = { @@ -43,7 +48,8 @@ export const DEFAULT_POOL_SETTINGS: PoolSettings = { motion: 0.35, spread: 0.5, persistence: 0.55, - hold: false + hold: false, + rotate: 0 }; export interface PoolColor { @@ -130,6 +136,10 @@ const SPIRAL_CENTRE_TAU = 1.6; const SPIRAL_SPIN_TAU = 2.2; /** A released stroke dissolves on this clock. */ const RELEASE_TAU = 0.9; +/** Rotate at full deflection: one lap of the pool in this many seconds. */ +export const ROTATE_LAP_SECONDS = 20; +/** Moving the Rotate slider eases the turn in on this clock. */ +const ROTATE_TAU = 3; /** A new source swells in on this clock, so a tap blooms rather than pops. */ const BLOOM_TAU = 0.4; /** A held blob never spreads wider than this, so a held field keeps its shape. */ @@ -152,6 +162,11 @@ export function spreadSigma(s: number): number { } /** Motion 0..1 → spiral spin in radians per second (a lap takes 20s to 2min). */ +/** Angular speed (rad/s) for a Rotate setting of -1..1. */ +export function rotateOmega(r: number): number { + return (clamp(r, -1, 1) * 2 * Math.PI) / ROTATE_LAP_SECONDS; +} + export function spiralOmega(m: number): number { return lerp(0.05, 0.32, clamp(m, 0, 1)); } @@ -172,6 +187,8 @@ export class PoolField { private stir = 0; /** How the water remembers being stirred; sources ride it. */ private current = new Current(); + /** The constant turn, eased toward the slider. */ + private rotOmega = 0; /** Bumped by reset(), so whoever smooths the output knows to drop its state too. */ generation = 0; @@ -352,6 +369,18 @@ export class PoolField { this.spiral.omega += (0 - this.spiral.omega) * lagStep(dt, SPIRAL_SPIN_TAU); } + this.rotOmega += (rotateOmega(this.settings.rotate ?? 0) - this.rotOmega) * lagStep(dt, ROTATE_TAU); + const rot = this.rotOmega * dt; + const cosR = Math.cos(rot); + const sinR = Math.sin(rot); + const carousel = (s: { x: number; y: number }) => { + if (Math.abs(rot) < 1e-7) return; + const rx = s.x - 0.5; + const ry = s.y - 0.5; + s.x = 0.5 + rx * cosR - ry * sinR; + s.y = 0.5 + rx * sinR + ry * cosR; + }; + // 3. Sources: drift, spread, turn, dissolve. const hold = this.settings.hold === true; const tau = persistenceSeconds(this.settings.persistence); @@ -382,6 +411,7 @@ export class PoolField { s.ring += ringGrow; // A ring thins as it widens so the total light stays about the same. s.energy *= Math.exp(-ringGrow * 2.5); + carousel(s); continue; } const c = this.current.at(s.x, s.y); @@ -397,6 +427,7 @@ export class PoolField { s.x = cx + rx * cosT - ry * sinT; s.y = cy + rx * sinT + ry * cosT; } + carousel(s); } this.sources = this.sources.filter((s) => (s.energy > 0.004 || s.target > 0.004) && (s.kind === 'blob' || s.ring < 1.6)); diff --git a/packages/server/__tests__/pool.test.ts b/packages/server/__tests__/pool.test.ts index 8a22b5a..cad63e7 100644 --- a/packages/server/__tests__/pool.test.ts +++ b/packages/server/__tests__/pool.test.ts @@ -15,7 +15,7 @@ interface PoolMsg { active: boolean; touches: number; sources: Array<{ x: number; y: number; energy: number }>; - settings: { mode: string; motion: number; spread: number; persistence: number; hold?: boolean }; + settings: { mode: string; motion: number; spread: number; persistence: number; hold?: boolean; rotate?: number }; } interface Client { @@ -86,7 +86,7 @@ describe('server-owned pool', () => { beforeEach(async () => { handle.send({ type: 'clear' }); - handle.send({ type: 'pool_settings', mode: 'flow', motion: 0.35, spread: 0.5, persistence: 0.55, hold: false }); + handle.send({ type: 'pool_settings', mode: 'flow', motion: 0.35, spread: 0.5, persistence: 0.55, hold: false, rotate: 0 }); handle.send({ type: 'smoothness', value: 0.3 }); handle.send({ type: 'attack', value: 1 }); await wait(50); @@ -225,6 +225,12 @@ describe('server-owned pool', () => { a.send({ type: 'pool_settings', hold: true }); await wait(80); expect(b.pool?.settings.hold).toBe(true); + a.send({ type: 'pool_settings', rotate: -3 }); + await wait(80); + expect(b.pool?.settings.rotate).toBe(-1); + a.send({ type: 'pool_settings', rotate: 'fast' }); + await wait(80); + expect(b.pool?.settings.rotate).toBe(-1); a.ws.close(); b.ws.close(); }); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 34c648c..f75e008 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -831,7 +831,11 @@ function handleMessage(msg: any, ws?: WebSocket) { motion: num(msg.motion, s.motion), spread: num(msg.spread, s.spread), persistence: num(msg.persistence, s.persistence), - hold: typeof msg.hold === 'boolean' ? msg.hold : s.hold === true + hold: typeof msg.hold === 'boolean' ? msg.hold : s.hold === true, + rotate: + typeof msg.rotate === 'number' && Number.isFinite(msg.rotate) + ? Math.max(-1, Math.min(1, msg.rotate)) + : (s.rotate ?? 0) }; broadcastPool(); scheduleSave(); diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx index 33f7184..84d1d3a 100644 --- a/packages/ui/src/app.tsx +++ b/packages/ui/src/app.tsx @@ -810,7 +810,8 @@ export default function Home() { prev.motion === poolServerSettings.motion && prev.spread === poolServerSettings.spread && prev.persistence === poolServerSettings.persistence && - (prev.hold === true) === (poolServerSettings.hold === true) + (prev.hold === true) === (poolServerSettings.hold === true) && + (prev.rotate ?? 0) === (poolServerSettings.rotate ?? 0) ? prev : poolServerSettings ); diff --git a/packages/ui/src/components/pool-tab.tsx b/packages/ui/src/components/pool-tab.tsx index 55348cd..935c790 100644 --- a/packages/ui/src/components/pool-tab.tsx +++ b/packages/ui/src/components/pool-tab.tsx @@ -59,6 +59,7 @@ export function PoolTab({ onSettings({ ...settings, [key]: value }); const mode = MODES.find((m) => m.key === settings.mode) ?? MODES[0]; const hold = settings.hold === true; + const rotate = settings.rotate ?? 0; return (
- Intensity and fade are the master sliders above; Clear up top + Rotate turns the whole pool slowly, under everything else; centre + is off. Intensity and fade are the master sliders above; Clear up top dissolves too.