feat(effects): polish 8 effects and add 7 new engines with parallax
Build, Test & Push Frontend / quality-check (push) Successful in 32s
Build, Test & Push Frontend / docker (push) Successful in 26s

Synthwave, fireflies, snow, sakura, nebula: CSS/SVG. Matrix and
constellation: canvas. Existing engines gain depth, color, glow, and
parallax (mouse and device tilt, opt-in on iOS)
This commit is contained in:
lobo committed 2026-10-03 17:31:19 +02:00
1 parent d7810fbf94
commit ed5df8c40d
21 files changed
+1510 -211

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
import {LOGO_COLORS} from '../components/Logo.jsx';
// The drawers of the canvas page and banner backgrounds (components/EffectCanvas.jsx). Plain objects with no canvas
// or DOM, so the logic is testable; sizes are css px. `create` fills the first frame, which is also the static picture.
/** mulberry32: the same seed gives the same sequence, so the static picture is the same on every visit. */
export const seededRandom = (seed) => () => {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
/** Colors of the element's own skin and accent (a public profile sets them on a wrapper), logo hex as fallback. */
export function readPalette(element) {
const style = getComputedStyle(element);
const read = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
return {
primary: read('--primary', LOGO_COLORS[0][1]),
foreground: read('--foreground', '#fafafa'),
logo: LOGO_COLORS.map(([name, hex]) => read(name, hex)),
};
}
// Half-width katakana and digits.
const GLYPHS = Array.from({length: 56}, (_, i) => (i < 46 ? String.fromCharCode(0xFF66 + i) : String(i - 46)));
const TRAIL = 14;
const glyph = (random) => GLYPHS[Math.floor(random() * GLYPHS.length)];
const matrix = {
fps: 20,
create(width, height, random) {
const gap = height < 360 ? 12 : 16;
const rows = Math.ceil(height / gap);
const columns = Array.from({length: Math.floor(width / gap)}, () => ({
head: random() * (rows + TRAIL),
speed: 6 + random() * 8,
glyphs: Array.from({length: rows}, () => glyph(random)),
}));
return {gap, rows, columns};
},
step(state, seconds, random) {
for (const column of state.columns) {
column.head += column.speed * seconds;
if (column.head - TRAIL > state.rows) column.head = -random() * 8;
for (let n = 0; n < 2 + Math.floor(random() * 2); n++) {
column.glyphs[Math.floor(random() * state.rows)] = glyph(random);
}
}
},
draw(context, state, palette) {
const {gap, rows, columns} = state;
context.clearRect(0, 0, columns.length * gap + gap, rows * gap + gap);
context.font = `${gap}px ui-monospace, monospace`;
context.textBaseline = 'top';
columns.forEach((column, index) => {
const head = Math.floor(column.head);
const trail = index % 6 === 5 ? palette.logo[2] : palette.primary;
for (let k = 0; k < TRAIL; k++) {
const row = head - k;
if (row < 0 || row >= rows) continue;
context.globalAlpha = k === 0 ? 0.9 : (1 - k / TRAIL) * 0.55;
context.fillStyle = k === 0 ? palette.foreground : trail;
context.fillText(column.glyphs[row], index * gap, row * gap);
}
});
context.globalAlpha = 1;
},
};
const LINK = 110;
const TIERS = [{r: 1, speed: 6, alpha: 0.35}, {r: 1.6, speed: 12, alpha: 0.6}, {r: 2.4, speed: 20, alpha: 0.9}];
const constellation = {
fps: 30,
create(width, height, random) {
const count = Math.min(80, Math.max(18, Math.round((width * height) / 16000)));
const points = Array.from({length: count}, (_, i) => {
const tier = TIERS[i % 3];
const angle = random() * Math.PI * 2;
return {
x: random() * width, y: random() * height,
vx: Math.cos(angle) * tier.speed, vy: Math.sin(angle) * tier.speed,
tier, color: i % 4, twinkle: i % 5 === 4, phase: random() * Math.PI * 2,
};
});
return {width, height, points, t: 0};
},
step(state, seconds) {
state.t += seconds;
for (const p of state.points) {
p.x = (p.x + p.vx * seconds + state.width) % state.width;
p.y = (p.y + p.vy * seconds + state.height) % state.height;
}
},
draw(context, state, palette) {
const {points, width, height, t} = state;
context.clearRect(0, 0, width, height);
context.strokeStyle = palette.primary;
context.lineWidth = 1;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const d = Math.hypot(points[i].x - points[j].x, points[i].y - points[j].y);
if (d >= LINK) continue;
context.globalAlpha = (1 - d / LINK) * 0.35;
context.beginPath();
context.moveTo(points[i].x, points[i].y);
context.lineTo(points[j].x, points[j].y);
context.stroke();
}
}
const colors = [palette.logo[0], palette.logo[1], palette.logo[2], palette.primary];
for (const p of points) {
const alpha = p.tier.alpha * (p.twinkle ? 0.6 + 0.4 * Math.sin(t * 2 + p.phase) : 1);
context.fillStyle = colors[p.color];
if (p.tier.r > 2) {
context.globalAlpha = 0.15;
context.beginPath();
context.arc(p.x, p.y, p.tier.r * 3, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = alpha;
context.beginPath();
context.arc(p.x, p.y, p.tier.r, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
},
};
export const CANVAS_EFFECTS = {matrix, constellation};
+85
View File
@@ -0,0 +1,85 @@
import {describe, expect, it, vi} from 'vitest';
import {CANVAS_EFFECTS, readPalette, seededRandom} from './canvasEffects.js';
const fakeContext = () => new Proxy({}, {get: (target, name) => (target[name] ??= vi.fn()), set: () => true});
const palette = {primary: '#111', foreground: '#eee', logo: ['#1', '#2', '#3']};
describe('seededRandom', () => {
it('gives the same sequence for the same seed', () => {
const take = (random) => Array.from({length: 5}, random);
expect(take(seededRandom(7))).toEqual(take(seededRandom(7)));
expect(take(seededRandom(7))).not.toEqual(take(seededRandom(8)));
});
});
describe('matrix', () => {
const {matrix} = CANVAS_EFFECTS;
it('has a column every 16 px with its head inside the rain', () => {
const state = matrix.create(320, 400, seededRandom(7));
expect(state.columns).toHaveLength(Math.floor(320 / 16));
for (const column of state.columns) {
expect(column.head).toBeGreaterThanOrEqual(0);
expect(column.head).toBeLessThanOrEqual(state.rows + 14);
}
});
it('draws glyphs with fillText and keeps its heads in range while it steps', () => {
const random = seededRandom(7);
const state = matrix.create(320, 400, random);
for (let i = 0; i < 500; i++) matrix.step(state, 0.1, random);
const context = fakeContext();
matrix.draw(context, state, palette);
expect(context.fillText).toHaveBeenCalled();
for (const column of state.columns) expect(column.head).toBeLessThanOrEqual(state.rows + 14 + 1);
});
});
describe('constellation', () => {
const {constellation} = CANVAS_EFFECTS;
it('scales the point count with the area between 18 and 80', () => {
expect(constellation.create(320, 200, seededRandom(7)).points).toHaveLength(18);
expect(constellation.create(4000, 3000, seededRandom(7)).points).toHaveLength(80);
});
it('keeps every point inside the box and draws arcs and lines', () => {
const random = seededRandom(7);
const state = constellation.create(320, 200, random);
for (let i = 0; i < 500; i++) constellation.step(state, 0.1, random);
for (const p of state.points) {
expect(p.x).toBeGreaterThanOrEqual(0);
expect(p.x).toBeLessThanOrEqual(320);
expect(p.y).toBeGreaterThanOrEqual(0);
expect(p.y).toBeLessThanOrEqual(200);
}
const context = fakeContext();
constellation.draw(context, state, palette);
expect(context.arc).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalled();
});
});
describe('readPalette', () => {
it('falls back to the logo hex and the default colors when the variables are empty', () => {
vi.stubGlobal('getComputedStyle', () => ({getPropertyValue: () => ''}));
const result = readPalette({});
expect(result.logo).toEqual(['#7467f9', '#0192ef', '#17c7a4']);
expect(result.primary).toBe('#7467f9');
expect(result.foreground).toBe('#fafafa');
vi.unstubAllGlobals();
});
it('reads the element variables when they are set', () => {
vi.stubGlobal('getComputedStyle', () => ({getPropertyValue: (name) => (name === '--primary' ? ' #abc ' : '')}));
expect(readPalette({}).primary).toBe('#abc');
vi.unstubAllGlobals();
});
});
+99
View File
@@ -0,0 +1,99 @@
import {storage} from './storage.js';
// Writes --px/--py (-1..1) from the mouse or the phone tilt on each effect root; effects.css moves the layers.
export const TILT_STORAGE_KEY = 'tbp_tilt';
/** iOS and iPadOS Safari ask for a permission before they send orientation events. */
export const tiltNeedsPermission = () => typeof window.DeviceOrientationEvent?.requestPermission === 'function';
export const tiltAllowed = () => !tiltNeedsPermission() || storage.get(TILT_STORAGE_KEY) === 'true';
const roots = new Set();
const target = {x: 0, y: 0};
const current = {x: 0, y: 0};
let frame = 0;
let tiltListening = false;
const clamp = (value) => Math.max(-1, Math.min(1, value));
function onPointer(event) {
if (event.pointerType !== 'mouse') return;
target.x = (event.clientX / window.innerWidth) * 2 - 1;
target.y = (event.clientY / window.innerHeight) * 2 - 1;
schedule();
}
function onTilt(event) {
if (event.gamma == null || event.beta == null) return;
let x = event.gamma / 30;
let y = (event.beta - 45) / 30;
const angle = window.screen?.orientation?.angle;
if (angle === 90) [x, y] = [y, -x];
else if (angle === 270) [x, y] = [-y, x];
target.x = clamp(x);
target.y = clamp(y);
schedule();
}
function tick() {
frame = 0;
current.x += (target.x - current.x) * 0.12;
current.y += (target.y - current.y) * 0.12;
for (const root of roots) {
root.style.setProperty('--px', current.x.toFixed(3));
root.style.setProperty('--py', current.y.toFixed(3));
}
if (Math.abs(target.x - current.x) >= 0.002 || Math.abs(target.y - current.y) >= 0.002) schedule();
}
function schedule() {
if (!frame && roots.size) frame = requestAnimationFrame(tick);
}
function listenTilt() {
if (tiltListening || !roots.size) return;
tiltListening = true;
window.addEventListener('deviceorientation', onTilt, {passive: true});
}
function stopTilt() {
if (!tiltListening) return;
tiltListening = false;
window.removeEventListener('deviceorientation', onTilt);
}
/** Call it inside a tap (iOS). Stores the answer and resolves to whether tilt is granted. */
export async function requestTilt() {
let answer;
try {
answer = await window.DeviceOrientationEvent.requestPermission();
} catch {
answer = 'denied';
}
const granted = answer === 'granted';
storage.set(TILT_STORAGE_KEY, String(granted));
if (granted) listenTilt();
return granted;
}
export function disableTilt() {
storage.set(TILT_STORAGE_KEY, 'false');
stopTilt();
}
/** Starts moving the layers of `element`; returns the stop function. */
export function watchParallax(element) {
roots.add(element);
if (roots.size === 1) window.addEventListener('pointermove', onPointer, {passive: true});
if (tiltAllowed()) listenTilt();
return () => {
roots.delete(element);
element.style.removeProperty('--px');
element.style.removeProperty('--py');
if (roots.size) return;
window.removeEventListener('pointermove', onPointer);
stopTilt();
cancelAnimationFrame(frame);
frame = 0;
target.x = target.y = current.x = current.y = 0;
};
}
+137
View File
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {disableTilt, requestTilt, TILT_STORAGE_KEY, tiltAllowed, watchParallax} from './parallax.js';
const listeners = (spy, type) => spy.mock.calls.filter(([name]) => name === type);
const pointer = (pointerType, clientX, clientY) => {
const event = new Event('pointermove');
Object.assign(event, {pointerType, clientX, clientY});
window.dispatchEvent(event);
};
const tilt = (gamma, beta) => {
const event = new Event('deviceorientation');
Object.assign(event, {gamma, beta});
window.dispatchEvent(event);
};
const makeRoot = () => document.createElement('div');
let add;
let remove;
let stops;
beforeEach(() => {
localStorage.clear();
vi.stubGlobal('requestAnimationFrame', (callback) => { callback(); return 1; });
vi.stubGlobal('cancelAnimationFrame', vi.fn());
add = vi.spyOn(window, 'addEventListener');
remove = vi.spyOn(window, 'removeEventListener');
stops = [];
});
afterEach(() => {
stops.forEach((stop) => stop());
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete window.DeviceOrientationEvent;
});
const watch = (root) => { const stop = watchParallax(root); stops.push(stop); return stop; };
describe('parallax', () => {
it('attaches one pointermove listener for two roots', () => {
watch(makeRoot());
watch(makeRoot());
expect(listeners(add, 'pointermove')).toHaveLength(1);
});
it('writes --px and --py on every root for a mouse and ignores touch', () => {
const a = makeRoot();
const b = makeRoot();
watch(a);
watch(b);
pointer('touch', 0, 0);
expect(a.style.getPropertyValue('--px')).toBe('');
pointer('mouse', window.innerWidth, 0);
expect(Number(a.style.getPropertyValue('--px'))).toBeGreaterThan(0);
expect(Number(a.style.getPropertyValue('--py'))).toBeLessThan(0);
expect(b.style.getPropertyValue('--px')).toBe(a.style.getPropertyValue('--px'));
});
it('removes the listener and the variables when the last root stops', () => {
const a = makeRoot();
const b = makeRoot();
const stopA = watch(a);
const stopB = watch(b);
pointer('mouse', window.innerWidth, window.innerHeight);
stopA();
expect(a.style.getPropertyValue('--px')).toBe('');
expect(listeners(remove, 'pointermove')).toHaveLength(0);
stopB();
expect(listeners(remove, 'pointermove')).toHaveLength(1);
});
it('listens for tilt at once where no permission is needed', () => {
watch(makeRoot());
expect(tiltAllowed()).toBe(true);
expect(listeners(add, 'deviceorientation')).toHaveLength(1);
});
it('drives both variables to 1, clamped, from a tilt, and ignores null angles', () => {
const root = makeRoot();
watch(root);
tilt(null, null);
expect(root.style.getPropertyValue('--px')).toBe('');
tilt(60, 120);
for (let i = 0; i < 60; i++) tilt(60, 120);
expect(Number(root.style.getPropertyValue('--px'))).toBeCloseTo(1, 1);
expect(Number(root.style.getPropertyValue('--py'))).toBeCloseTo(1, 1);
});
describe('where iOS asks for permission', () => {
const stubPermission = (answer) => {
window.DeviceOrientationEvent = {requestPermission: vi.fn().mockResolvedValue(answer)};
};
it('listens for tilt only after a granted request, and stores it', async () => {
stubPermission('granted');
watch(makeRoot());
expect(tiltAllowed()).toBe(false);
expect(listeners(add, 'deviceorientation')).toHaveLength(0);
await expect(requestTilt()).resolves.toBe(true);
expect(listeners(add, 'deviceorientation')).toHaveLength(1);
expect(localStorage.getItem(TILT_STORAGE_KEY)).toBe('true');
});
it('stays off when the request is denied or throws', async () => {
stubPermission('denied');
watch(makeRoot());
await expect(requestTilt()).resolves.toBe(false);
window.DeviceOrientationEvent = {requestPermission: vi.fn().mockRejectedValue(new Error('no'))};
await expect(requestTilt()).resolves.toBe(false);
expect(listeners(add, 'deviceorientation')).toHaveLength(0);
expect(localStorage.getItem(TILT_STORAGE_KEY)).toBe('false');
});
it('removes the tilt listener when it is switched off', async () => {
stubPermission('granted');
watch(makeRoot());
await requestTilt();
disableTilt();
expect(listeners(remove, 'deviceorientation')).toHaveLength(1);
expect(localStorage.getItem(TILT_STORAGE_KEY)).toBe('false');
});
});
});
+14
View File
@@ -32,6 +32,13 @@ export const LOOKS = {
BG_GRID: {effect: 'grid'},
BG_WAVES: {effect: 'waves'},
BG_BOKEH: {effect: 'bokeh'},
BG_SYNTHWAVE: {effect: 'synthwave'},
BG_FIREFLIES: {effect: 'fireflies'},
BG_SNOW: {effect: 'snow'},
BG_SAKURA: {effect: 'sakura'},
BG_NEBULA: {effect: 'nebula'},
BG_MATRIX: {effect: 'matrix'},
BG_CONSTELLATION: {effect: 'constellation'},
BN_TRIANGLES: {effect: 'triangles'},
BN_STARFIELD: {effect: 'starfield'},
BN_PIXELS: {effect: 'pixels'},
@@ -40,6 +47,13 @@ export const LOOKS = {
BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'},
BN_BOKEH: {effect: 'bokeh'},
BN_SYNTHWAVE: {effect: 'synthwave'},
BN_FIREFLIES: {effect: 'fireflies'},
BN_SNOW: {effect: 'snow'},
BN_SAKURA: {effect: 'sakura'},
BN_NEBULA: {effect: 'nebula'},
BN_MATRIX: {effect: 'matrix'},
BN_CONSTELLATION: {effect: 'constellation'},
// Unlock-only items (Core: earned by progress, never sold).
BADGE_FINISH_10: {Icon: Flag, className: 'bg-emerald-500/15 text-emerald-500'},
BORDER_FINISH_25: {className: 'ring-4 ring-slate-300 ring-offset-2 ring-offset-background'},
+14
View File
@@ -1,4 +1,5 @@
import {describe, expect, it} from 'vitest';
import {EFFECTS} from '../components/EffectBackground.jsx';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
describe('effects worn', () => {
@@ -33,3 +34,16 @@ describe('equippedSkin', () => {
expect(equippedSkin(undefined)).toBeNull();
});
});
describe('LOOKS backgrounds', () => {
it('gives every page background a banner with the same engine, except the unlock-only equalizer', () => {
for (const [code, look] of Object.entries(LOOKS)) {
if (code.startsWith('BG_')) expect(LOOKS[`BN_${code.slice(3)}`]?.effect, code).toBe(look.effect);
if (code.startsWith('BN_') && code !== 'BN_EQUALIZER') expect(LOOKS[`BG_${code.slice(3)}`], code).toBeDefined();
}
});
it('only names engines that EffectBackground renders', () => {
for (const look of Object.values(LOOKS)) if (look.effect) expect(EFFECTS).toContain(look.effect);
});
});