mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
Blend the liquid drop into its card and let it be spun by hand
The rim hue drifted with play time until it left the card's colour altogether, so a pink card could carry a green drop. It now only sways around the accent. The edge fades into the page, a halo in the accent pulses behind it like the plain artwork's glow, the surface reflects the accent from above, and the title and the rest of the card sit on top where the drop spills over them. Dragging the drop turns it (sideways only on touch, where up and down turn the page). A flick keeps it spinning; it slows down and a weak spring wobbles it back to face the front. Spun fast it flattens a little and sloshes, and the artwork blurs into colour where it is seen from the side. A spin is not a tap, so it does not pause.
This commit is contained in:
1 parent
b09a7370ca
commit
2ddc1b5629
7 files changed
+265
-31
No files matched your search
@@ -159,8 +159,9 @@ Single-letter keys clash with vim-key browser extensions (Vimium and the like),
|
||||
- `blob/`: the liquid artwork. One WebGL2 canvas moves into the playing card: an icosphere
|
||||
displaced by noise driven by those features (lobes, ripples, snare spikes, a ring after each
|
||||
kick), showing the artwork through its surface. Tempo, tags and key set its character
|
||||
(`blob/style.ts`). It steps its quality down on slow devices and falls back to the plain
|
||||
artwork without WebGL2, with reduced motion, or with visuals set to simple.
|
||||
(`blob/style.ts`). Dragging it spins it; a flick keeps spinning and springs back to face
|
||||
the front (`blob/spin.ts`). It steps its quality down on slow devices and falls back to the
|
||||
plain artwork without WebGL2, with reduced motion, or with visuals set to simple.
|
||||
- `visual.ts`: runs both per frame, plus `--energy` (kick-driven) for the CSS glow.
|
||||
- `station.ts`: the radio sheet.
|
||||
- `color.ts`: each card's accent colour (`--card-accent`) from the artist image.
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@
|
||||
<dt>m</dt><dd>mute</dd>
|
||||
<dt>?</dt><dd>this help</dd>
|
||||
</dl>
|
||||
<p>Lock-screen and headphone buttons work too.</p>
|
||||
<p>Drag the liquid artwork sideways to spin it. Lock-screen and headphone buttons work too.</p>
|
||||
<p class="pointer-only fineprint">Keys like r or x doing something else, such as reloading or
|
||||
closing the tab? A vim-keys browser extension (Vimium and the like) takes them first:
|
||||
turn it off for this site.</p>
|
||||
|
||||
+22
-7
@@ -5,6 +5,7 @@
|
||||
import type { Features } from "../features";
|
||||
import { icosphere } from "./mesh";
|
||||
import { FRAGMENT, VERTEX } from "./shaders";
|
||||
import { Spin } from "./spin";
|
||||
import type { Look } from "./style";
|
||||
|
||||
const FOV = (30 * Math.PI) / 180;
|
||||
@@ -22,6 +23,7 @@ const PUMP = 0.7; // size kick of a snare hit
|
||||
const PUMP_STIFF = 160;
|
||||
const PUMP_DAMPING = 12;
|
||||
const THROB = 0.09; // size swing with the bass line's envelope
|
||||
const WHIRL = 15; // rad/s of hand spin at which it bulges and sloshes the most
|
||||
const SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop
|
||||
const SNARE_RELEASE = 0.15; // s
|
||||
const TEXTURE_SIZE = 512;
|
||||
@@ -32,7 +34,7 @@ const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
|
||||
|
||||
const UNIFORMS = [
|
||||
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl",
|
||||
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer",
|
||||
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront",
|
||||
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
|
||||
] as const;
|
||||
|
||||
@@ -40,6 +42,8 @@ export class LiquidBlob {
|
||||
readonly canvas = document.createElement("canvas");
|
||||
/// Called once if the GPU drops the context; the page then falls back to plain art.
|
||||
onLost: (() => void) | null = null;
|
||||
/// Turning it by hand.
|
||||
readonly spin = new Spin();
|
||||
private gl: WebGL2RenderingContext;
|
||||
private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
|
||||
private vao: WebGLVertexArrayObject;
|
||||
@@ -100,6 +104,9 @@ export class LiquidBlob {
|
||||
gl.uniformMatrix4fv(this.u.uProj, false, perspective(FOV, 1, 0.1, 20));
|
||||
gl.uniform1f(this.u.uDist, DIST);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
// The edge fades into the page; the canvas is premultiplied.
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
@@ -138,6 +145,7 @@ export class LiquidBlob {
|
||||
this.ready = false;
|
||||
this.morph = 0;
|
||||
this.slow = 0;
|
||||
this.spin.reset();
|
||||
const source = await artwork(art, initial).catch(() => artwork(null, initial));
|
||||
if (gen !== this.gen || this.gl.isContextLost()) return false;
|
||||
const gl = this.gl;
|
||||
@@ -162,7 +170,7 @@ export class LiquidBlob {
|
||||
}
|
||||
|
||||
/// Draw one frame. Returns true while it still moves without music (morphing in,
|
||||
/// settling, following the pointer).
|
||||
/// settling, spinning, following the pointer).
|
||||
frame(f: Features, dt: number, playing: boolean): boolean {
|
||||
const look = this.look;
|
||||
if (!look || !this.ready) return false;
|
||||
@@ -183,6 +191,7 @@ export class LiquidBlob {
|
||||
this.time += dt * look.flow * (playing ? 0.6 + 0.8 * f.wobble + 0.6 * f.drop : 0.25);
|
||||
const k = 1 - Math.exp(-dt / 0.15);
|
||||
for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k;
|
||||
const spinning = this.spin.step(dt);
|
||||
const moving = this.bounce(f, dt, look);
|
||||
|
||||
const u = this.u;
|
||||
@@ -208,12 +217,16 @@ export class LiquidBlob {
|
||||
gl.uniform1f(u.uHeat, look.heat);
|
||||
gl.uniform1f(u.uHue, look.hue);
|
||||
gl.uniform3fv(u.uAccent, this.accent);
|
||||
gl.uniform4fv(u.uPointer, this.pointer);
|
||||
// The pointer and the front are in view space; the noise turns with the drop.
|
||||
const [px, py, pz, pw] = this.pointer;
|
||||
gl.uniform4f(u.uPointer, ...this.spin.toObject(px, py, pz), pw);
|
||||
gl.uniform3f(u.uFront, ...this.spin.toObject(0, 0, 1));
|
||||
gl.uniformMatrix3fv(u.uRot, false, this.spin.matrix);
|
||||
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0);
|
||||
return this.morph < 1 || moving || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
|
||||
return this.morph < 1 || moving || spinning || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
|
||||
}
|
||||
|
||||
/// Step the springs and set the jump uniforms. True while they still move.
|
||||
@@ -236,11 +249,13 @@ export class LiquidBlob {
|
||||
const stretch = 1 + Math.min(0.18, Math.max(-0.15, jump[1] * STRETCH + Math.min(0, jump[0]) * 1.2));
|
||||
// Each "womb" of the bass line swells the whole drop.
|
||||
const size = 1 + pump[0] + THROB * look.throb * f.bass;
|
||||
const side = size / Math.sqrt(stretch);
|
||||
// Spun fast it flattens out a little, like dough, and sloshes.
|
||||
const whirl = Math.min(1, this.spin.speed / WHIRL);
|
||||
const side = (size / Math.sqrt(stretch)) * (1 + 0.08 * whirl);
|
||||
const u = this.u;
|
||||
this.gl.uniform3f(u.uScale, side, size * stretch, side);
|
||||
this.gl.uniform3f(u.uScale, side, size * stretch * (1 - 0.06 * whirl), side);
|
||||
this.gl.uniform1f(u.uLift, Math.max(0, jump[0]));
|
||||
this.gl.uniform1f(u.uJelly, Math.min(1, Math.abs(jump[1]) * 0.4 + Math.abs(pump[1]) * 0.6));
|
||||
this.gl.uniform1f(u.uJelly, Math.min(1, Math.abs(jump[1]) * 0.4 + Math.abs(pump[1]) * 0.6 + 0.5 * whirl));
|
||||
return Math.abs(jump[0]) + Math.abs(jump[1]) + Math.abs(pump[1]) > 0.002;
|
||||
}
|
||||
|
||||
|
||||
+26
-12
@@ -1,6 +1,6 @@
|
||||
// GLSL for the liquid artwork drop. The vertex shader moves a unit sphere with
|
||||
// noise driven by the music; the fragment shader shows the artwork through the
|
||||
// surface, with a rim in the card's accent colour.
|
||||
// noise driven by the music and turns it as spin.ts says; the fragment shader shows
|
||||
// the artwork through the surface, glossy with the card's accent colour.
|
||||
|
||||
// 3D simplex noise by Ian McEwan and Stefan Gustavson (Ashima Arts), MIT licence:
|
||||
// https://github.com/ashima/webgl-noise
|
||||
@@ -72,10 +72,12 @@ uniform vec3 uScale; // squash and stretch
|
||||
uniform float uLift; // upwards
|
||||
uniform float uJelly; // extra wobble while a jump rings out, 0..1
|
||||
uniform vec4 uPointer; // direction on the sphere, strength
|
||||
uniform mat3 uRot; // turned by hand, object to view space
|
||||
uniform vec3 uFront; // towards the viewer, in object space
|
||||
|
||||
out vec3 vPos; // view space
|
||||
out vec3 vNormal; // view space
|
||||
out vec2 vFlat; // front projection of the resting sphere, for the artwork
|
||||
out vec3 vObj; // the resting sphere; its front projection carries the artwork
|
||||
|
||||
${NOISE}
|
||||
|
||||
@@ -98,7 +100,7 @@ float disp(vec3 p) {
|
||||
float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9));
|
||||
float spikes = pow(max(n, 0.0), 3.0) * (0.05 + 0.25 * uSnare + 0.15 * uGrowl * uBass + 0.1 * uDrop) * uSpike;
|
||||
// After each kick a wave runs from the front of the drop round to the back.
|
||||
float angle = acos(clamp(p.z, -1.0, 1.0));
|
||||
float angle = acos(clamp(dot(p, uFront), -1.0, 1.0));
|
||||
float wave = exp(-pow((angle - uKickAge * 5.0) * 1.8, 2.0)) * exp(-uKickAge * 2.8) * 0.09 * uShock;
|
||||
float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0));
|
||||
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4);
|
||||
@@ -119,9 +121,9 @@ void main() {
|
||||
vec3 s = shape(p);
|
||||
vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s));
|
||||
if (dot(n, p) < 0.0) n = -n;
|
||||
vPos = s * uScale + vec3(0.0, uLift, -uDist);
|
||||
vNormal = normalize(n / uScale);
|
||||
vFlat = p.xy;
|
||||
vPos = uRot * s * uScale + vec3(0.0, uLift, -uDist);
|
||||
vNormal = normalize(uRot * n / uScale);
|
||||
vObj = p;
|
||||
gl_Position = uProj * vec4(vPos, 1.0);
|
||||
}
|
||||
`;
|
||||
@@ -131,7 +133,7 @@ precision highp float;
|
||||
|
||||
in vec3 vPos;
|
||||
in vec3 vNormal;
|
||||
in vec2 vFlat;
|
||||
in vec3 vObj;
|
||||
|
||||
uniform sampler2D uArt;
|
||||
uniform vec3 uAccent;
|
||||
@@ -155,19 +157,31 @@ void main() {
|
||||
float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0);
|
||||
// Looking through the surface bends the artwork like water does.
|
||||
vec2 bend = n.xy * (0.22 + 0.1 * uDrop) * uMorph;
|
||||
vec2 uv = 0.5 + vec2(vFlat.x, -vFlat.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5;
|
||||
vec2 uv = 0.5 + vec2(vObj.x, -vObj.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5;
|
||||
vec2 split = n.xy * (0.004 + 0.02 * uKick + 0.008 * uGrowl * uBass) * uMorph;
|
||||
vec3 art = vec3(texture(uArt, uv + split).r, texture(uArt, uv).g, texture(uArt, uv - split).b);
|
||||
// Turned sideways the projection smears; blur it into flowing colour instead.
|
||||
float blur = 4.0 * pow(1.0 - abs(vObj.z), 3.0) * uMorph;
|
||||
vec3 art = vec3(texture(uArt, uv + split, blur).r, texture(uArt, uv, blur).g, texture(uArt, uv - split, blur).b);
|
||||
art = mix(art, smoothstep(0.0, 1.0, art), 0.6 * uDrop);
|
||||
|
||||
vec3 light = normalize(vec3(-0.5, 0.7, 0.6));
|
||||
float spec = pow(max(dot(n, normalize(light + v)), 0.0), mix(40.0, 140.0, uMetal)) * mix(0.35, 0.8, uMetal);
|
||||
vec3 rim = hueRotate(uAccent, uHue + uIri * (fres * 3.0 + uTime * 0.3));
|
||||
// The hue sways around the accent but never leaves it, so the drop stays in the
|
||||
// card's colours.
|
||||
vec3 rim = hueRotate(uAccent, uHue + uIri * (fres + 0.5 * sin(uTime * 0.3)));
|
||||
rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6);
|
||||
|
||||
vec3 col = art * mix(1.0, 0.45, fres * uMorph);
|
||||
col += rim * fres * (0.5 + 0.7 * uKick + 0.4 * uDrop + 0.5 * uBass) * uMorph;
|
||||
col += spec * uMorph;
|
||||
outColor = vec4(col, 1.0);
|
||||
// Gloss: a dark room lit by the accent from above, and one soft window. They stay
|
||||
// put while the surface turns under them, which is what makes a spin read.
|
||||
vec3 r = reflect(-v, n);
|
||||
vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0));
|
||||
env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5))));
|
||||
col += env * (0.1 + 0.5 * fres) * uMorph;
|
||||
// The very edge fades out, so the drop sits in the page instead of on it.
|
||||
float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph;
|
||||
outColor = vec4(col * a, a);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,113 @@
|
||||
// Turning the drop by hand. While held it follows the finger; let go, it keeps the
|
||||
// speed of the flick, slows down, and a weak spring wobbles it back to face the
|
||||
// front. A hard flick carries it over the top of the spring a few times first.
|
||||
|
||||
const RETURN = 7; // 1/s², spring towards facing the front
|
||||
const FRICTION = 1.6; // 1/s
|
||||
const MAX_SPEED = 30; // rad/s
|
||||
const STALE = 60; // ms; a finger that rested this long before lifting does not fling
|
||||
|
||||
type Quat = [number, number, number, number]; // x, y, z, w
|
||||
|
||||
export class Spin {
|
||||
/// Angular speed, rad/s, as a rotation vector in view space.
|
||||
private vel = [0, 0, 0];
|
||||
private q: Quat = [0, 0, 0, 1];
|
||||
private held = false;
|
||||
private turnedAt = 0;
|
||||
/// Column-major, object to view space; kept in step with `q`.
|
||||
readonly matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
|
||||
|
||||
get speed(): number {
|
||||
return Math.hypot(...this.vel);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.q = [0, 0, 0, 1];
|
||||
this.vel = [0, 0, 0];
|
||||
this.held = false;
|
||||
this.toMatrix();
|
||||
}
|
||||
|
||||
hold(on: boolean): void {
|
||||
this.held = on;
|
||||
if (on || performance.now() - this.turnedAt > STALE) this.vel = [0, 0, 0];
|
||||
}
|
||||
|
||||
/// The finger moved by `dx`, `dy` (radii of the drop, y up) at `at` (ms, as
|
||||
/// `performance.now()`): the surface under it moves along.
|
||||
turn(dx: number, dy: number, at: number): void {
|
||||
const a = [-dy, dx, 0];
|
||||
this.q = normalize(mul(fromVector(a), this.q));
|
||||
const dt = (at - this.turnedAt) / 1000;
|
||||
this.turnedAt = at;
|
||||
if (dt > 0 && dt < 0.1) {
|
||||
for (let i = 0; i < 3; i++) this.vel[i] += (a[i] / Math.max(dt, 0.004) - this.vel[i]) * 0.6;
|
||||
const s = this.speed;
|
||||
if (s > MAX_SPEED) this.vel = this.vel.map((v) => (v * MAX_SPEED) / s);
|
||||
}
|
||||
this.toMatrix();
|
||||
}
|
||||
|
||||
/// Advance by `dt` seconds. True while it still moves.
|
||||
step(dt: number): boolean {
|
||||
if (this.held) return true;
|
||||
if (this.speed < 0.03 && Math.hypot(...toVector(this.q)) < 0.005) {
|
||||
if (this.q[3] !== 1) this.reset();
|
||||
return false;
|
||||
}
|
||||
// Small fixed steps keep the spring stable on slow frames.
|
||||
for (let left = Math.min(dt, 0.1); left > 0; left -= 1 / 240) {
|
||||
const h = Math.min(left, 1 / 240);
|
||||
const off = toVector(this.q);
|
||||
for (let i = 0; i < 3; i++) this.vel[i] += (-RETURN * off[i] - FRICTION * this.vel[i]) * h;
|
||||
this.q = normalize(mul(fromVector(this.vel.map((v) => v * h)), this.q));
|
||||
}
|
||||
this.toMatrix();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// A view-space direction in object space.
|
||||
toObject(x: number, y: number, z: number): [number, number, number] {
|
||||
const m = this.matrix;
|
||||
return [m[0] * x + m[1] * y + m[2] * z, m[3] * x + m[4] * y + m[5] * z, m[6] * x + m[7] * y + m[8] * z];
|
||||
}
|
||||
|
||||
private toMatrix(): void {
|
||||
const [x, y, z, w] = this.q;
|
||||
this.matrix.set([
|
||||
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w),
|
||||
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w),
|
||||
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function mul(a: Quat, b: Quat): Quat {
|
||||
return [
|
||||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||
];
|
||||
}
|
||||
|
||||
function normalize(q: Quat): Quat {
|
||||
const l = Math.hypot(...q);
|
||||
return [q[0] / l, q[1] / l, q[2] / l, q[3] / l];
|
||||
}
|
||||
|
||||
/// Rotation by `|v|` radians around `v`.
|
||||
function fromVector(v: number[]): Quat {
|
||||
const a = Math.hypot(v[0], v[1], v[2]);
|
||||
const s = a < 1e-8 ? 0.5 : Math.sin(a / 2) / a;
|
||||
return [v[0] * s, v[1] * s, v[2] * s, Math.cos(a / 2)];
|
||||
}
|
||||
|
||||
/// The shortest rotation vector to `q`, so the spring always takes the short way.
|
||||
function toVector(q: Quat): number[] {
|
||||
const [x, y, z, w] = q[3] < 0 ? q.map((c) => -c) : q;
|
||||
const s = Math.sqrt(Math.max(0, 1 - w * w));
|
||||
const k = s < 1e-6 ? 2 : (2 * Math.acos(Math.min(1, w))) / s;
|
||||
return [x * k, y * k, z * k];
|
||||
}
|
||||
+15
-1
@@ -262,8 +262,22 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
height: 200%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; }
|
||||
/* The halo stays behind the drop and pulses with it; the text sits on top where
|
||||
the drop spills over it. */
|
||||
.card.liquid .art {
|
||||
z-index: 0;
|
||||
background-image: none !important;
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 calc(30px + var(--energy) * 80px) color-mix(in oklab, var(--card-accent) calc(18% + var(--energy) * 40%), transparent);
|
||||
scale: 1;
|
||||
}
|
||||
.card.liquid .content > :not(.art) { position: relative; z-index: 1; }
|
||||
.card.liquid .title { text-shadow: 0 2px 16px #000b; }
|
||||
.card.liquid .initial { display: none; }
|
||||
@media (pointer: fine) {
|
||||
.card.liquid .art { cursor: grab; }
|
||||
.card.turning, .card.turning .art { cursor: grabbing; }
|
||||
}
|
||||
.paused-badge, .spinner { z-index: 1; }
|
||||
|
||||
.audio-debug {
|
||||
|
||||
+85
-8
@@ -15,6 +15,8 @@ import type { Player } from "./player";
|
||||
|
||||
export type VisualsSetting = "liquid" | "simple";
|
||||
const VISUALS_KEY = "pl8inf.visuals";
|
||||
const SLOP = 8; // px before a press on the drop turns it
|
||||
const REACH = 1.2; // how far past the art's radius the drop can be grabbed
|
||||
|
||||
export class Visualizer {
|
||||
private raf = 0;
|
||||
@@ -26,6 +28,9 @@ export class Visualizer {
|
||||
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid";
|
||||
private debug: ((f: Features) => void) | null = null;
|
||||
/// A press on the drop; `on` once it moved enough to turn it.
|
||||
private grab: { id: number; x: number; y: number; on: boolean } | null = null;
|
||||
private spun = false;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
@@ -33,8 +38,17 @@ export class Visualizer {
|
||||
) {
|
||||
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()));
|
||||
this.reduced.addEventListener("change", () => this.attach(this.card, true));
|
||||
for (const ev of ["pointermove", "pointerdown"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, true), { passive: true });
|
||||
for (const ev of ["pointerup", "pointercancel", "pointerleave"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, false), { passive: true });
|
||||
// Capturing, so a turn can keep its moves from the pager.
|
||||
const on = { capture: true, passive: true };
|
||||
document.addEventListener("pointerdown", (e) => this.down(e), on);
|
||||
document.addEventListener("pointermove", (e) => this.move(e), on);
|
||||
for (const ev of ["pointerup", "pointercancel"]) document.addEventListener(ev, (e) => this.up(e as PointerEvent), on);
|
||||
document.addEventListener("pointerleave", (e) => this.pointer(e, false), { passive: true });
|
||||
// A turn is not a tap: no play/pause after it.
|
||||
document.addEventListener("click", (e) => {
|
||||
if (this.spun) e.stopPropagation();
|
||||
this.spun = false;
|
||||
}, true);
|
||||
if (new URLSearchParams(location.search).get("debug") === "audio") {
|
||||
import("./debug").then((m) => { this.debug = m.audioDebug(); });
|
||||
}
|
||||
@@ -71,7 +85,8 @@ export class Visualizer {
|
||||
if (this.card === card && !again) return;
|
||||
if (this.card && this.card !== card) {
|
||||
this.card.el?.style.setProperty("--energy", "0");
|
||||
this.card.el?.classList.remove("liquid");
|
||||
this.card.el?.classList.remove("liquid", "turning");
|
||||
this.grab = null;
|
||||
}
|
||||
this.card = card;
|
||||
const blob = this.liquid();
|
||||
@@ -105,16 +120,78 @@ export class Visualizer {
|
||||
this.raf = 0;
|
||||
}
|
||||
|
||||
/// The drop on screen: its centre and the art's radius, in px. Null while it
|
||||
/// does not show.
|
||||
private where(): { x: number; y: number; radius: number } | null {
|
||||
const blob = this.blob;
|
||||
if (!blob?.canvas.isConnected || !this.card?.el?.classList.contains("liquid")) return null;
|
||||
const r = blob.canvas.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / SPILL / 2 };
|
||||
}
|
||||
|
||||
/// Pointer over the drop pushes a dent into it.
|
||||
private pointer(e: PointerEvent, on: boolean): void {
|
||||
const blob = this.blob;
|
||||
if (!blob?.canvas.isConnected) return;
|
||||
const r = blob.canvas.getBoundingClientRect();
|
||||
const radius = r.width / SPILL / 2;
|
||||
blob.setPointer((e.clientX - r.left - r.width / 2) / radius, -(e.clientY - r.top - r.height / 2) / radius, on);
|
||||
const at = this.where();
|
||||
if (!at) return;
|
||||
this.blob!.setPointer((e.clientX - at.x) / at.radius, -(e.clientY - at.y) / at.radius, on);
|
||||
if (on) this.start();
|
||||
}
|
||||
|
||||
// Dragging the drop turns it. On touch only a sideways drag does, as up and
|
||||
// down turn the page; a mouse can turn it any way.
|
||||
|
||||
private down(e: PointerEvent): void {
|
||||
this.pointer(e, true);
|
||||
const at = this.where();
|
||||
if (this.grab || !at || (e.pointerType === "mouse" && e.button !== 0)) return;
|
||||
if ((e.target as Element).closest("button, a, input, .wave-wrap")) return;
|
||||
if (Math.hypot(e.clientX - at.x, e.clientY - at.y) > at.radius * REACH) return;
|
||||
this.grab = { id: e.pointerId, x: e.clientX, y: e.clientY, on: false };
|
||||
}
|
||||
|
||||
private move(e: PointerEvent): void {
|
||||
this.pointer(e, true);
|
||||
const g = this.grab;
|
||||
const at = this.where();
|
||||
if (!g || e.pointerId !== g.id) return;
|
||||
if (!at) {
|
||||
this.grab = null;
|
||||
return;
|
||||
}
|
||||
const dx = e.clientX - g.x, dy = e.clientY - g.y;
|
||||
if (!g.on) {
|
||||
if (Math.hypot(dx, dy) < SLOP) return;
|
||||
if (e.pointerType !== "mouse" && Math.abs(dy) >= Math.abs(dx)) {
|
||||
this.grab = null;
|
||||
return;
|
||||
}
|
||||
g.on = true;
|
||||
// Keep the moves when the mouse leaves the window.
|
||||
document.documentElement.setPointerCapture(e.pointerId);
|
||||
this.blob!.spin.hold(true);
|
||||
this.card?.el?.classList.add("turning");
|
||||
}
|
||||
e.stopPropagation();
|
||||
this.blob!.spin.turn(dx / at.radius, -dy / at.radius, performance.now());
|
||||
g.x = e.clientX;
|
||||
g.y = e.clientY;
|
||||
this.start();
|
||||
}
|
||||
|
||||
private up(e: PointerEvent): void {
|
||||
this.pointer(e, false);
|
||||
const g = this.grab;
|
||||
if (!g || e.pointerId !== g.id) return;
|
||||
this.grab = null;
|
||||
if (!g.on) return;
|
||||
this.blob?.spin.hold(false);
|
||||
this.card?.el?.classList.remove("turning");
|
||||
this.spun = e.type === "pointerup";
|
||||
// The click, if any, comes right after.
|
||||
setTimeout(() => (this.spun = false), 350);
|
||||
this.start();
|
||||
}
|
||||
|
||||
private frame = (now: number) => {
|
||||
this.raf = 0;
|
||||
const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 1 / 60;
|
||||
|
||||
Reference in new issue
Block a user