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:
1 parent
8452a99c64
commit
cf6043a066
21 files changed
+748
-18
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user