diff --git a/README.md b/README.md index 6918a9d9..787abd50 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, 2017 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2018 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/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index aa9ca4e0..195a928c 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -554,8 +554,9 @@ 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 }; + this.tween.target(() => { + this.panning = { pointer: point, panX: this.view.panX }; + }); e.stopPropagation(); return; } @@ -616,7 +617,10 @@ export class TimelineCanvas extends Widget { private onPointerMove(e: FederatedPointerEvent): void { const local = this.toLocal(e.global); if (this.panning) { - this.view = clampPan({ ...this.view, panX: this.panning.panX + (local.x - this.panning.pointer.x) }); + const { panning } = this; + this.tween.carry(() => { + this.view = clampPan({ ...this.view, panX: panning.panX + (local.x - panning.pointer.x) }); + }); this.redraw(); return; } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index c511cd85..2944db26 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -593,8 +593,9 @@ 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 }; + this.tween.target(() => { + this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; + }); }; if (e.button === 1) { startPan(); @@ -782,9 +783,14 @@ export class GraphCanvas extends Widget { this.trackIncoming(dragged, e); return; } - if (this.panning) { - this.pan.x = this.panning.panX + (e.global.x - this.panning.startX); - this.pan.y = this.panning.panY + (e.global.y - this.panning.startY); + const { panning } = this; + if (panning) { + this.tween.carry(() => { + this.pan = { + x: panning.panX + (e.global.x - panning.startX), + y: panning.panY + (e.global.y - panning.startY), + }; + }); this.applyView(); return; } diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index 7be9f6ea..c7cc0967 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -76,13 +76,16 @@ export function panAndZoom( ): 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 } }; + tween.target(() => { + panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } }; + }); }); surface.on('globalpointermove', (e: FederatedPointerEvent) => { if (!panning) return; const { start, pan } = panning; - set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'); + tween.carry(() => + set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'), + ); }); surface.on('pointerup', () => (panning = null)); surface.on('pointerupoutside', () => (panning = null)); diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts index 0819fcba..51b3904c 100644 --- a/src/editor/pattern/PianoRoll.ts +++ b/src/editor/pattern/PianoRoll.ts @@ -566,9 +566,11 @@ export class PianoRoll extends Widget { e.stopPropagation(); ui().focus.focus(this); if (e.button === 1) { - this.timeTween.finish(); - this.rowTween.finish(); - this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; + this.timeTween.target(() => + this.rowTween.target(() => { + this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; + }), + ); return; } const g = this.inGrid(p); @@ -660,17 +662,21 @@ export class PianoRoll extends Widget { const free = e.altKey || e.shiftKey; switch (gesture.kind) { case 'pan': - this.view = clampRows( - clampTimePan( - { - ...this.view, - panX: gesture.panX + (local.x - gesture.pointer.x), - panY: gesture.panY + (local.y - gesture.pointer.y), - }, - this.gridWidth, - this.earliest, - ), - this.gridHeight, + this.timeTween.carry(() => + this.rowTween.carry(() => { + this.view = clampRows( + clampTimePan( + { + ...this.view, + panX: gesture.panX + (local.x - gesture.pointer.x), + panY: gesture.panY + (local.y - gesture.pointer.y), + }, + this.gridWidth, + this.earliest, + ), + this.gridHeight, + ); + }), ); break; case 'key': { diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 26d6073d..f4163129 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -756,9 +756,11 @@ export class SongTimeline extends Widget { e.stopPropagation(); ui().focus.focus(this); if (e.button === 1) { - this.timeTween.finish(); - this.trackTween.finish(); - this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; + this.timeTween.target(() => + this.trackTween.target(() => { + this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; + }), + ); return; } if (this.take) return; @@ -911,17 +913,21 @@ export class SongTimeline extends Widget { const picked = new Set(this.pickedIds); switch (gesture.kind) { case 'pan': - this.view = clampTracks( - clampTimePan( - { - ...this.view, - panX: gesture.panX + (local.x - gesture.pointer.x), - panY: gesture.panY + (local.y - gesture.pointer.y), - }, - this.lanesWidth, - ), - asset.tracks.length, - this.lanesHeight, + this.timeTween.carry(() => + this.trackTween.carry(() => { + this.view = clampTracks( + clampTimePan( + { + ...this.view, + panX: gesture.panX + (local.x - gesture.pointer.x), + panY: gesture.panY + (local.y - gesture.pointer.y), + }, + this.lanesWidth, + ), + asset.tracks.length, + this.lanesHeight, + ); + }), ); break; case 'loop': { diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index 989821c9..cc56eb60 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -380,8 +380,9 @@ export class WaveCanvas extends Widget { e.stopPropagation(); ui().focus.focus(this); if (e.button === 1) { - this.timeTween.finish(); - this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX }; + this.timeTween.target(() => { + this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX }; + }); return; } if (e.button !== 0 || p.x < 0) return; @@ -410,7 +411,13 @@ export class WaveCanvas extends Widget { return; } if (g.kind === 'pan') { - this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, wave.duration, this.areaWidth); + this.timeTween.carry(() => { + this.view = clampWave( + { ...this.view, panX: g.panX + p.x - g.pointerX }, + wave.duration, + this.areaWidth, + ); + }); this.needsFit = false; this.redraw(); return; diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts index 7284eff1..edf3f6ed 100644 --- a/src/editor/texture/SheetCanvas.ts +++ b/src/editor/texture/SheetCanvas.ts @@ -142,8 +142,9 @@ 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 }; + this.tween.target(() => { + this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; + }); e.stopPropagation(); return; } @@ -160,7 +161,9 @@ export abstract class SheetCanvas extends Widget { this.move({ x: local.x, y: local.y }, e); return; } - this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)); + this.tween.carry(() => + this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)), + ); this.panned(); } diff --git a/src/editor/viewTween.ts b/src/editor/viewTween.ts index 23058227..8cd7ca88 100644 --- a/src/editor/viewTween.ts +++ b/src/editor/viewTween.ts @@ -13,8 +13,60 @@ export interface ViewMap { /** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */ export function easeOut(t: number): number { + return glide(t, 3); +} + +/** The steepest start `glide` takes without running past its end. */ +const STEEPEST = 4; + +/** + * The share of the way at `t`, setting off at `speed` (shares per duration) and settling with + * neither speed nor pull left at the end. At 3 it is the cubic ease-out, at 0 it starts at rest. + */ +export function glide(t: number, speed: number): number { const rest = 1 - Math.min(1, Math.max(0, t)); - return 1 - rest * rest * rest; + return 1 - (4 - speed) * rest ** 3 - (speed - 3) * rest ** 4; +} + +/** How fast `glide` moves at `t`, in shares per duration. */ +export function glideSlope(t: number, speed: number): number { + const rest = 1 - Math.min(1, Math.max(0, t)); + return 3 * (4 - speed) * rest ** 2 + 4 * (speed - 3) * rest ** 3; +} + +/** + * The speed a new way from `from` to `to`, `ms` long, sets off at to go on as fast as the old one + * moved: `rate` shares of `old` per ms, at share `at`. Along the scale where the new way zooms, + * along its line where it slides. Moving against the new way starts it at rest. + */ +export function carriedSpeed( + old: { from: ViewMap; to: ViewMap }, + at: number, + rate: number, + from: ViewMap, + to: ViewMap, + ms: number, +): number { + const h = at < 0.5 ? 1e-4 : -1e-4; + const a = tweenView(old.from, old.to, at); + const b = tweenView(old.from, old.to, at + h); + const per = (p: number, q: number) => ((q - p) / h) * rate * ms; + const zoom = Math.log(to.scale / from.scale); + let speed: number; + if (Math.abs(zoom) > 1e-9) speed = per(Math.log(a.scale), Math.log(b.scale)) / zoom; + else { + const dx = to.x - from.x; + const dy = to.y - from.y; + const length = dx * dx + dy * dy; + speed = length > 0 ? (per(a.x, b.x) * dx + per(a.y, b.y) * dy) / length : 0; + } + return Number.isFinite(speed) ? Math.max(0, speed) : 0; +} + +/** The move that takes the screen of view `a` to that of `b`, applied to `view`. */ +function moved(view: ViewMap, a: ViewMap, b: ViewMap): ViewMap { + const k = b.scale / a.scale; + return { scale: view.scale * k, x: b.x + (view.x - a.x) * k, y: b.y + (view.y - a.y) * k }; } /** @@ -67,11 +119,19 @@ const tickerClock: TweenClock = { * 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. + * A pan made through `carry` moves the transition along with it. Changing the view by other means – + * another document, a resize, a jump – 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 run: { + from: ViewMap; + to: ViewMap; + start: number; + ms: number; + speed: number; + left: ViewMap; + } | null = null; private readonly tick = () => this.step(); constructor( @@ -93,7 +153,7 @@ export class ViewTween { /** * 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. + * while the way to the new view starts from what is on screen, as fast as the screen moved. */ change(change: () => void): void { const shown = this.view.read(); @@ -101,7 +161,7 @@ export class ViewTween { if (running) this.view.write(running.to); change(); const to = this.view.read(); - const ms = this.duration(); + let ms = this.duration(); if (ms <= 0 || near(shown, to)) { this.stop(); if (running) this.view.draw(); @@ -111,18 +171,60 @@ export class ViewTween { this.view.write(shown); return; } - if (!running) this.clock.add(this.tick); + const now = this.clock.now(); + let speed = 3; + if (running) { + const t = Math.min(1, (now - running.start) / running.ms); + const rate = glideSlope(t, running.speed) / running.ms; + speed = carriedSpeed(running, glide(t, running.speed), rate, shown, to, ms); + // Faster than the rest of the way allows without running past it: get there sooner instead. + if (speed > STEEPEST) { + ms *= STEEPEST / speed; + speed = STEEPEST; + } + } else 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() }; + this.run = { from: shown, to, start: now, ms, speed, 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(); + /** + * Runs `fn` on the view a transition is heading for rather than the one on screen – where a pan + * takes its hold, so a pan made through `carry` moves on from there. + */ + target(fn: () => void): void { + const run = this.run; + if (!run) { + fn(); + return; + } + const shown = this.view.read(); + this.view.write(run.to); + fn(); + this.view.write(shown); + } + + /** + * Carries out a pan during a transition without ending it: `fn` moves the view the transition is + * heading for, and the view on screen and the rest of the way move with it. + */ + carry(fn: () => void): void { + const run = this.run; + if (!run) { + fn(); + return; + } + const shown = this.view.read(); + this.view.write(run.to); + fn(); + const to = this.view.read(); + const left = moved(shown, run.to, to); + run.from = moved(run.from, run.to, to); + run.to = to; + this.view.write(left); + run.left = this.view.read(); + requestRender(); } /** Puts the view where the transition was heading, without drawing – before it is saved. */ @@ -141,7 +243,7 @@ export class ViewTween { return; } const t = (this.clock.now() - run.start) / run.ms; - this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, easeOut(t))); + this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, glide(t, run.speed))); run.left = this.view.read(); if (t >= 1) this.stop(); this.view.draw(); diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 2fa0f418..3a76740f 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -133,17 +133,21 @@ 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 }; + this.tween.target(() => { + this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y }; + }); e.stopPropagation(); } /** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */ protected panTo(e: FederatedPointerEvent): boolean { - if (!this.panning) return false; - const scale = this.worldScale(); - this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / scale; - this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / scale; + const { panning } = this; + if (!panning) return false; + this.tween.carry(() => { + const scale = this.worldScale(); + this.view.x = panning.viewX - (e.global.x - panning.startX) / scale; + this.view.y = panning.viewY - (e.global.y - panning.startY) / scale; + }); this.moveCamera(); return true; } diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts index de4032c9..d61e5356 100644 --- a/tests/editor/view-tween.test.ts +++ b/tests/editor/view-tween.test.ts @@ -11,7 +11,10 @@ import { zoomedAbout } from '../../src/editor/graph/graphView'; import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView'; import { axisTween, + carriedSpeed, easeOut, + glide, + glideSlope, scrollTween, tweenView, type ViewMap, @@ -178,7 +181,7 @@ describe('the transition of a view', () => { expect(clock.attached).toBe(0); }); - it('lands on the target without drawing, and finishes by drawing it', () => { + it('lands on the target without drawing', () => { const clock = fakeClock(); const { s, target } = surface(); const tween = new ViewTween(target, () => 200, clock); @@ -188,12 +191,82 @@ describe('the transition of a view', () => { 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); }); + + /** How fast the scale moves, in ratio per ms, over the next `ms`. */ + const pace = (s: { view: ViewMap }, clock: ReturnType, ms: number) => { + const before = s.view.scale; + clock.advance(ms); + return Math.log(s.view.scale / before) / ms; + }; + + it('goes on at the pace it had when a quick step comes in, instead of starting over', () => { + const clock = fakeClock(); + const { s, target } = surface(); + const tween = new ViewTween(target, () => 200, clock); + tween.change(double(s)); + clock.advance(48); + const before = pace(s, clock, 2); + tween.change(double(s)); + const after = pace(s, clock, 2); + expect(after / before).toBeGreaterThan(0.9); + expect(after / before).toBeLessThan(1.1); + clock.advance(200); + expect(s.view.scale).toBe(4); + }); + + it('pans along during a transition: the rest of the way moves with the pan, and it lands moved', () => { + const clock = fakeClock(); + const { s, target } = surface(); + const tween = new ViewTween(target, () => 200, clock); + tween.change(double(s)); + clock.advance(60); + let hold = { x: 0, y: 0 }; + tween.target(() => (hold = { x: s.view.x, y: s.view.y })); + expect(hold).toEqual({ x: 0, y: 0 }); + const shown = { ...s.view }; + tween.carry(() => (s.view = { ...s.view, x: hold.x + 30, y: hold.y - 10 })); + expect(s.view).toEqual({ ...shown, x: shown.x + 30, y: shown.y - 10 }); + expect(tween.moving).toBe(true); + clock.advance(16); + expect(tween.moving).toBe(true); + expect(s.view.scale).toBeGreaterThan(shown.scale); + clock.advance(200); + expect(s.view).toEqual({ scale: 2, x: 30, y: -10 }); + }); +}); + +describe('the curve of a transition', () => { + it('starts at the speed it is given and settles at the end', () => { + for (const speed of [0, 1.5, 3, 4]) { + expect(glide(0, speed)).toBe(0); + expect(glide(1, speed)).toBe(1); + expect(glideSlope(0, speed)).toBeCloseTo(speed, 12); + expect(glideSlope(1, speed)).toBe(0); + expect((glide(1e-6, speed) - glide(0, speed)) / 1e-6).toBeCloseTo(speed, 4); + } + }); + + it('never runs back or past its end up to the steepest start', () => { + for (const speed of [0, 2, 4]) { + let last = 0; + for (let i = 1; i <= 100; i++) { + const share = glide(i / 100, speed); + expect(share).toBeGreaterThanOrEqual(last); + expect(share).toBeLessThanOrEqual(1); + last = share; + } + } + }); + + it('carries the pace of a slide over to a longer slide the same way, and starts at rest against it', () => { + const old = { from: { scale: 1, x: 0, y: 0 }, to: { scale: 1, x: 100, y: 0 } }; + const from = tweenView(old.from, old.to, 0.5); + // 0.01 of 100 px per ms is 1 px/ms; over 200 ms on a way of 150 px that is a speed of 4/3. + expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: 200, y: 0 }, 200)).toBeCloseTo(4 / 3, 6); + expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: -50, y: 0 }, 200)).toBe(0); + }); }); describe('a view whose axes zoom apart (#377)', () => { diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index 2458f768..7dcecde5 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -471,7 +471,28 @@ export async function topbar(t) { 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. + // A pan during the transition takes it along instead of jumping to its end. + const by = { x: 40, y: 30 }; + const moved = { x: at.x + by.x, y: at.y + by.y }; + await t.mouse('mouseMoved', at.x, at.y); + await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 }); + await t.drag(at.x, at.y, moved.x, moved.y, { steps: 4, button: 'middle' }); + const panned = await json('__gm.sceneView()'); + await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended after the pan' }); + const settled = await json('__gm.sceneView()'); + const heldAfter = await json(`__gm.sceneWorldAt(${moved.x}, ${moved.y})`); + check( + 'a pan during the transition carries it on: still on its way, then landed one step further, moved by the pan', + panned.moving === true && + panned.zoom > landed.zoom && + panned.zoom < settled.zoom && + settled.zoom > landed.zoom && + samePoint(heldAfter, start.world), + JSON.stringify({ landed, panned, settled, heldAfter, world: start.world }), + ); + // Handed back as found: no transition for the steps after this one, and the view it had. await t.evaluate('__gm.zoomTransition(0)'); + await t.drag(moved.x, moved.y, at.x, at.y, { steps: 4, button: 'middle' }); + await t.wheel(at.x, at.y, 100); await t.wheel(at.x, at.y, 100); }