Public Access
feat(effects): polish 8 effects and add 7 new engines with parallax
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:
1 parent
d7810fbf94
commit
ed5df8c40d
21 files changed
+1510
-211
No files matched your search
@@ -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};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user