Show a wheel step or Fit as a short zoom transition (#309)

The wheel and the Fit button used to jump to the new view. Now the
view takes a short way there, 200 ms by default, everywhere the editor
zooms. The zoom steps stay the same, only the change is shown.

- viewTween: tweenView is the way between two views as a pure
  function. The scale moves by equal ratios, and the world point that
  sits at the same place on screen in both views stays there. After a
  wheel step that is the point under the pointer.
- ViewTween drives it on the ticker. A surface hands it reading,
  writing and drawing its own view and wraps its wheel and Fit code in
  change(). A step during a transition starts from where that one was
  heading, so quick steps add up to exactly as many steps.
- Anything else that moves the view ends the transition. A pan jumps
  to the target first, and saving a view saves the target.
- Wired into FreeViewport (Scene, Container, animation preview),
  SheetCanvas (Texture, Curve, Pixel art, Vector art), GraphCanvas,
  panAndZoom (Tile board, plugin node board) and the timeline.
- Menu > General settings... sets the time in ms, 0 jumps at once. It
  is kept in this browser like the panel sizes, not in the project,
  and it is not an undo step.
- A document that opens still comes up framed at once. The animation
  preview frames a new subject itself instead of calling fit().
- The pixel art readout names the step the view is heading for.

Measured in the scene view at 200 ms on melektaus-atx, where every
frame of the transition is a full redraw:

- 500 sprites: redraw 6.7 ms, 16 frames, mean 18 ms
- 1000 sprites: redraw 13.0 ms, 9 frames, mean 27 ms
- 2000 sprites: redraw 32.2 ms, 5 frames, mean 58 ms

Tests:
- The harness sets 0 ms on every load, so the existing modules read
  the step and not the way.
- e2e 46i samples one wheel step at 1000 ms a frame later: zoom 1.033
  between 1 and 2, the point under the pointer kept at (134, -40). It
  lands on the view a 0 ms jump reaches.
- e2e 46 and 49 expect the new menu entry.
- Gate (atx): 234 files, 3563 tests, CHECK EXIT 0 (+15)
- e2e 46,49 (atx): ALL PASSED, 43 checks (+3, sum 1735)
- Three counter-checks fail view-tween.test.ts as expected. e2e 46i
  fails when the scene view does not carry its centre along.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 21:01:10 +02:00
1 parent 8452a99c64
commit cf6043a066
21 files changed
+748 -18

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1732 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1735 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+15
View File
@@ -45,6 +45,8 @@ import type { ReleaseStore } from '../core/store/ReleaseStore';
import type { ReleaseDialog } from './ReleaseDialog'; import type { ReleaseDialog } from './ReleaseDialog';
import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
import { dialogSlot } from './FormDialog'; import { dialogSlot } from './FormDialog';
import { generalSettings, keepGeneralSettings } from './generalSettings';
import { GeneralSettingsDialog } from './GeneralSettingsDialog';
import { ContainerEditor } from './container/ContainerEditor'; import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun'; import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor'; import { GraphEditor } from './graph/GraphEditor';
@@ -256,6 +258,7 @@ export class Editor implements PlayHost {
plugins: PluginManager | null = null; plugins: PluginManager | null = null;
private readonly toolbar: Toolbar; private readonly toolbar: Toolbar;
private readonly passwordDialog = dialogSlot<PasswordDialog>(); private readonly passwordDialog = dialogSlot<PasswordDialog>();
private readonly generalDialog = dialogSlot<GeneralSettingsDialog>();
/** Drawn-frame count at the last measurement, not a counter of its own. */ /** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0; private fpsFrames = 0;
private fpsElapsed = 0; private fpsElapsed = 0;
@@ -601,11 +604,22 @@ export class Editor implements PlayHost {
const open = () => this.openDocument('management'); const open = () => this.openDocument('management');
return [ return [
{ separator: true }, { separator: true },
{ label: 'General settings…', onSelect: () => this.openGeneralSettings() },
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []), ...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
{ label: 'Plugins', onSelect: open }, { label: 'Plugins', onSelect: open },
]; ];
} }
/** What the editor does for whoever sits at it, kept in this browser (#309). */
openGeneralSettings(): GeneralSettingsDialog {
return this.generalDialog.open(new GeneralSettingsDialog(generalSettings(), keepGeneralSettings));
}
/** The open "General settings" dialog, for the check scripts. */
generalSettingsDialog(): GeneralSettingsDialog | null {
return this.generalDialog.current();
}
/** /**
* The account menu under its button (#182). The name stands in it as an entry that cannot be * The account menu under its button (#182). The name stands in it as an entry that cannot be
* picked: it says who is signed in without costing the crowded toolbar another label. * picked: it says who is signed in without costing the crowded toolbar another label.
@@ -921,6 +935,7 @@ export class Editor implements PlayHost {
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup(); for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too. // Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
this.passwordDialog.current()?.close(); this.passwordDialog.current()?.close();
this.generalDialog.current()?.close();
// The tooltip is not the editor's to destroy: it leaves the overlay before that is emptied. // The tooltip is not the editor's to destroy: it leaves the overlay before that is emptied.
tooltips.dismiss(); tooltips.dismiss();
// A menu or a picker left open belongs to widgets that are about to be destroyed. // A menu or a picker left open belongs to widgets that are about to be destroyed.
+45
View File
@@ -0,0 +1,45 @@
import { Label, NumberField } from '../ui';
import { type GeneralSettings, ZOOM_TRANSITION_MAX_MS, zoomTransitionMs } from './generalSettings';
import { FormDialog } from './FormDialog';
/**
* How the editor behaves for whoever sits at it (#309), as opposed to the project's own settings:
* kept in this browser, the same in every project, and not an undo step.
*/
export class GeneralSettingsDialog extends FormDialog {
private zoomMs: number;
private readonly zoomField: NumberField;
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
super(340, 'General settings');
this.zoomMs = settings.zoomTransitionMs;
this.zoomField = new NumberField(this.zoomMs, {
width: 150,
min: 0,
max: ZOOM_TRANSITION_MAX_MS,
step: 10,
decimals: 0,
onInput: (value) => {
this.zoomMs = value;
},
onChange: (value) => {
this.zoomMs = value;
},
});
this.content.add(this.formRow(this.fieldLabel('Zoom (ms)'), this.zoomField));
this.content.add(
new Label('How long a zoom or Fit takes to settle. 0 jumps at once.', { style: 'muted' }),
);
this.addButtons('Apply', 'primary', () => onApply({ zoomTransitionMs: zoomTransitionMs(this.zoomMs) }));
}
/** The settings as the fields stand (test hook). */
values(): GeneralSettings {
return { zoomTransitionMs: this.zoomMs };
}
/** The zoom field itself, so a check can type into it. */
get zoomControl(): NumberField {
return this.zoomField;
}
}
+6 -3
View File
@@ -63,12 +63,15 @@ export class PreviewCanvas extends FreeViewport {
return item ? { x: item.x, y: item.y, props: item.props } : null; return item ? { x: item.x, y: item.y, props: item.props } : null;
} }
/** Skipped while the tab is hidden, like the Game tab: a frame costs a collect of the root. */ /**
* Skipped while the tab is hidden, like the Game tab: a frame costs a collect of the root. A fresh
* subject is framed at once, not by a transition from the last one's view.
*/
override redraw(): void { override redraw(): void {
if (!this.visible) return; if (!this.visible) return;
if (this.fitPending && this.w > 0 && this.h > 0) { if (this.fitPending && this.w > 0 && this.h > 0) {
this.fit(); this.fitPending = false;
return; this.frameContent();
} }
super.redraw(); super.redraw();
} }
+18 -3
View File
@@ -43,6 +43,7 @@ import {
crispText, crispText,
} from '../../ui'; } from '../../ui';
import { type EditorState, trackSelection } from '../state'; import { type EditorState, trackSelection } from '../state';
import { ViewTween } from '../viewTween';
import { FramesDialog, openFramesDialog } from './FramesDialog'; import { FramesDialog, openFramesDialog } from './FramesDialog';
import { import {
clampMove, clampMove,
@@ -107,6 +108,14 @@ export class TimelineCanvas extends Widget {
private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly hint = crispText({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics(); private readonly clip = new Graphics();
private view: TimelineView = { ...DEFAULT_VIEW }; private view: TimelineView = { ...DEFAULT_VIEW };
/** Only across: a frame's cell is `cellWidth` wide and frame 0 starts at `panX`. */
private readonly tween = new ViewTween({
read: () => ({ scale: this.view.cellWidth, x: this.view.panX, y: 0 }),
write: (map) => {
this.view = { ...this.view, panX: map.x, cellWidth: map.scale };
},
draw: () => this.redraw(),
});
private assetId = ''; private assetId = '';
/** Where the playhead stands, in frames – fractional while playing. */ /** Where the playhead stands, in frames – fractional while playing. */
playhead = 0; playhead = 0;
@@ -163,6 +172,7 @@ export class TimelineCanvas extends Widget {
} }
saveView(): TimelineView { saveView(): TimelineView {
this.tween.land();
return { ...this.view }; return { ...this.view };
} }
@@ -403,6 +413,7 @@ export class TimelineCanvas extends Widget {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const point = { x: local.x, y: local.y }; const point = { x: local.x, y: local.y };
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) { if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
this.tween.finish();
this.panning = { pointer: point, panX: this.view.panX }; this.panning = { pointer: point, panX: this.view.panX };
e.stopPropagation(); e.stopPropagation();
return; return;
@@ -529,11 +540,15 @@ export class TimelineCanvas extends Widget {
private onWheel(e: FederatedWheelEvent): void { private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
if (e.shiftKey) { if (e.shiftKey) {
this.tween.land();
this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY }); this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY });
} else {
this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH));
}
this.redraw(); this.redraw();
} else {
this.tween.change(() => {
this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH));
this.redraw();
});
}
e.preventDefault(); e.preventDefault();
} }
+4
View File
@@ -51,6 +51,10 @@ export function sceneHooks({ state, editor }: HookContext) {
sceneHeaderAt: (name: 'fit' | 'visibility') => sceneHeaderAt: (name: 'fit' | 'visibility') =>
centreOf(name === 'fit' ? scenePane.fitButton : scenePane.visibilityButton), centreOf(name === 'fit' ? scenePane.fitButton : scenePane.visibilityButton),
viewportInfo: () => viewport.debugInfo(), viewportInfo: () => viewport.debugInfo(),
/** The scene view's centre and zoom, and whether a zoom transition is under way (#309). */
sceneView: () => viewport.viewState(),
/** The world point under a screen point, as the scene view shows it right now. */
sceneWorldAt: (x: number, y: number) => viewport.worldAt(x, y),
/** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */ /** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */
viewportRedraw: () => viewport.redraw(), viewportRedraw: () => viewport.redraw(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */ /** Where a dragged asset would land in the Scene tab, and why not when it may not. */
+16
View File
@@ -13,6 +13,7 @@ import {
} from '../../ui'; } from '../../ui';
import { Theme } from '../../ui/Theme'; import { Theme } from '../../ui/Theme';
import { currentFontPicker } from '../font/FontPicker'; import { currentFontPicker } from '../font/FontPicker';
import { generalSettings, keepGeneralSettings } from '../generalSettings';
import { currentGroupDialog } from '../panels/GroupNameDialog'; import { currentGroupDialog } from '../panels/GroupNameDialog';
import { currentSoundPicker } from '../sound/SoundPicker'; import { currentSoundPicker } from '../sound/SoundPicker';
import { rectOf, textProbe } from '../textProbe'; import { rectOf, textProbe } from '../textProbe';
@@ -318,6 +319,21 @@ export function uiHooks({ app, state, editor }: HookContext) {
const dialog = editor.projectSettingsDialog(); const dialog = editor.projectSettingsDialog();
return dialog ? centreOf(dialog.symbolScaleControl) : null; return dialog ? centreOf(dialog.symbolScaleControl) : null;
}, },
/** The "General settings" dialog (#309) as its field stands, null while shut. */
generalSettingsDialog: () => editor.generalSettingsDialog()?.values() ?? null,
/** Where to click the zoom field inside that dialog. */
generalSettingsZoomAt: () => {
const dialog = editor.generalSettingsDialog();
return dialog ? centreOf(dialog.zoomControl) : null;
},
/**
* The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets
* 0 on every load, so a check reading the zoom after the wheel finds the step and not the way.
*/
zoomTransition: (ms?: number) => {
if (ms !== undefined) keepGeneralSettings({ zoomTransitionMs: ms });
return generalSettings().zoomTransitionMs;
},
/** The "Open project" dialog (#183): the projects it lists and the row picked. */ /** The "Open project" dialog (#183): the projects it lists and the row picked. */
projectPicker: () => editor.projectPickerDialog()?.info() ?? null, projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
/** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */ /** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */
+64
View File
@@ -0,0 +1,64 @@
/**
* How the editor itself behaves, kept in this browser rather than in a project (#309): it is about
* the person at the screen, not the game. Best effort, like the panel sizes – a browser that
* refuses `localStorage` keeps the settings for the page only.
*/
import { clamp } from '../ui/math';
const STORAGE_KEY = 'game-maker.general-settings';
export interface GeneralSettings {
/** How long a zoom takes to reach its new step, in ms; 0 jumps at once. */
zoomTransitionMs: number;
}
export const ZOOM_TRANSITION_MAX_MS = 1000;
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200 };
let settings: GeneralSettings | null = null;
function storage(): Pick<Storage, 'getItem' | 'setItem'> | null {
try {
return typeof window === 'undefined' ? null : window.localStorage;
} catch {
return null;
}
}
/** A whole number of ms within the range the dialog offers; anything else is the default. */
export function zoomTransitionMs(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(clamp(value, 0, ZOOM_TRANSITION_MAX_MS))
: DEFAULTS.zoomTransitionMs;
}
function read(): GeneralSettings {
try {
const stored: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? '{}');
const raw = typeof stored === 'object' && stored !== null ? (stored as Record<string, unknown>) : {};
return { zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs) };
} catch {
return { ...DEFAULTS };
}
}
export function generalSettings(): GeneralSettings {
settings ??= read();
return { ...settings };
}
export function keepGeneralSettings(change: Partial<GeneralSettings>): void {
settings = { ...generalSettings(), ...change };
settings.zoomTransitionMs = zoomTransitionMs(settings.zoomTransitionMs);
try {
storage()?.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// Kept for this page only.
}
}
/** Forgets what was read, so the next question asks the browser again (tests). */
export function resetGeneralSettings(): void {
settings = null;
}
+17 -1
View File
@@ -30,6 +30,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { findAsset, findGraphAsset } from '../../core/model/Project'; import { findAsset, findGraphAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { ViewTween } from '../viewTween';
import { BeadClock } from './beads'; import { BeadClock } from './beads';
import { import {
activeDrag, activeDrag,
@@ -116,6 +117,14 @@ export class GraphCanvas extends Widget {
private assetId = ''; private assetId = '';
private pan = { x: 0, y: 0 }; private pan = { x: 0, y: 0 };
private zoom = 1; private zoom = 1;
private readonly tween = new ViewTween({
read: () => ({ scale: this.zoom, x: this.pan.x, y: this.pan.y }),
write: (map) => {
this.pan = { x: map.x, y: map.y };
this.zoom = map.scale;
},
draw: () => this.applyView(),
});
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private snapping = false; private snapping = false;
private readonly wiring: GraphWiring; private readonly wiring: GraphWiring;
@@ -190,12 +199,15 @@ export class GraphCanvas extends Widget {
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e)); this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e)); this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => { this.on('wheel', (e: FederatedWheelEvent) => {
const at = this.toLocal(e.global);
this.tween.change(() => {
({ pan: this.pan, zoom: this.zoom } = zoomedAbout( ({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom }, { pan: this.pan, zoom: this.zoom },
this.toLocal(e.global), at,
zoomFactor(e.deltaY), zoomFactor(e.deltaY),
)); ));
this.applyView(); this.applyView();
});
e.preventDefault(); e.preventDefault();
}); });
@@ -226,11 +238,14 @@ export class GraphCanvas extends Widget {
} }
fit(): void { fit(): void {
this.tween.change(() => {
this.needsFit = true; this.needsFit = true;
this.fitView(); this.fitView();
});
} }
saveView(): GraphViewState { saveView(): GraphViewState {
this.tween.land();
return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit }; return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit };
} }
@@ -507,6 +522,7 @@ export class GraphCanvas extends Widget {
// The middle button pans from anywhere, a node under the pointer included – it is the one // The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason. // gesture that never means "grab this". Nodes let it through for exactly that reason.
const startPan = () => { const startPan = () => {
this.tween.finish();
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}; };
if (e.button === 1) { if (e.button === 1) {
+20 -1
View File
@@ -1,6 +1,7 @@
import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js'; import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js';
import { clamp, drawGrid, zoomFactor } from '../../ui'; import { clamp, drawGrid, zoomFactor } from '../../ui';
import type { Point } from '../framing'; import type { Point } from '../framing';
import { ViewTween } from '../viewTween';
/** /**
* What every surface that shows nodes shares with the graph editor: how far it zooms, how the grid * What every surface that shows nodes shares with the graph editor: how far it zooms, how the grid
@@ -48,18 +49,34 @@ export function zoomedAbout(
export type WheelZoom = export type WheelZoom =
{ min: number; max: number } | ((view: GraphView, at: Point, deltaY: number) => GraphView | null); { min: number; max: number } | ((view: GraphView, at: Point, deltaY: number) => GraphView | null);
/** The transition (#309) of a board whose view is a `GraphView`. */
export function graphViewTween(
get: () => GraphView,
put: (view: GraphView) => void,
draw: () => void,
): ViewTween {
return new ViewTween({
read: () => ({ scale: get().zoom, x: get().pan.x, y: get().pan.y }),
write: (map) => put({ pan: { x: map.x, y: map.y }, zoom: map.scale }),
draw,
});
}
/** /**
* Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer * Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer
* when its content takes none. `set` receives each new view and which gesture made it. * when its content takes none. `set` receives each new view and which gesture made it; `tween`
* shows the wheel's views as a transition (#309).
*/ */
export function panAndZoom( export function panAndZoom(
surface: Container, surface: Container,
get: () => GraphView, get: () => GraphView,
set: (view: GraphView, gesture: 'pan' | 'zoom') => void, set: (view: GraphView, gesture: 'pan' | 'zoom') => void,
zoom: WheelZoom = ZOOM_LIMITS, zoom: WheelZoom = ZOOM_LIMITS,
tween: ViewTween,
): void { ): void {
let panning: { start: Point; pan: Point } | null = null; let panning: { start: Point; pan: Point } | null = null;
surface.on('pointerdown', (e: FederatedPointerEvent) => { surface.on('pointerdown', (e: FederatedPointerEvent) => {
tween.finish();
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } }; panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
}); });
surface.on('globalpointermove', (e: FederatedPointerEvent) => { surface.on('globalpointermove', (e: FederatedPointerEvent) => {
@@ -72,12 +89,14 @@ export function panAndZoom(
surface.on('wheel', (e: FederatedWheelEvent) => { surface.on('wheel', (e: FederatedWheelEvent) => {
e.preventDefault(); e.preventDefault();
const at = surface.toLocal(e.global); const at = surface.toLocal(e.global);
tween.change(() => {
const view = const view =
typeof zoom === 'function' typeof zoom === 'function'
? zoom(get(), at, e.deltaY) ? zoom(get(), at, e.deltaY)
: zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom); : zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom);
if (view) set(view, 'zoom'); if (view) set(view, 'zoom');
}); });
});
} }
/** What a node draws beyond its box: the shadow, the pins on its edges and the ring around a drop target. */ /** What a node draws beyond its box: the shadow, the pins on its edges and the ring around a drop target. */
+5 -3
View File
@@ -151,8 +151,9 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
this.onChange(); this.onChange();
} }
/** The step the view is at, or heading for during a transition – the readout names steps only. */
get zoom(): number { get zoom(): number {
return this.view.zoom; return this.tween.heading?.scale ?? this.view.zoom;
} }
asset(): PixelArtAsset | null { asset(): PixelArtAsset | null {
@@ -354,8 +355,9 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
protected override onWheel(e: FederatedWheelEvent): void { protected override onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
this.tween.change(() => {
const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1); const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1);
if (zoom !== this.view.zoom) { if (zoom === this.view.zoom) return;
// The pixel under the pointer stays under it. // The pixel under the pointer stays under it.
const x = (local.x - this.view.panX) / this.view.zoom; const x = (local.x - this.view.panX) / this.view.zoom;
const y = (local.y - this.view.panY) / this.view.zoom; const y = (local.y - this.view.panY) / this.view.zoom;
@@ -363,7 +365,7 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
this.needsFit = false; this.needsFit = false;
this.redraw(); this.redraw();
this.onChange(); this.onChange();
} });
e.preventDefault(); e.preventDefault();
} }
+21 -2
View File
@@ -2,7 +2,14 @@ import { Container, Graphics } from 'pixi.js';
import type { NodeDef } from '../../core/graph'; import type { NodeDef } from '../../core/graph';
import { Button, Label, type Size, Spacer, Theme, VStack, Widget } from '../../ui'; import { Button, Label, type Size, Spacer, Theme, VStack, Widget } from '../../ui';
import { type Box, frameBoxes } from '../framing'; import { type Box, frameBoxes } from '../framing';
import { drawGraphGrid, FIT_MARGIN, type GraphView, panAndZoom, ZOOM_LIMITS } from '../graph/graphView'; import {
drawGraphGrid,
FIT_MARGIN,
type GraphView,
graphViewTween,
panAndZoom,
ZOOM_LIMITS,
} from '../graph/graphView';
import { NodeView } from '../graph/NodeView'; import { NodeView } from '../graph/NodeView';
import { TabHeader } from '../TabHeader'; import { TabHeader } from '../TabHeader';
@@ -53,6 +60,11 @@ class BoardCanvas extends Widget {
private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 }; private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 };
/** Framed once there is a size and something to frame; after that the view is the user's. */ /** Framed once there is a size and something to frame; after that the view is the user's. */
private needsFit = true; private needsFit = true;
private readonly tween = graphViewTween(
() => this.view,
(view) => (this.view = view),
() => this.applyView(),
);
constructor() { constructor() {
super(); super();
@@ -66,6 +78,8 @@ class BoardCanvas extends Widget {
this.view = view; this.view = view;
this.applyView(); this.applyView();
}, },
ZOOM_LIMITS,
this.tween,
); );
} }
@@ -84,6 +98,11 @@ class BoardCanvas extends Widget {
else this.applyView(); else this.applyView();
} }
/** Fit by hand: the Fit button and a plugin's call, shown as a transition. */
refit(): void {
this.tween.change(() => this.fit());
}
fit(): void { fit(): void {
if (this.w === 0 || this.h === 0) return; if (this.w === 0 || this.h === 0) return;
const boxes: Box[] = this.views.map((v) => ({ id: v.node.id, x: v.x, y: v.y, w: v.w, h: v.h })); const boxes: Box[] = this.views.map((v) => ({ id: v.node.id, x: v.x, y: v.y, w: v.w, h: v.h }));
@@ -151,7 +170,7 @@ export class NodeBoard extends VStack {
} }
fit(): void { fit(): void {
this.canvas.fit(); this.canvas.refit();
} }
/** The nodes as drawn and the view – what the check scripts read. */ /** The nodes as drawn and the view – what the check scripts read. */
+14
View File
@@ -4,6 +4,7 @@ import type { Vec2 } from '../../core/model/Properties';
import type { CutSize } from '../../core/model/texture'; import type { CutSize } from '../../core/model/texture';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor, crispText } from '../../ui'; import { type Size, Theme, textStyle, ui, Widget, zoomFactor, crispText } from '../../ui';
import { dashedLine } from '../../viewport/symbols'; import { dashedLine } from '../../viewport/symbols';
import { ViewTween } from '../viewTween';
import { cutLines, imageToLocal, type SheetLine, type SheetView, zoomAbout } from './sheetView'; import { cutLines, imageToLocal, type SheetLine, type SheetView, zoomAbout } from './sheetView';
/** /**
@@ -24,6 +25,13 @@ export abstract class SheetCanvas<A> extends Widget {
/** Fit once the widget has a real size and a picture to fit (a tab starts hidden). */ /** Fit once the widget has a real size and a picture to fit (a tab starts hidden). */
protected needsFit = true; protected needsFit = true;
private panning: { pointer: Vec2; panX: number; panY: number } | null = null; private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
protected readonly tween = new ViewTween({
read: () => ({ scale: this.view.zoom, x: this.view.panX, y: this.view.panY }),
write: (map) => {
this.view = { panX: map.x, panY: map.y, zoom: map.scale };
},
draw: () => this.redraw(),
});
constructor() { constructor() {
super(); super();
@@ -66,6 +74,7 @@ export abstract class SheetCanvas<A> extends Widget {
} }
saveView(): { view: SheetView; needsFit: boolean } { saveView(): { view: SheetView; needsFit: boolean } {
this.tween.land();
return { view: { ...this.view }, needsFit: this.needsFit }; return { view: { ...this.view }, needsFit: this.needsFit };
} }
@@ -78,8 +87,10 @@ export abstract class SheetCanvas<A> extends Widget {
} }
fit(): void { fit(): void {
this.tween.change(() => {
this.needsFit = true; this.needsFit = true;
this.redraw(); this.redraw();
});
} }
/** Where a picture pixel sits on screen; the check scripts aim their drags with it. */ /** Where a picture pixel sits on screen; the check scripts aim their drags with it. */
@@ -96,9 +107,11 @@ export abstract class SheetCanvas<A> extends Widget {
protected onWheel(e: FederatedWheelEvent): void { protected onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
this.tween.change(() => {
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
this.needsFit = false; this.needsFit = false;
this.redraw(); this.redraw();
});
e.preventDefault(); e.preventDefault();
} }
@@ -129,6 +142,7 @@ export abstract class SheetCanvas<A> extends Widget {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y }); const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
if (e.button === 1 || pans) { if (e.button === 1 || pans) {
this.tween.finish();
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation(); e.stopPropagation();
return; return;
+10 -1
View File
@@ -23,7 +23,7 @@ import { type Size, Theme, textStyle, ui, Widget, crispText } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary';
import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view'; import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view';
import type { Point } from '../framing'; import type { Point } from '../framing';
import { type GraphView, panAndZoom, zoomedAbout } from '../graph/graphView'; import { type GraphView, graphViewTween, panAndZoom, zoomedAbout } from '../graph/graphView';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
/** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */ /** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */
@@ -108,6 +108,11 @@ export class TileCanvas extends Widget {
private fittedFor = { w: 0, h: 0, board: { w: 0, h: 0 } }; private fittedFor = { w: 0, h: 0, board: { w: 0, h: 0 } };
private readonly stopPictures: () => void; private readonly stopPictures: () => void;
private readonly wheel = wheelSteps(); private readonly wheel = wheelSteps();
private readonly tween = graphViewTween(
() => this.view,
(view) => (this.view = view),
() => this.applyView(),
);
constructor(private readonly state: EditorState) { constructor(private readonly state: EditorState) {
super(); super();
@@ -128,6 +133,7 @@ export class TileCanvas extends Widget {
this.applyView(); this.applyView();
}, },
(view, at, deltaY) => this.zoomedStep(view, at, deltaY), (view, at, deltaY) => this.zoomedStep(view, at, deltaY),
this.tween,
); );
state.on('project', () => this.redraw()); state.on('project', () => this.redraw());
@@ -163,12 +169,15 @@ export class TileCanvas extends Widget {
} }
fit(): void { fit(): void {
this.tween.change(() => {
this.needsFit = true; this.needsFit = true;
this.followsFit = true; this.followsFit = true;
this.redraw(); this.redraw();
});
} }
saveView(): { view: GraphView; needsFit: boolean } { saveView(): { view: GraphView; needsFit: boolean } {
this.tween.land();
return { view: { pan: { ...this.view.pan }, zoom: this.view.zoom }, needsFit: this.needsFit }; return { view: { pan: { ...this.view.pan }, zoom: this.view.zoom }, needsFit: this.needsFit };
} }
+156
View File
@@ -0,0 +1,156 @@
import { requestRender, ui } from '../ui';
import { generalSettings } from './generalSettings';
/**
* A view as every zoomable surface of the editor has one, whatever its fields are called: a world
* point `p` is shown at `p * scale + (x, y)`.
*/
export interface ViewMap {
scale: number;
x: number;
y: number;
}
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
export function easeOut(t: number): number {
const rest = 1 - Math.min(1, Math.max(0, t));
return 1 - rest * rest * rest;
}
/**
* The view `t` of the way from `from` to `to` (#309). The scale moves by equal ratios, so zooming
* in and out feel equally fast. The world point that sits at the same place on screen in both
* views stays there all the way – after a wheel step that is the point under the pointer. Two
* views of the same scale have no such point, and between them the view slides.
*/
export function tweenView(from: ViewMap, to: ViewMap, t: number): ViewMap {
if (t <= 0) return { ...from };
if (t >= 1) return { ...to };
const scale = from.scale * (to.scale / from.scale) ** t;
const gap = from.scale - to.scale;
if (Math.abs(gap) <= 1e-9 * from.scale)
return { scale, x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
const held = (a: number, b: number) => {
const world = (b - a) / gap;
return a + world * from.scale - world * scale;
};
return { scale, x: held(from.x, to.x), y: held(from.y, to.y) };
}
function near(a: ViewMap, b: ViewMap): boolean {
const close = (p: number, q: number) => Math.abs(p - q) <= 1e-6 * Math.max(1, Math.abs(p), Math.abs(q));
return close(a.scale, b.scale) && close(a.x, b.x) && close(a.y, b.y);
}
/** How a surface hands its view to the tween. */
export interface TweenedView {
read(): ViewMap;
/** Puts a view in place without drawing it. */
write(view: ViewMap): void;
draw(): void;
}
/** Time and frames; the editor's are the ticker's, a test brings its own. */
export interface TweenClock {
now(): number;
add(fn: () => void): void;
remove(fn: () => void): void;
}
const tickerClock: TweenClock = {
now: () => performance.now(),
add: (fn) => ui().app.ticker.add(fn),
remove: (fn) => ui().app.ticker.remove(fn),
};
/**
* Shows a zoom or a Fit as a short transition instead of a jump (#309). The surface changes its view
* as it always did, and the tween shows the way from what was on screen to it.
*
* Changing the view by other means – a pan, another document, a resize – ends the transition where
* it stands: the tween notices that the view is no longer the one it left and lets go.
*/
export class ViewTween {
private run: { from: ViewMap; to: ViewMap; start: number; ms: number; left: ViewMap } | null = null;
private readonly tick = () => this.step();
constructor(
private readonly view: TweenedView,
private readonly duration: () => number = () => generalSettings().zoomTransitionMs,
private readonly clock: TweenClock = tickerClock,
) {}
/** Whether a transition is under way – the check scripts sample it. */
get moving(): boolean {
return this.run !== null;
}
/** Where the transition is heading, null while none is under way. */
get heading(): ViewMap | null {
return this.run ? { ...this.run.to } : null;
}
/**
* Carries out `change` and shows the way to the view it leaves. A change made during a transition
* starts from where that one was heading, so quick wheel steps add up to exactly as many steps,
* while the way to the new view starts from what is on screen.
*/
change(change: () => void): void {
const shown = this.view.read();
const running = this.run;
if (running) this.view.write(running.to);
change();
const to = this.view.read();
const ms = this.duration();
if (ms <= 0 || near(shown, to)) {
this.stop();
if (running) this.view.draw();
return;
}
if (running && near(running.to, to)) {
this.view.write(shown);
return;
}
if (!running) this.clock.add(this.tick);
// Not drawn here: the next frame draws the first step of the way before it is rendered.
this.view.write(shown);
this.run = { from: shown, to, start: this.clock.now(), ms, left: this.view.read() };
requestRender();
}
/** Jumps to where the transition was heading and draws it – before a pan takes the view. */
finish(): void {
if (!this.run) return;
this.land();
this.view.draw();
}
/** Puts the view where the transition was heading, without drawing – before it is saved. */
land(): void {
const run = this.run;
if (!run) return;
this.stop();
this.view.write(run.to);
}
private step(): void {
const run = this.run;
if (!run) return;
if (!near(this.view.read(), run.left)) {
this.stop();
return;
}
const t = (this.clock.now() - run.start) / run.ms;
this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, easeOut(t)));
run.left = this.view.read();
if (t >= 1) this.stop();
this.view.draw();
requestRender();
}
private stop(): void {
if (!this.run) return;
this.run = null;
this.clock.remove(this.tick);
}
}
+32 -1
View File
@@ -13,6 +13,7 @@ import {
zoomStepBelow, zoomStepBelow,
} from '../../viewport/view'; } from '../../viewport/view';
import { WorldViewport } from '../../viewport/WorldViewport'; import { WorldViewport } from '../../viewport/WorldViewport';
import { ViewTween } from '../viewTween';
/** Breathing room left around the content by `fit()`, in screen pixels. */ /** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40; const FIT_MARGIN = 40;
@@ -29,6 +30,18 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
/** Global position of the top-left corner at the last resize, for `resize()` to keep the world in place. */ /** Global position of the top-left corner at the last resize, for `resize()` to keep the world in place. */
private screenOrigin = { x: 0, y: 0 }; private screenOrigin = { x: 0, y: 0 };
private readonly wheel = wheelSteps(); private readonly wheel = wheelSteps();
/** The view as a map from world to device pixels around the centre, which a transition keeps still. */
protected readonly tween = new ViewTween({
read: () => ({
scale: this.view.zoom,
x: -this.view.x * this.view.zoom,
y: -this.view.y * this.view.zoom,
}),
write: (map) => {
this.view = { x: -map.x / map.scale, y: -map.y / map.scale, zoom: map.scale };
},
draw: () => this.redraw(),
});
protected constructor(state: EditorState) { protected constructor(state: EditorState) {
super(state); super(state);
@@ -40,8 +53,10 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
* placed and where the preview places its root, so content far off to one side would hide it. * placed and where the preview places its root, so content far off to one side would hide it.
*/ */
fit(): void { fit(): void {
this.tween.change(() => {
this.frameContent(); this.frameContent();
this.redraw(); this.redraw();
});
} }
/** Points the camera at everything drawn, without drawing. */ /** Points the camera at everything drawn, without drawing. */
@@ -77,6 +92,7 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
*/ */
override resize(w: number, h: number): void { override resize(w: number, h: number): void {
if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) { if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) {
this.tween.land();
const before = this.screenOrigin; const before = this.screenOrigin;
const after = this.getGlobalPosition(); const after = this.getGlobalPosition();
this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.worldScale(); this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.worldScale();
@@ -88,6 +104,7 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
/** Where the camera stands, to come back to (#171). */ /** Where the camera stands, to come back to (#171). */
saveView(): unknown { saveView(): unknown {
this.tween.land();
return { ...this.view }; return { ...this.view };
} }
@@ -116,6 +133,7 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
} }
protected startPan(e: FederatedPointerEvent): void { protected startPan(e: FederatedPointerEvent): void {
this.tween.finish();
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y }; this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation(); e.stopPropagation();
} }
@@ -139,14 +157,27 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
e.preventDefault(); e.preventDefault();
const direction = this.wheel(e.deltaY); const direction = this.wheel(e.deltaY);
if (direction === 0) return; if (direction === 0) return;
const local = this.toLocal(e.global);
this.tween.change(() => {
const zoom = stepFreeZoom(this.view.zoom, direction); const zoom = stepFreeZoom(this.view.zoom, direction);
if (zoom === this.view.zoom) return; if (zoom === this.view.zoom) return;
const local = this.toLocal(e.global);
const before = screenToWorld(this.currentView(), local.x, local.y); const before = screenToWorld(this.currentView(), local.x, local.y);
this.view.zoom = zoom; this.view.zoom = zoom;
const after = screenToWorld(this.currentView(), local.x, local.y); const after = screenToWorld(this.currentView(), local.x, local.y);
this.view.x += before.x - after.x; this.view.x += before.x - after.x;
this.view.y += before.y - after.y; this.view.y += before.y - after.y;
this.redraw(); this.redraw();
});
}
/** Centre, zoom and whether a transition is under way – what the check scripts sample (#309). */
viewState(): { x: number; y: number; zoom: number; moving: boolean } {
return { ...this.view, moving: this.tween.moving };
}
/** The world point shown at a global screen point, by the rule the wheel zooms with. */
worldAt(x: number, y: number): { x: number; y: number } {
const local = this.toLocal({ x, y });
return screenToWorld(this.currentView(), local.x, local.y);
} }
} }
+2
View File
@@ -308,8 +308,10 @@ export class SceneViewport extends FreeViewport {
* switching between the two tabs moves nothing. Without one, frames the whole content. * switching between the two tabs moves nothing. Without one, frames the whole content.
*/ */
fitScene(): void { fitScene(): void {
this.tween.change(() => {
this.frameScene(); this.frameScene();
this.redraw(); this.redraw();
});
} }
private frameScene(): void { private frameScene(): void {
+225
View File
@@ -0,0 +1,225 @@
/** 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,
resetGeneralSettings,
zoomTransitionMs,
} from '../../src/editor/generalSettings';
import { zoomedAbout } from '../../src/editor/graph/graphView';
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
import { easeOut, tweenView, type ViewMap, ViewTween } from '../../src/editor/viewTween';
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, and finishes by drawing it', () => {
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);
tween.change(double(s));
tween.finish();
expect(s.view.scale).toBe(4);
expect(s.draws).toBe(draws + 1);
expect(clock.attached).toBe(0);
});
});
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('game-maker.general-settings') ?? '')).toEqual({
zoomTransitionMs: 350,
});
});
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);
});
});
+73 -1
View File
@@ -60,10 +60,11 @@ export async function topbar(t) {
'Project settings…', 'Project settings…',
'Clear saved data', 'Clear saved data',
null, null,
'General settings…',
'Plugins', 'Plugins',
]; ];
check( check(
'the menu button opens new project, the project picker, the settings, clearing saves and the plugins', 'the menu button opens new project, the project picker, the settings, clearing saves, the general settings and the plugins',
JSON.stringify(rows) === JSON.stringify(expected), JSON.stringify(rows) === JSON.stringify(expected),
JSON.stringify(rows), JSON.stringify(rows),
); );
@@ -401,4 +402,75 @@ export async function topbar(t) {
const assets = __gm.state.project.assets; const assets = __gm.state.project.assets;
for (const id of ['vis-map', 'vis-wall', 'vis-col']) assets.splice(assets.findIndex((a) => a.id === id), 1)`); for (const id of ['vis-map', 'vis-wall', 'vis-col']) assets.splice(assets.findIndex((a) => a.id === id), 1)`);
await t.saved(); await t.saved();
// 46i. "General settings…" (#309): the zoom transition in ms, kept in this browser. The harness
// sets 0 on load. At 1000 ms one wheel step in the scene view is sampled on its way – the zoom
// between the two steps, the world point under the pointer still under it – and it lands on
// the very view a jump of 0 ms reaches.
await clickToolbar('menu');
await clickText('General settings…');
const general = await json('__gm.generalSettingsDialog()');
const msField = await json('__gm.generalSettingsZoomAt()');
await t.click(msField.x, msField.y);
await press('a', { ctrl: true });
await typeText('1000');
await press('Enter');
await clickText('Apply');
await sleep(100);
const kept = {
dialog: await json('__gm.generalSettingsDialog()'),
ms: await json('__gm.zoomTransition()'),
stored: await json(`JSON.parse(localStorage.getItem('game-maker.general-settings') ?? 'null')`),
};
check(
'"General settings…" opens on the kept transition, and Apply keeps the one typed in the browser',
general?.zoomTransitionMs === 0 &&
kept.dialog === null &&
kept.ms === 1000 &&
kept.stored?.zoomTransitionMs === 1000,
JSON.stringify({ general, kept }),
);
await t.showTab('scene');
const rect = await json('__gm.viewportRect()');
const at = { x: Math.round(rect.x + rect.w * 0.7), y: Math.round(rect.y + rect.h * 0.4) };
const viewNow = async () => ({
...(await json('__gm.sceneView()')),
world: await json(`__gm.sceneWorldAt(${at.x}, ${at.y})`),
});
await t.evaluate('__gm.zoomTransition(0)');
const start = await viewNow();
await t.wheel(at.x, at.y, -100);
const jumped = await viewNow();
await t.wheel(at.x, at.y, 100);
const back = await viewNow();
await t.evaluate('__gm.zoomTransition(1000)');
await t.mouse('mouseMoved', at.x, at.y);
await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
await t.settle(2);
const midway = await viewNow();
await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended' });
const landed = await viewNow();
const samePoint = (a, b) => Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6;
check(
'a frame after the wheel the scene view is on its way: zoom between the steps, the point under the pointer kept',
back.zoom === start.zoom &&
jumped.zoom > start.zoom &&
midway.moving === true &&
midway.zoom > start.zoom &&
midway.zoom < jumped.zoom &&
samePoint(midway.world, start.world),
JSON.stringify({ start, jumped, back, midway }),
);
check(
'the transition lands on the view a jump reaches: the same step and the same centre',
landed.moving === false &&
landed.zoom === jumped.zoom &&
samePoint(landed, jumped) &&
samePoint(landed.world, start.world),
JSON.stringify({ jumped, landed }),
);
// Handed back as found: no transition for the steps after this one, and the zoom it had.
await t.evaluate('__gm.zoomTransition(0)');
await t.wheel(at.x, at.y, 100);
} }
+2 -1
View File
@@ -150,7 +150,8 @@ export async function users(t) {
await press('Escape'); await press('Escape');
check( check(
'an administrator finds the user management before the plugins', 'an administrator finds the user management before the plugins',
JSON.stringify(adminMenu.slice(-3)) === JSON.stringify([null, 'User management', 'Plugins']), JSON.stringify(adminMenu.slice(-4)) ===
JSON.stringify([null, 'General settings…', 'User management', 'Plugins']),
JSON.stringify(adminMenu), JSON.stringify(adminMenu),
); );
+2
View File
@@ -488,6 +488,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
// `state` rather than the bare hook: the sign-in screen installs one of its own, and a run // `state` rather than the bare hook: the sign-in screen installs one of its own, and a run
// that stopped there must not look like a loaded editor. // that stopped there must not look like a loaded editor.
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm?.state)'))); check('debug hook present', Boolean(await evaluate('Boolean(window.__gm?.state)')));
// The zoom is shown as a transition (#309); a check reads the step it lands on, not the way.
await evaluate('__gm.zoomTransition?.(0)');
}; };
return { return {