From 5869df1f0c3b8876ab040982c00532e724cb4bb5 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 1 Sep 2026 16:00:50 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.7=20=E2=80=94=20idle=20drift=20cru?= =?UTF-8?q?ise:=20wire=20driftAngle/driftSpeed=20into=20the=20physics=20lo?= =?UTF-8?q?op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through 0.3.6 driftAngle/driftSpeed were initialized per blob but never read: with no pointer, scroll, or devicemotion input (every idle desktop) the only motion was zero-mean jitter plus a slow bounded slosh, so the background read as frozen. The loop now applies a constant per-substep force along each blob's persistent heading with driftSpeed as the terminal speed under the unchanged *=0.992 damping (extracted to VELOCITY_DAMPING): blobs drift gently by default, reach walls, and bounce — recordBounce() already re-randomizes the heading on impact. The force is purely additive and consumes no randomness, so gravity/pointer/scroll feel is untouched, and reduced-motion behavior is unchanged (the cruise lives inside the step the component never runs under prefers-reduced-motion). driftSpeed init raised 0.01+rand*0.015 -> 0.05+rand*0.05 (terminal ~3-6 units/s in the 180-unit field; field crossing in ~30-60s). Deterministic unit coverage in tests/unit/idle-drift-cruise.test.ts: a differential run pair isolates the cruise term exactly, plus wall-bounce containment and the init floor. --- BUILD.bazel | 2 +- CHANGELOG.md | 7 ++ MODULE.bazel | 2 +- docs/physics-feel-contract.md | 2 + package.json | 2 +- src/core/BlobPhysics.ts | 31 +++++++-- tests/unit/idle-drift-cruise.test.ts | 97 ++++++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 tests/unit/idle-drift-cruise.test.ts diff --git a/BUILD.bazel b/BUILD.bazel index f2314a7..84549a8 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -149,7 +149,7 @@ npm_package( ":tinyvectors", ], package = "@tummycrypt/tinyvectors", - version = "0.3.6", + version = "0.3.7", visibility = ["//visibility:public"], ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b57cc8..d7bdf45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ > [tinyland-inc/bazel-registry](https://github.com/tinyland-inc/bazel-registry) > only. See the README's Install section for the sanctioned consumption paths. +## 0.3.7 - 2026-09-01 + +- Idle drift cruise: `driftAngle`/`driftSpeed` — initialized per blob since 0.3.0 but never read by the physics loop — are now wired into the screensaver physics step as a constant per-substep force along each blob's persistent heading, with `driftSpeed` its terminal speed under the unchanged `*= 0.992` damping. With no pointer, scroll, or devicemotion input (i.e. every idle desktop), the only idle motion was zero-mean jitter and a slow bounded slosh, so the background read as frozen; blobs now drift gently around the field by default, reach the walls, and bounce (`recordBounce()` already re-randomizes the heading on impact). No permission grant or sensor is required, and devicemotion/pointer/scroll input still layers on top with identical feel: the cruise force is purely additive and consumes no randomness, so transient impulses decay exactly as before. +- `driftSpeed` init raised from `0.01 + rand * 0.015` (dead-code-era values, weaker than the ambient slosh) to `0.05 + rand * 0.05`: terminal cruise ≈ 3–6 units/s in the 180-unit physics field, i.e. a blob crosses the field in roughly 30–60 s. +- `prefers-reduced-motion` behavior is unchanged: the cruise lives inside the physics step, which `TinyVectors` never runs while reduced motion is active (`respectReducedMotion` defaults `true` and renders the existing static frame). +- The per-substep damping literal is extracted to a named `VELOCITY_DAMPING` constant (still `0.992`); no behavior change. + ## 0.3.6 - 2026-07-25 - Adds a `respectReducedMotion?: boolean` prop (default `true`) to `TinyVectors`: when `(prefers-reduced-motion: reduce)` matches, the component renders the existing static single frame (the same path `animated={false}` already uses) instead of running the rAF loop, and switches live if the media query changes. Pass `respectReducedMotion={false}` to animate regardless (TIN-3170). diff --git a/MODULE.bazel b/MODULE.bazel index a977d45..972b4dc 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -7,7 +7,7 @@ Usage from external repo: module( name = "tummycrypt_tinyvectors", - version = "0.3.6", + version = "0.3.7", compatibility_level = 1, ) diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index eb917f0..89d9373 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -46,6 +46,8 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s ## Current Status +**2026-09-01 update (0.3.7):** the idle drift cruise has landed. `driftAngle`/`driftSpeed` were initialized per blob since 0.3.0 but never read by the physics loop, so "idle blobs drift" (this document's first promise) was only ever the zero-mean jitter plus a slow bounded slosh — measured on an idle desktop, coherent displacement decorrelated within seconds and the background read as frozen. The loop now applies a constant per-substep force along each blob's persistent heading with `driftSpeed` (init `0.05 + rand * 0.05`) as the terminal speed under the unchanged `*= 0.992` damping: ≈3–6 units/s in the 180-unit field, so a blob crosses the field in roughly 30–60 s, reaches walls, and bounces (`recordBounce()` re-randomizes the heading on impact). The force is purely additive and consumes no randomness, so gravity/pointer/scroll feel and transient-impulse decay are untouched, and reduced-motion behavior is unchanged (the cruise lives inside the step the component never runs under `prefers-reduced-motion`). Unit coverage: `tests/unit/idle-drift-cruise.test.ts` (differential run isolating the cruise term, wall-bounce containment, init floor). + **2026-07-25 update (0.3.6):** the fixed-timestep accumulator described in TIN-853 has landed. `BlobPhysics.tick()` still accepts the caller's variable per-frame `deltaTime`, but the simulation now always advances in fixed `1/60`s quanta (capped at 8 substeps per call) instead of integrating directly on the raw frame delta. Before 0.3.6, nothing in the physics loop actually scaled by `deltaTime` — velocity adds, damping multipliers (`*= 0.992`), and drift amounts were all raw per-invocation constants — so feel and damping implicitly scaled with whatever refresh rate the caller happened to run at. Concretely, **a 120Hz display ran physics roughly 2x too fast** (twice as many ticks, hence twice as much ambient drift/damping/deformation, per real second), and a throttled ~30Hz caller ran it roughly half as fast. The 0.3.6 feel change observed on non-60Hz displays is therefore the **correction** toward this document's refresh-rate-independence intent, not a regression: 60Hz displays (the common baseline) see no feel change, and 120Hz/30Hz displays now match 60Hz feel instead of drifting from it. The browser probe that would confirm this end-to-end still requires a local server to drive real CDP orientation/pointer events (see Test Strategy above); running it is deferred to CI or another sanctioned lane rather than ad hoc here. - Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. diff --git a/package.json b/package.json index 6cff52f..490d44c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tummycrypt/tinyvectors", - "version": "0.3.6", + "version": "0.3.7", "description": "Animated vector blob backgrounds with physics simulation for Svelte 5", "type": "module", "packageManager": "pnpm@9.15.9", diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 0194d7c..7852362 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -61,6 +61,13 @@ const FIXED_TIMESTEP_SECONDS = 1 / 60; // spiral into an unbounded number of substeps in one frame. const MAX_PHYSICS_SUBSTEPS = 8; +// Per-substep velocity damping applied at the end of updateScreensaverPhysics. +// Terminal speed under a constant per-substep force f is +// f * VELOCITY_DAMPING / (1 - VELOCITY_DAMPING) ≈ 124 * f — the relation the +// idle cruise in updateMovementWithAccelerometer() inverts so each blob's +// driftSpeed is its terminal cruise speed. +const VELOCITY_DAMPING = 0.992; + export class BlobPhysics { private blobs: ConvexBlob[] = []; private config: BlobPhysicsConfig; @@ -448,7 +455,7 @@ export class BlobPhysics { wallBounceCount: 0, lastBounceTime: 0, driftAngle: Math.random() * Math.PI * 2, - driftSpeed: 0.01 + Math.random() * 0.015, + driftSpeed: 0.05 + Math.random() * 0.05, territoryRadius: 100 + Math.random() * 60, territoryX: clampedX, territoryY: clampedY, @@ -535,8 +542,8 @@ export class BlobPhysics { this.handleWallBouncing(blob); - blob.velocityX *= 0.992; - blob.velocityY *= 0.992; + blob.velocityX *= VELOCITY_DAMPING; + blob.velocityY *= VELOCITY_DAMPING; } private applyAccelerometerForces(blob: ConvexBlob): void { @@ -580,7 +587,23 @@ export class BlobPhysics { blob.velocityX += brownianX; blob.velocityY += brownianY; - + // Idle cruise (0.3.7): driftAngle/driftSpeed have been initialized per + // blob since 0.3.0 but were never read by the physics loop, so with no + // pointer, scroll, or devicemotion input the only idle motion was + // zero-mean jitter and slow bounded slosh — the background read as + // frozen on every desktop. A constant per-substep force along the + // blob's persistent heading makes driftSpeed the terminal cruise speed + // under the unchanged damping: blobs drift, reach walls, and bounce + // (recordBounce() already re-randomizes driftAngle on impact), while + // every transient input — scroll, pointer, devicemotion — keeps + // exactly the same feel because the cruise force is purely additive + // and consumes no randomness. + const driftAngle = blob.driftAngle || 0; + const cruiseForce = (blob.driftSpeed ?? 0.075) * (1 - VELOCITY_DAMPING); + blob.velocityX += Math.cos(driftAngle) * cruiseForce; + blob.velocityY += Math.sin(driftAngle) * cruiseForce; + + if (Math.random() < 0.002) { blob.driftAngle = Math.random() * Math.PI * 2; } diff --git a/tests/unit/idle-drift-cruise.test.ts b/tests/unit/idle-drift-cruise.test.ts new file mode 100644 index 0000000..2d4c544 --- /dev/null +++ b/tests/unit/idle-drift-cruise.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BlobPhysics } from '../../src/core/BlobPhysics.js'; +import type { ConvexBlob } from '../../src/core/types.js'; + +// Idle drift cruise (0.3.7): with NO pointer, scroll, or devicemotion input, +// blobs must still move — driftAngle/driftSpeed are wired into the physics +// loop as a constant per-substep force whose terminal speed is driftSpeed. +// +// Math.random is mocked to a constant 0.5 so: +// - neutral drift and territorial jitter are exactly zero ((0.5 - 0.5) * k); +// - the 0.002-probability drift retarget never fires (0.5 < 0.002 is false); +// - recordBounce()'s velocity kick is exactly zero; +// - init draws identical deterministic values on every run. +// The bounded brownian slosh term is sinusoidal and RNG-free, so paired runs +// below see byte-identical brownian forces and their difference isolates the +// cruise wiring exactly. + +const FRAME = 1 / 60; + +let randomSpy: ReturnType; + +beforeEach(() => { + randomSpy = vi.spyOn(Math, 'random'); + randomSpy.mockReturnValue(0.5); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function runIdle( + seconds: number, + configureBlob: (blob: ConvexBlob) => void +): Promise<{ dx: number; dy: number; blob: ConvexBlob }> { + const physics = new BlobPhysics(1); + await physics.init(); + const blob = physics.getBlobs()[0]; + configureBlob(blob); + const startX = blob.currentX; + const startY = blob.currentY; + const frames = Math.round(seconds / FRAME); + for (let i = 0; i < frames; i++) { + physics.tick(FRAME, i * FRAME); + } + return { dx: blob.currentX - startX, dy: blob.currentY - startY, blob }; +} + +describe('idle drift cruise', () => { + it('initializes every blob with a cruise heading and a non-trivial cruise speed', async () => { + const physics = new BlobPhysics(4); + await physics.init(); + for (const blob of physics.getBlobs()) { + expect(blob.driftAngle).toBeTypeOf('number'); + // Constant 0.5 draw → 0.05 + 0.5 * 0.05 exactly; the floor is the + // contract (dead-code era init was 0.01 + rand * 0.015, which the + // damping made imperceptible against the ambient slosh). + expect(blob.driftSpeed).toBeGreaterThanOrEqual(0.05); + expect(blob.driftSpeed).toBeLessThanOrEqual(0.1); + } + }); + + it('cruise force moves an otherwise-idle blob along its heading (differential vs driftSpeed=0)', async () => { + // Identical mocked RNG and identical brownian phase in both runs: the + // trajectories differ ONLY by the cruise term under test. + const withCruise = await runIdle(10, (blob) => { + blob.driftAngle = Math.PI; // straight toward -x + blob.driftSpeed = 0.1; + }); + const withoutCruise = await runIdle(10, (blob) => { + blob.driftAngle = Math.PI; + blob.driftSpeed = 0; + }); + + const cruiseDx = withCruise.dx - withoutCruise.dx; + const cruiseDy = withCruise.dy - withoutCruise.dy; + + // Terminal speed ≈ driftSpeed (0.1 units/substep ≈ 6 units/s in the + // 180-unit field) with a ~2s exponential spin-up: expect roughly + // -47 units over 10 simulated seconds, entirely along the heading. + expect(cruiseDx).toBeLessThan(-35); + expect(cruiseDx).toBeGreaterThan(-60); + expect(Math.abs(cruiseDy)).toBeLessThan(0.001); + }); + + it('a cruising blob reaches the wall and bounces instead of escaping the field', async () => { + const { blob } = await runIdle(3, (b) => { + b.currentX = -20; // near the left wall (clamp at PHYSICS_MIN + size * 0.8) + b.driftAngle = Math.PI; // heading into the wall + b.driftSpeed = 0.3; + }); + + expect(blob.wallBounceCount ?? 0).toBeGreaterThanOrEqual(1); + // Wall clamp held: the blob never leaves the physics field. + expect(blob.currentX).toBeGreaterThanOrEqual(-40); + }); +});