The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
/** The zoom transition (#309): the way between two views, the driver that shows it, and its setting. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
generalSettings,
|
|
keepGeneralSettings,
|
|
onGeneralSettings,
|
|
resetGeneralSettings,
|
|
zoomTransitionMs,
|
|
} from '../../src/editor/generalSettings';
|
|
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
|
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
|
|
import {
|
|
axisTween,
|
|
carriedSpeed,
|
|
easeOut,
|
|
glide,
|
|
glideSlope,
|
|
scrollTween,
|
|
tweenView,
|
|
type ViewMap,
|
|
ViewTween,
|
|
} from '../../src/editor/viewTween';
|
|
import { ScrollContainer, Spacer, VStack } from '../../src/ui';
|
|
|
|
function fakeClock() {
|
|
let time = 0;
|
|
const ticks = new Set<() => void>();
|
|
return {
|
|
now: () => time,
|
|
add: (fn: () => void) => void ticks.add(fn),
|
|
remove: (fn: () => void) => void ticks.delete(fn),
|
|
advance(ms: number) {
|
|
time += ms;
|
|
for (const fn of [...ticks]) fn();
|
|
},
|
|
get attached() {
|
|
return ticks.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A surface with a view and nothing else; `draws` counts what it was asked to draw. */
|
|
function surface(start: ViewMap = { scale: 1, x: 0, y: 0 }) {
|
|
const s = { view: { ...start }, draws: 0 };
|
|
const target = {
|
|
read: () => ({ ...s.view }),
|
|
write: (map: ViewMap) => void (s.view = { ...map }),
|
|
draw: () => void (s.draws += 1),
|
|
};
|
|
return { s, target };
|
|
}
|
|
|
|
/** The world point shown at a screen point. */
|
|
const worldAt = (view: ViewMap, x: number, y: number) => ({
|
|
x: (x - view.x) / view.scale,
|
|
y: (y - view.y) / view.scale,
|
|
});
|
|
|
|
describe('the way between two views', () => {
|
|
it('starts on the first and ends on the second', () => {
|
|
const from = { scale: 1, x: 10, y: 20 };
|
|
const to = { scale: 3, x: -40, y: 5 };
|
|
expect(tweenView(from, to, 0)).toEqual(from);
|
|
expect(tweenView(from, to, 1)).toEqual(to);
|
|
});
|
|
|
|
it('keeps the graph point under the pointer under it all the way', () => {
|
|
const from = { pan: { x: 120, y: 80 }, zoom: 1 };
|
|
const to = zoomedAbout(from, { x: 300, y: 200 }, 2);
|
|
const a = { scale: from.zoom, x: from.pan.x, y: from.pan.y };
|
|
const b = { scale: to.zoom, x: to.pan.x, y: to.pan.y };
|
|
for (const t of [0.1, 0.5, 0.9]) {
|
|
const mid = worldAt(tweenView(a, b, t), 300, 200);
|
|
expect(mid.x).toBeCloseTo(180, 9);
|
|
expect(mid.y).toBeCloseTo(120, 9);
|
|
}
|
|
});
|
|
|
|
it('keeps the picture pixel under the pointer under it in the sheet tabs', () => {
|
|
const from = { panX: 40, panY: -12, zoom: 4 };
|
|
const to = zoomAbout(from, 0.5, 250, 90);
|
|
const mid = tweenView(
|
|
{ scale: from.zoom, x: from.panX, y: from.panY },
|
|
{ scale: to.zoom, x: to.panX, y: to.panY },
|
|
0.4,
|
|
);
|
|
const pixel = localToImage({ panX: mid.x, panY: mid.y, zoom: mid.scale }, 250, 90);
|
|
const before = localToImage(from, 250, 90);
|
|
expect(pixel.x).toBeCloseTo(before.x, 9);
|
|
expect(pixel.y).toBeCloseTo(before.y, 9);
|
|
});
|
|
|
|
it('moves the scale by equal ratios, so halfway from 1 to 4 is 2', () => {
|
|
expect(tweenView({ scale: 1, x: 0, y: 0 }, { scale: 4, x: 0, y: 0 }, 0.5).scale).toBeCloseTo(2, 12);
|
|
});
|
|
|
|
it('slides between two views of the same scale', () => {
|
|
expect(tweenView({ scale: 2, x: 0, y: 10 }, { scale: 2, x: 100, y: 30 }, 0.25)).toEqual({
|
|
scale: 2,
|
|
x: 25,
|
|
y: 15,
|
|
});
|
|
});
|
|
|
|
it('eases out: most of the way early, nothing past the end', () => {
|
|
expect(easeOut(0)).toBe(0);
|
|
expect(easeOut(1)).toBe(1);
|
|
expect(easeOut(2)).toBe(1);
|
|
expect(easeOut(0.5)).toBeCloseTo(0.875, 12);
|
|
});
|
|
});
|
|
|
|
describe('the transition of a view', () => {
|
|
const double = (s: { view: ViewMap }) => () => {
|
|
s.view = { scale: s.view.scale * 2, x: s.view.x * 2, y: s.view.y * 2 };
|
|
};
|
|
|
|
it('shows the way from what was on screen to the new view, and lets go at the end', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
expect(s.view).toEqual({ scale: 1, x: 0, y: 0 });
|
|
expect(tween.moving).toBe(true);
|
|
expect(tween.heading).toEqual({ scale: 2, x: 0, y: 0 });
|
|
clock.advance(100);
|
|
expect(s.view.scale).toBeGreaterThan(1);
|
|
expect(s.view.scale).toBeLessThan(2);
|
|
clock.advance(100);
|
|
expect(s.view).toEqual({ scale: 2, x: 0, y: 0 });
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('adds quick steps up to exactly as many steps, starting the way from what is shown', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
const shown = { ...s.view };
|
|
tween.change(double(s));
|
|
expect(tween.heading?.scale).toBe(4);
|
|
expect(s.view).toEqual(shown);
|
|
clock.advance(200);
|
|
expect(s.view.scale).toBe(4);
|
|
});
|
|
|
|
it('keeps its pace when a wheel event makes no step', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
tween.change(() => {});
|
|
clock.advance(150);
|
|
expect(tween.moving).toBe(false);
|
|
expect(s.view.scale).toBe(2);
|
|
});
|
|
|
|
it('lets go when something else moves the view', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
s.view = { scale: 7, x: 1, y: 2 };
|
|
clock.advance(16);
|
|
expect(s.view).toEqual({ scale: 7, x: 1, y: 2 });
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('jumps at once at 0 ms', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 0, clock);
|
|
tween.change(double(s));
|
|
expect(s.view.scale).toBe(2);
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('lands on the target without drawing', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
const draws = s.draws;
|
|
tween.land();
|
|
expect(s.view.scale).toBe(2);
|
|
expect(s.draws).toBe(draws);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
/** How fast the scale moves, in ratio per ms, over the next `ms`. */
|
|
const pace = (s: { view: ViewMap }, clock: ReturnType<typeof fakeClock>, ms: number) => {
|
|
const before = s.view.scale;
|
|
clock.advance(ms);
|
|
return Math.log(s.view.scale / before) / ms;
|
|
};
|
|
|
|
it('goes on at the pace it had when a quick step comes in, instead of starting over', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(48);
|
|
const before = pace(s, clock, 2);
|
|
tween.change(double(s));
|
|
const after = pace(s, clock, 2);
|
|
expect(after / before).toBeGreaterThan(0.9);
|
|
expect(after / before).toBeLessThan(1.1);
|
|
clock.advance(200);
|
|
expect(s.view.scale).toBe(4);
|
|
});
|
|
|
|
it('pans along during a transition: the rest of the way moves with the pan, and it lands moved', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(60);
|
|
let hold = { x: 0, y: 0 };
|
|
tween.target(() => (hold = { x: s.view.x, y: s.view.y }));
|
|
expect(hold).toEqual({ x: 0, y: 0 });
|
|
const shown = { ...s.view };
|
|
tween.carry(() => (s.view = { ...s.view, x: hold.x + 30, y: hold.y - 10 }));
|
|
expect(s.view).toEqual({ ...shown, x: shown.x + 30, y: shown.y - 10 });
|
|
expect(tween.moving).toBe(true);
|
|
clock.advance(16);
|
|
expect(tween.moving).toBe(true);
|
|
expect(s.view.scale).toBeGreaterThan(shown.scale);
|
|
clock.advance(200);
|
|
expect(s.view).toEqual({ scale: 2, x: 30, y: -10 });
|
|
});
|
|
});
|
|
|
|
describe('the curve of a transition', () => {
|
|
it('starts at the speed it is given and settles at the end', () => {
|
|
for (const speed of [0, 1.5, 3, 4]) {
|
|
expect(glide(0, speed)).toBe(0);
|
|
expect(glide(1, speed)).toBe(1);
|
|
expect(glideSlope(0, speed)).toBeCloseTo(speed, 12);
|
|
expect(glideSlope(1, speed)).toBe(0);
|
|
expect((glide(1e-6, speed) - glide(0, speed)) / 1e-6).toBeCloseTo(speed, 4);
|
|
}
|
|
});
|
|
|
|
it('never runs back or past its end up to the steepest start', () => {
|
|
for (const speed of [0, 2, 4]) {
|
|
let last = 0;
|
|
for (let i = 1; i <= 100; i++) {
|
|
const share = glide(i / 100, speed);
|
|
expect(share).toBeGreaterThanOrEqual(last);
|
|
expect(share).toBeLessThanOrEqual(1);
|
|
last = share;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('carries the pace of a slide over to a longer slide the same way, and starts at rest against it', () => {
|
|
const old = { from: { scale: 1, x: 0, y: 0 }, to: { scale: 1, x: 100, y: 0 } };
|
|
const from = tweenView(old.from, old.to, 0.5);
|
|
// 0.01 of 100 px per ms is 1 px/ms; over 200 ms on a way of 150 px that is a speed of 4/3.
|
|
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: 200, y: 0 }, 200)).toBeCloseTo(4 / 3, 6);
|
|
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: -50, y: 0 }, 200)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('a view whose axes zoom apart (#377)', () => {
|
|
function song(clock: ReturnType<typeof fakeClock>) {
|
|
const s = { view: { panX: 40, pxPerTick: 0.02, panY: 10, trackHeight: 52 } };
|
|
const time = axisTween(
|
|
() => ({ scale: s.view.pxPerTick, offset: s.view.panX }),
|
|
({ scale, offset }) => (s.view = { ...s.view, pxPerTick: scale, panX: offset }),
|
|
() => {},
|
|
() => 200,
|
|
clock,
|
|
);
|
|
const tracks = axisTween(
|
|
() => ({ scale: s.view.trackHeight, offset: s.view.panY }),
|
|
({ scale, offset }) => (s.view = { ...s.view, trackHeight: scale, panY: offset }),
|
|
() => {},
|
|
() => 200,
|
|
clock,
|
|
);
|
|
return { s, time, tracks };
|
|
}
|
|
|
|
it('zooms the time about the pointer and leaves the tracks alone', () => {
|
|
const clock = fakeClock();
|
|
const { s, time, tracks } = song(clock);
|
|
const tickAt = (x: number) => (x - s.view.panX) / s.view.pxPerTick;
|
|
const held = tickAt(300);
|
|
time.change(() => (s.view = { ...s.view, pxPerTick: 0.08, panX: 300 - held * 0.08 }));
|
|
clock.advance(80);
|
|
expect(s.view.pxPerTick).toBeGreaterThan(0.02);
|
|
expect(s.view.pxPerTick).toBeLessThan(0.08);
|
|
expect(tickAt(300)).toBeCloseTo(held, 9);
|
|
expect(s.view.trackHeight).toBe(52);
|
|
expect(s.view.panY).toBe(10);
|
|
expect([time.moving, tracks.moving]).toEqual([true, false]);
|
|
clock.advance(200);
|
|
expect(s.view.pxPerTick).toBe(0.08);
|
|
expect(time.moving).toBe(false);
|
|
});
|
|
|
|
it('moves both axes on a Fit, each landing where the jump would', () => {
|
|
const clock = fakeClock();
|
|
const { s, time, tracks } = song(clock);
|
|
const fitted = { panX: 30, pxPerTick: 0.005, panY: 20, trackHeight: 90 };
|
|
time.change(() => tracks.change(() => (s.view = { ...fitted })));
|
|
clock.advance(100);
|
|
expect(s.view.pxPerTick).toBeLessThan(0.02);
|
|
expect(s.view.trackHeight).toBeGreaterThan(52);
|
|
expect([time.moving, tracks.moving]).toEqual([true, true]);
|
|
clock.advance(200);
|
|
expect(s.view).toEqual(fitted);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('a list scrolled by the wheel', () => {
|
|
function list(clock: ReturnType<typeof fakeClock>) {
|
|
const scroll = new ScrollContainer();
|
|
scroll.resize(200, 100);
|
|
const rows = new VStack();
|
|
for (let i = 0; i < 20; i++) rows.add(new Spacer(20));
|
|
scroll.setContent(rows);
|
|
const tween = scrollTween(scroll, () => 200, clock);
|
|
const wheel = (deltaY: number) => scroll.wheelStep(() => scroll.scrollTo(scroll.offset + deltaY));
|
|
return { scroll, tween, wheel };
|
|
}
|
|
|
|
it('glides to where the step leads and lands there', () => {
|
|
const clock = fakeClock();
|
|
const { scroll, tween, wheel } = list(clock);
|
|
wheel(100);
|
|
expect(scroll.offset).toBe(0);
|
|
clock.advance(80);
|
|
expect(scroll.offset).toBeGreaterThan(0);
|
|
expect(scroll.offset).toBeLessThan(100);
|
|
clock.advance(200);
|
|
expect(scroll.offset).toBe(100);
|
|
expect(tween.moving).toBe(false);
|
|
});
|
|
|
|
it('adds up quick steps and stops at the end of the list', () => {
|
|
const clock = fakeClock();
|
|
const { scroll, wheel } = list(clock);
|
|
wheel(100);
|
|
clock.advance(50);
|
|
wheel(100);
|
|
clock.advance(400);
|
|
expect(scroll.offset).toBe(200);
|
|
wheel(500);
|
|
clock.advance(400);
|
|
expect(scroll.offset).toBe(scroll.maxScroll);
|
|
});
|
|
|
|
it('lets go when the bar or a jump to a row moves the list', () => {
|
|
const clock = fakeClock();
|
|
const { scroll, tween, wheel } = list(clock);
|
|
wheel(100);
|
|
clock.advance(50);
|
|
scroll.scrollTo(250);
|
|
clock.advance(10);
|
|
expect(tween.moving).toBe(false);
|
|
expect(scroll.offset).toBe(250);
|
|
});
|
|
|
|
it('jumps at once with the transition switched off', () => {
|
|
const { scroll } = list(fakeClock());
|
|
scrollTween(scroll, () => 0, fakeClock());
|
|
scroll.wheelStep(() => scroll.scrollTo(60));
|
|
expect(scroll.offset).toBe(60);
|
|
});
|
|
});
|
|
|
|
describe('the general settings', () => {
|
|
function memoryStore() {
|
|
const items = new Map<string, string>();
|
|
return {
|
|
items,
|
|
getItem: (key: string) => items.get(key) ?? null,
|
|
setItem: (key: string, value: string) => void items.set(key, value),
|
|
};
|
|
}
|
|
beforeEach(() => resetGeneralSettings());
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
resetGeneralSettings();
|
|
});
|
|
|
|
it('starts at 200 ms where nothing was kept', () => {
|
|
vi.stubGlobal('window', { localStorage: memoryStore() });
|
|
expect(generalSettings().zoomTransitionMs).toBe(200);
|
|
});
|
|
|
|
it('keeps the transition in the browser and reads it back on a new page', () => {
|
|
const store = memoryStore();
|
|
vi.stubGlobal('window', { localStorage: store });
|
|
keepGeneralSettings({ zoomTransitionMs: 350 });
|
|
resetGeneralSettings();
|
|
expect(generalSettings().zoomTransitionMs).toBe(350);
|
|
expect(JSON.parse(store.items.get('feuerwerk.general-settings') ?? '')).toEqual({
|
|
zoomTransitionMs: 350,
|
|
showEmptyAssetGroups: false,
|
|
customCursor: true,
|
|
});
|
|
});
|
|
|
|
it('hides empty asset groups until switched on, keeps the switch and tells listeners (#361)', () => {
|
|
vi.stubGlobal('window', { localStorage: memoryStore() });
|
|
expect(generalSettings().showEmptyAssetGroups).toBe(false);
|
|
let told = 0;
|
|
const stop = onGeneralSettings(() => (told += 1));
|
|
keepGeneralSettings({ showEmptyAssetGroups: true });
|
|
resetGeneralSettings();
|
|
expect(generalSettings()).toEqual({
|
|
zoomTransitionMs: 200,
|
|
showEmptyAssetGroups: true,
|
|
customCursor: true,
|
|
});
|
|
stop();
|
|
keepGeneralSettings({ showEmptyAssetGroups: false });
|
|
expect(told).toBe(1);
|
|
});
|
|
|
|
it('shows the custom mouse pointer until switched off, and keeps the switch', () => {
|
|
const store = memoryStore();
|
|
vi.stubGlobal('window', { localStorage: store });
|
|
expect(generalSettings().customCursor).toBe(true);
|
|
keepGeneralSettings({ customCursor: false });
|
|
resetGeneralSettings();
|
|
expect(generalSettings().customCursor).toBe(false);
|
|
// Settings kept before the switch existed show it.
|
|
store.setItem('feuerwerk.general-settings', JSON.stringify({ zoomTransitionMs: 100 }));
|
|
resetGeneralSettings();
|
|
expect(generalSettings().customCursor).toBe(true);
|
|
});
|
|
|
|
it('holds the transition to whole ms from 0 to 1000', () => {
|
|
expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
|
|
expect(zoomTransitionMs('fast')).toBe(200);
|
|
});
|
|
});
|