From cf6043a066f66977ace225cf9f3072f643a269a9 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 21:01:10 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/editor/Editor.ts | 15 ++ src/editor/GeneralSettingsDialog.ts | 45 +++++ src/editor/animation/PreviewCanvas.ts | 9 +- src/editor/animation/TimelineCanvas.ts | 19 ++- src/editor/debug/sceneHooks.ts | 4 + src/editor/debug/uiHooks.ts | 16 ++ src/editor/generalSettings.ts | 64 +++++++ src/editor/graph/GraphCanvas.ts | 32 +++- src/editor/graph/graphView.ts | 31 +++- src/editor/pixelart/PixelArtCanvas.ts | 10 +- src/editor/plugins/NodeBoard.ts | 23 ++- src/editor/texture/SheetCanvas.ts | 24 ++- src/editor/tile/TileCanvas.ts | 17 +- src/editor/viewTween.ts | 156 +++++++++++++++++ src/editor/viewport/FreeViewport.ts | 51 ++++-- src/editor/viewport/SceneViewport.ts | 6 +- tests/editor/view-tween.test.ts | 225 +++++++++++++++++++++++++ tools/e2e/46-topbar.mjs | 74 +++++++- tools/e2e/49-users.mjs | 3 +- tools/lib/harness.mjs | 2 + 21 files changed, 779 insertions(+), 49 deletions(-) create mode 100644 src/editor/GeneralSettingsDialog.ts create mode 100644 src/editor/generalSettings.ts create mode 100644 src/editor/viewTween.ts create mode 100644 tests/editor/view-tween.test.ts diff --git a/README.md b/README.md index 52298741..578d738f 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index bf51fa25..957f8b1c 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -45,6 +45,8 @@ import type { ReleaseStore } from '../core/store/ReleaseStore'; import type { ReleaseDialog } from './ReleaseDialog'; import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import { dialogSlot } from './FormDialog'; +import { generalSettings, keepGeneralSettings } from './generalSettings'; +import { GeneralSettingsDialog } from './GeneralSettingsDialog'; import { ContainerEditor } from './container/ContainerEditor'; import { EditorRun } from './EditorRun'; import { GraphEditor } from './graph/GraphEditor'; @@ -256,6 +258,7 @@ export class Editor implements PlayHost { plugins: PluginManager | null = null; private readonly toolbar: Toolbar; private readonly passwordDialog = dialogSlot(); + private readonly generalDialog = dialogSlot(); /** Drawn-frame count at the last measurement, not a counter of its own. */ private fpsFrames = 0; private fpsElapsed = 0; @@ -601,11 +604,22 @@ export class Editor implements PlayHost { const open = () => this.openDocument('management'); return [ { separator: true }, + { label: 'General settings…', onSelect: () => this.openGeneralSettings() }, ...(this.usersPane ? [{ label: 'User management', 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 * 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(); // Closed rather than just thrown away with the overlay below, so the slot lets go of it too. 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. tooltips.dismiss(); // A menu or a picker left open belongs to widgets that are about to be destroyed. diff --git a/src/editor/GeneralSettingsDialog.ts b/src/editor/GeneralSettingsDialog.ts new file mode 100644 index 00000000..5157ea54 --- /dev/null +++ b/src/editor/GeneralSettingsDialog.ts @@ -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; + } +} diff --git a/src/editor/animation/PreviewCanvas.ts b/src/editor/animation/PreviewCanvas.ts index 200830d2..de86cb2d 100644 --- a/src/editor/animation/PreviewCanvas.ts +++ b/src/editor/animation/PreviewCanvas.ts @@ -63,12 +63,15 @@ export class PreviewCanvas extends FreeViewport { 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 { if (!this.visible) return; if (this.fitPending && this.w > 0 && this.h > 0) { - this.fit(); - return; + this.fitPending = false; + this.frameContent(); } super.redraw(); } diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 0e50b14f..54b20140 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -43,6 +43,7 @@ import { crispText, } from '../../ui'; import { type EditorState, trackSelection } from '../state'; +import { ViewTween } from '../viewTween'; import { FramesDialog, openFramesDialog } from './FramesDialog'; import { clampMove, @@ -107,6 +108,14 @@ export class TimelineCanvas extends Widget { private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly clip = new Graphics(); 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 = ''; /** Where the playhead stands, in frames – fractional while playing. */ playhead = 0; @@ -163,6 +172,7 @@ export class TimelineCanvas extends Widget { } saveView(): TimelineView { + this.tween.land(); return { ...this.view }; } @@ -403,6 +413,7 @@ export class TimelineCanvas extends Widget { const local = this.toLocal(e.global); const point = { x: local.x, y: local.y }; if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) { + this.tween.finish(); this.panning = { pointer: point, panX: this.view.panX }; e.stopPropagation(); return; @@ -529,11 +540,15 @@ export class TimelineCanvas extends Widget { private onWheel(e: FederatedWheelEvent): void { const local = this.toLocal(e.global); if (e.shiftKey) { + this.tween.land(); this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY }); + this.redraw(); } else { - this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH)); + this.tween.change(() => { + this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH)); + this.redraw(); + }); } - this.redraw(); e.preventDefault(); } diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index c9a795a2..4cd95168 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -51,6 +51,10 @@ export function sceneHooks({ state, editor }: HookContext) { sceneHeaderAt: (name: 'fit' | 'visibility') => centreOf(name === 'fit' ? scenePane.fitButton : scenePane.visibilityButton), 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. */ viewportRedraw: () => viewport.redraw(), /** Where a dragged asset would land in the Scene tab, and why not when it may not. */ diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 36687edb..c343eea9 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -13,6 +13,7 @@ import { } from '../../ui'; import { Theme } from '../../ui/Theme'; import { currentFontPicker } from '../font/FontPicker'; +import { generalSettings, keepGeneralSettings } from '../generalSettings'; import { currentGroupDialog } from '../panels/GroupNameDialog'; import { currentSoundPicker } from '../sound/SoundPicker'; import { rectOf, textProbe } from '../textProbe'; @@ -318,6 +319,21 @@ export function uiHooks({ app, state, editor }: HookContext) { const dialog = editor.projectSettingsDialog(); 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. */ projectPicker: () => editor.projectPickerDialog()?.info() ?? null, /** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */ diff --git a/src/editor/generalSettings.ts b/src/editor/generalSettings.ts new file mode 100644 index 00000000..20526d40 --- /dev/null +++ b/src/editor/generalSettings.ts @@ -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 | 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) : {}; + return { zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs) }; + } catch { + return { ...DEFAULTS }; + } +} + +export function generalSettings(): GeneralSettings { + settings ??= read(); + return { ...settings }; +} + +export function keepGeneralSettings(change: Partial): 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; +} diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 0d300cfa..c3df36f1 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -30,6 +30,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { findAsset, findGraphAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; +import { ViewTween } from '../viewTween'; import { BeadClock } from './beads'; import { activeDrag, @@ -116,6 +117,14 @@ export class GraphCanvas extends Widget { private assetId = ''; private pan = { x: 0, y: 0 }; 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 snapping = false; private readonly wiring: GraphWiring; @@ -190,12 +199,15 @@ export class GraphCanvas extends Widget { this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e)); this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e)); this.on('wheel', (e: FederatedWheelEvent) => { - ({ pan: this.pan, zoom: this.zoom } = zoomedAbout( - { pan: this.pan, zoom: this.zoom }, - this.toLocal(e.global), - zoomFactor(e.deltaY), - )); - this.applyView(); + const at = this.toLocal(e.global); + this.tween.change(() => { + ({ pan: this.pan, zoom: this.zoom } = zoomedAbout( + { pan: this.pan, zoom: this.zoom }, + at, + zoomFactor(e.deltaY), + )); + this.applyView(); + }); e.preventDefault(); }); @@ -226,11 +238,14 @@ export class GraphCanvas extends Widget { } fit(): void { - this.needsFit = true; - this.fitView(); + this.tween.change(() => { + this.needsFit = true; + this.fitView(); + }); } saveView(): GraphViewState { + this.tween.land(); 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 // gesture that never means "grab this". Nodes let it through for exactly that reason. const startPan = () => { + this.tween.finish(); this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; }; if (e.button === 1) { diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index baee29b4..4ba2818a 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -1,6 +1,7 @@ import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js'; import { clamp, drawGrid, zoomFactor } from '../../ui'; 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 @@ -48,18 +49,34 @@ export function zoomedAbout( export type WheelZoom = { 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 - * 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( surface: Container, get: () => GraphView, set: (view: GraphView, gesture: 'pan' | 'zoom') => void, zoom: WheelZoom = ZOOM_LIMITS, + tween: ViewTween, ): void { let panning: { start: Point; pan: Point } | null = null; surface.on('pointerdown', (e: FederatedPointerEvent) => { + tween.finish(); panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } }; }); surface.on('globalpointermove', (e: FederatedPointerEvent) => { @@ -72,11 +89,13 @@ export function panAndZoom( surface.on('wheel', (e: FederatedWheelEvent) => { e.preventDefault(); const at = surface.toLocal(e.global); - const view = - typeof zoom === 'function' - ? zoom(get(), at, e.deltaY) - : zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom); - if (view) set(view, 'zoom'); + tween.change(() => { + const view = + typeof zoom === 'function' + ? zoom(get(), at, e.deltaY) + : zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom); + if (view) set(view, 'zoom'); + }); }); } diff --git a/src/editor/pixelart/PixelArtCanvas.ts b/src/editor/pixelart/PixelArtCanvas.ts index 14834405..08bf75f1 100644 --- a/src/editor/pixelart/PixelArtCanvas.ts +++ b/src/editor/pixelart/PixelArtCanvas.ts @@ -151,8 +151,9 @@ export class PixelArtCanvas extends SheetCanvas { this.onChange(); } + /** The step the view is at, or heading for during a transition – the readout names steps only. */ get zoom(): number { - return this.view.zoom; + return this.tween.heading?.scale ?? this.view.zoom; } asset(): PixelArtAsset | null { @@ -354,8 +355,9 @@ export class PixelArtCanvas extends SheetCanvas { protected override onWheel(e: FederatedWheelEvent): void { const local = this.toLocal(e.global); - const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1); - if (zoom !== this.view.zoom) { + this.tween.change(() => { + const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1); + if (zoom === this.view.zoom) return; // The pixel under the pointer stays under it. const x = (local.x - this.view.panX) / this.view.zoom; const y = (local.y - this.view.panY) / this.view.zoom; @@ -363,7 +365,7 @@ export class PixelArtCanvas extends SheetCanvas { this.needsFit = false; this.redraw(); this.onChange(); - } + }); e.preventDefault(); } diff --git a/src/editor/plugins/NodeBoard.ts b/src/editor/plugins/NodeBoard.ts index 5b6f4cd0..5f4f2504 100644 --- a/src/editor/plugins/NodeBoard.ts +++ b/src/editor/plugins/NodeBoard.ts @@ -2,7 +2,14 @@ import { Container, Graphics } from 'pixi.js'; import type { NodeDef } from '../../core/graph'; import { Button, Label, type Size, Spacer, Theme, VStack, Widget } from '../../ui'; 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 { TabHeader } from '../TabHeader'; @@ -53,6 +60,11 @@ class BoardCanvas extends Widget { 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. */ private needsFit = true; + private readonly tween = graphViewTween( + () => this.view, + (view) => (this.view = view), + () => this.applyView(), + ); constructor() { super(); @@ -66,6 +78,8 @@ class BoardCanvas extends Widget { this.view = view; this.applyView(); }, + ZOOM_LIMITS, + this.tween, ); } @@ -84,6 +98,11 @@ class BoardCanvas extends Widget { 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 { 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 })); @@ -151,7 +170,7 @@ export class NodeBoard extends VStack { } fit(): void { - this.canvas.fit(); + this.canvas.refit(); } /** The nodes as drawn and the view – what the check scripts read. */ diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts index 30de0dd5..7284eff1 100644 --- a/src/editor/texture/SheetCanvas.ts +++ b/src/editor/texture/SheetCanvas.ts @@ -4,6 +4,7 @@ import type { Vec2 } from '../../core/model/Properties'; import type { CutSize } from '../../core/model/texture'; import { type Size, Theme, textStyle, ui, Widget, zoomFactor, crispText } from '../../ui'; import { dashedLine } from '../../viewport/symbols'; +import { ViewTween } from '../viewTween'; import { cutLines, imageToLocal, type SheetLine, type SheetView, zoomAbout } from './sheetView'; /** @@ -24,6 +25,13 @@ export abstract class SheetCanvas extends Widget { /** Fit once the widget has a real size and a picture to fit (a tab starts hidden). */ protected needsFit = true; 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() { super(); @@ -66,6 +74,7 @@ export abstract class SheetCanvas extends Widget { } saveView(): { view: SheetView; needsFit: boolean } { + this.tween.land(); return { view: { ...this.view }, needsFit: this.needsFit }; } @@ -78,8 +87,10 @@ export abstract class SheetCanvas extends Widget { } fit(): void { - this.needsFit = true; - this.redraw(); + this.tween.change(() => { + this.needsFit = true; + this.redraw(); + }); } /** Where a picture pixel sits on screen; the check scripts aim their drags with it. */ @@ -96,9 +107,11 @@ export abstract class SheetCanvas extends Widget { protected onWheel(e: FederatedWheelEvent): void { const local = this.toLocal(e.global); - this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); - this.needsFit = false; - this.redraw(); + this.tween.change(() => { + this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); + this.needsFit = false; + this.redraw(); + }); e.preventDefault(); } @@ -129,6 +142,7 @@ export abstract class SheetCanvas extends Widget { const local = this.toLocal(e.global); const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y }); 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 }; e.stopPropagation(); return; diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 0ae67c31..273e9d90 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -23,7 +23,7 @@ import { type Size, Theme, textStyle, ui, Widget, crispText } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view'; 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'; /** 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 readonly stopPictures: () => void; private readonly wheel = wheelSteps(); + private readonly tween = graphViewTween( + () => this.view, + (view) => (this.view = view), + () => this.applyView(), + ); constructor(private readonly state: EditorState) { super(); @@ -128,6 +133,7 @@ export class TileCanvas extends Widget { this.applyView(); }, (view, at, deltaY) => this.zoomedStep(view, at, deltaY), + this.tween, ); state.on('project', () => this.redraw()); @@ -163,12 +169,15 @@ export class TileCanvas extends Widget { } fit(): void { - this.needsFit = true; - this.followsFit = true; - this.redraw(); + this.tween.change(() => { + this.needsFit = true; + this.followsFit = true; + this.redraw(); + }); } saveView(): { view: GraphView; needsFit: boolean } { + this.tween.land(); return { view: { pan: { ...this.view.pan }, zoom: this.view.zoom }, needsFit: this.needsFit }; } diff --git a/src/editor/viewTween.ts b/src/editor/viewTween.ts new file mode 100644 index 00000000..547931c6 --- /dev/null +++ b/src/editor/viewTween.ts @@ -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); + } +} diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index dfb9867c..2fa0f418 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -13,6 +13,7 @@ import { zoomStepBelow, } from '../../viewport/view'; import { WorldViewport } from '../../viewport/WorldViewport'; +import { ViewTween } from '../viewTween'; /** Breathing room left around the content by `fit()`, in screen pixels. */ const FIT_MARGIN = 40; @@ -29,6 +30,18 @@ export abstract class FreeViewport extends WorldViewport { /** 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 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) { super(state); @@ -40,8 +53,10 @@ export abstract class FreeViewport extends WorldViewport { * placed and where the preview places its root, so content far off to one side would hide it. */ fit(): void { - this.frameContent(); - this.redraw(); + this.tween.change(() => { + this.frameContent(); + this.redraw(); + }); } /** Points the camera at everything drawn, without drawing. */ @@ -77,6 +92,7 @@ export abstract class FreeViewport extends WorldViewport { */ override resize(w: number, h: number): void { if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) { + this.tween.land(); const before = this.screenOrigin; const after = this.getGlobalPosition(); this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.worldScale(); @@ -88,6 +104,7 @@ export abstract class FreeViewport extends WorldViewport { /** Where the camera stands, to come back to (#171). */ saveView(): unknown { + this.tween.land(); return { ...this.view }; } @@ -116,6 +133,7 @@ export abstract class FreeViewport extends WorldViewport { } 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 }; e.stopPropagation(); } @@ -139,14 +157,27 @@ export abstract class FreeViewport extends WorldViewport { e.preventDefault(); const direction = this.wheel(e.deltaY); if (direction === 0) return; - const zoom = stepFreeZoom(this.view.zoom, direction); - if (zoom === this.view.zoom) return; const local = this.toLocal(e.global); - const before = screenToWorld(this.currentView(), local.x, local.y); - this.view.zoom = zoom; - const after = screenToWorld(this.currentView(), local.x, local.y); - this.view.x += before.x - after.x; - this.view.y += before.y - after.y; - this.redraw(); + this.tween.change(() => { + const zoom = stepFreeZoom(this.view.zoom, direction); + if (zoom === this.view.zoom) return; + const before = screenToWorld(this.currentView(), local.x, local.y); + this.view.zoom = zoom; + const after = screenToWorld(this.currentView(), local.x, local.y); + this.view.x += before.x - after.x; + this.view.y += before.y - after.y; + 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); } } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 068a4bff..d3e2ab38 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -308,8 +308,10 @@ export class SceneViewport extends FreeViewport { * switching between the two tabs moves nothing. Without one, frames the whole content. */ fitScene(): void { - this.frameScene(); - this.redraw(); + this.tween.change(() => { + this.frameScene(); + this.redraw(); + }); } private frameScene(): void { diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts new file mode 100644 index 00000000..7ab71532 --- /dev/null +++ b/tests/editor/view-tween.test.ts @@ -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(); + 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); + }); +}); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index 3f1245cb..3304d908 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -60,10 +60,11 @@ export async function topbar(t) { 'Project settings…', 'Clear saved data', null, + 'General settings…', 'Plugins', ]; 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), ); @@ -401,4 +402,75 @@ export async function topbar(t) { 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)`); 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); } diff --git a/tools/e2e/49-users.mjs b/tools/e2e/49-users.mjs index a5219f1b..8902d067 100644 --- a/tools/e2e/49-users.mjs +++ b/tools/e2e/49-users.mjs @@ -150,7 +150,8 @@ export async function users(t) { await press('Escape'); check( '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), ); diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 73a1c2ac..1d5761bb 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -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 // that stopped there must not look like a loaded editor. 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 {