diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 88b49742..3a9edde1 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -2,25 +2,35 @@ import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js'; import { footprintExtent } from '../../core/properties/bounds'; import { boundsOf } from '../framing'; import type { EditorState } from '../state'; -import { clamp, zoomFactor } from '../../ui'; -import { freeView, screenToWorld, type View } from '../../viewport/view'; +import { ui } from '../../ui'; +import { + freeView, + nearestZoomStep, + screenToWorld, + stepFreeZoom, + type View, + zoomStepBelow, +} from '../../viewport/view'; import { WorldViewport } from './WorldViewport'; -const MIN_ZOOM = 0.1; -const MAX_ZOOM = 8; +/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */ +const WHEEL_STEP = 50; /** Breathing room left around the content by `fit()`, in screen pixels. */ const FIT_MARGIN = 40; /** * A world view with a camera of its own – the Scene and Container tabs and the Animation tab's * preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content. + * The zoom only takes whole steps in device pixels (`FREE_ZOOM_STEPS`), so pixel art stays even. */ export abstract class FreeViewport extends WorldViewport { - /** World point at the viewport centre, and zoom. */ + /** World point at the viewport centre, and zoom in device pixels per world unit. */ protected view = { x: 0, y: 0, zoom: 1 }; private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null; /** 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 }; + /** Wheel travel not yet spent on a step; reset when the direction turns. */ + private wheel = 0; protected constructor(state: EditorState) { super(state); @@ -43,10 +53,8 @@ export abstract class FreeViewport extends WorldViewport { this.view = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, - zoom: clamp( - Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height), - MIN_ZOOM, - MAX_ZOOM, + zoom: zoomStepBelow( + Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height) * this.ratio(), ), }; this.redraw(); @@ -68,8 +76,8 @@ export abstract class FreeViewport extends WorldViewport { if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) { const before = this.screenOrigin; const after = this.getGlobalPosition(); - this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.view.zoom; - this.view.y += (after.y + h / 2 - (before.y + this.h / 2)) / this.view.zoom; + this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.worldScale(); + this.view.y += (after.y + h / 2 - (before.y + this.h / 2)) / this.worldScale(); } super.resize(w, h); this.screenOrigin = this.getGlobalPosition(); @@ -81,7 +89,8 @@ export abstract class FreeViewport extends WorldViewport { } restoreView(view: unknown): void { - this.view = { ...(view as FreeViewport['view']) }; + const saved = view as FreeViewport['view']; + this.view = { ...saved, zoom: nearestZoomStep(saved.zoom) }; this.redraw(); } @@ -90,7 +99,17 @@ export abstract class FreeViewport extends WorldViewport { } protected override currentView(): View { - return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h); + return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h, this.ratio()); + } + + /** CSS pixels per world unit, for pointer distances. */ + protected worldScale(): number { + return this.view.zoom / this.ratio(); + } + + /** Device pixels per CSS pixel: 1.25 under Windows scaling at 125%. */ + private ratio(): number { + return ui().app.renderer.resolution; } protected startPan(e: FederatedPointerEvent): void { @@ -101,9 +120,9 @@ export abstract class FreeViewport extends WorldViewport { /** 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 { zoom } = this.view; - this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / zoom; - this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / zoom; + 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; this.moveCamera(); return true; } @@ -112,15 +131,21 @@ export abstract class FreeViewport extends WorldViewport { this.panning = null; } - /** Zooms about the pointer, so the world point under it stays under it. */ + /** Zooms a step about the pointer, so the world point under it stays under it. */ private zoomAt(e: FederatedWheelEvent): void { + e.preventDefault(); + if (Math.sign(e.deltaY) !== Math.sign(this.wheel)) this.wheel = 0; + this.wheel += e.deltaY; + if (Math.abs(this.wheel) < WHEEL_STEP) return; + const zoom = stepFreeZoom(this.view.zoom, this.wheel < 0 ? 1 : -1); + this.wheel = 0; + if (zoom === this.view.zoom) return; const local = this.toLocal(e.global); const before = screenToWorld(this.currentView(), local.x, local.y); - this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM); + 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(); - e.preventDefault(); } } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 67f0dfeb..87a7ebe8 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -249,8 +249,8 @@ export class SceneViewport extends FreeViewport { return; if (this.dragging) { const local = this.toLocal(e.global); - const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; - const dy = (local.y - this.dragging.pointer.y) / this.view.zoom; + const dx = (local.x - this.dragging.pointer.x) / this.worldScale(); + const dy = (local.y - this.dragging.pointer.y) / this.worldScale(); if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return; this.dragging.moved = true; this.state.heldObjectIds = new Set(this.dragging.items.map((item) => item.objectId)); @@ -666,7 +666,7 @@ export class SceneViewport extends FreeViewport { private syncGrid(): void { this.grid.clear(); - const step = GRID_STEP * this.view.zoom; + const step = GRID_STEP * this.worldScale(); if (step < MIN_GRID_STEP) return; const origin = worldToScreen(this.currentView(), 0, 0); drawGrid(this.grid, { diff --git a/src/viewport/view.ts b/src/viewport/view.ts index a1d6e9db..330bfbf7 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -12,9 +12,40 @@ export interface View { rect: { x: number; y: number; w: number; h: number }; } -/** Free-panning view: the whole viewport is the rectangle. */ -export function freeView(x: number, y: number, zoom: number, w: number, h: number): View { - return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } }; +/** + * Free-panning view: the whole viewport is the rectangle. `zoom` counts device pixels per world + * unit and `ratio` device pixels per CSS pixel, so zoom 1 is one screen pixel per world pixel even + * under Windows scaling. + */ +export function freeView(x: number, y: number, zoom: number, w: number, h: number, ratio = 1): View { + return { x, y, scale: zoom / ratio, rect: { x: 0, y: 0, w, h } }; +} + +/** + * The zoom steps of the scene view, in device pixels per world pixel. Each gives every texel the + * same number of screen pixels, so pixel art never shows one pixel wider than its neighbour. + */ +export const FREE_ZOOM_STEPS = [0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 16, 24] as const; + +/** The largest step at or below `zoom`, never below the smallest (fit rounds down to it). */ +export function zoomStepBelow(zoom: number): number { + return [...FREE_ZOOM_STEPS].reverse().find((step) => step <= zoom + 1e-9) ?? FREE_ZOOM_STEPS[0]; +} + +/** The step nearest to `zoom` on a log scale, for a zoom saved before the steps or on another screen. */ +export function nearestZoomStep(zoom: number): number { + const distance = (step: number) => Math.abs(Math.log(step / Math.max(zoom, 1e-9))); + return FREE_ZOOM_STEPS.reduce((best, step) => (distance(step) < distance(best) ? step : best)); +} + +/** The next step up (`direction` 1) or down (-1) from `zoom`; stays at the ends. */ +export function stepFreeZoom(zoom: number, direction: 1 | -1): number { + const at = FREE_ZOOM_STEPS.indexOf(nearestZoomStep(zoom) as (typeof FREE_ZOOM_STEPS)[number]); + return FREE_ZOOM_STEPS[clampIndex(at + direction)]!; +} + +function clampIndex(index: number): number { + return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index)); } /** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */ diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index e687ce0b..a6eec78c 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -3,13 +3,17 @@ import { describe, expect, it } from 'vitest'; import { cameraFactor, cameraView, + FREE_ZOOM_STEPS, freeView, GAME_SCALE_STEPS, gameScaleFactor, isInside, + nearestZoomStep, screenToWorld, + stepFreeZoom, worldBounds, worldToScreen, + zoomStepBelow, } from '../../src/viewport/view'; describe('freeView', () => { @@ -37,6 +41,38 @@ describe('freeView', () => { }); }); +describe('free view zoom steps under Windows scaling', () => { + it('zoom counts device pixels, so zoom 1 at 125% is one screen pixel per world pixel', () => { + const view = freeView(0, 0, 1, 800, 600, 1.25); + expect(view.scale * 1.25).toBe(1); + expect(freeView(0, 0, 3, 800, 600, 1.25).scale * 1.25).toBeCloseTo(3, 12); + }); + + it('every step gives each texel a whole number of device pixels, or a whole fraction below 1', () => { + for (const step of FREE_ZOOM_STEPS) + expect(step >= 1 ? Number.isInteger(step) : Number.isInteger(1 / step)).toBe(true); + }); + + it('the wheel moves one step and stays at the ends', () => { + expect(stepFreeZoom(1, 1)).toBe(2); + expect(stepFreeZoom(2, -1)).toBe(1); + expect(stepFreeZoom(1, -1)).toBe(0.5); + expect(stepFreeZoom(6, 1)).toBe(8); + expect(stepFreeZoom(24, 1)).toBe(24); + expect(stepFreeZoom(0.125, -1)).toBe(0.125); + }); + + it('fit rounds down to a step, and a saved zoom snaps to the nearest', () => { + expect(zoomStepBelow(2.9)).toBe(2); + expect(zoomStepBelow(0.7)).toBe(0.5); + expect(zoomStepBelow(0.01)).toBe(0.125); + expect(zoomStepBelow(20)).toBe(16); + expect(zoomStepBelow(100)).toBe(24); + expect(nearestZoomStep(1.37)).toBe(1); + expect(nearestZoomStep(0.4)).toBe(0.5); + }); +}); + describe('cameraView', () => { const camera = { x: 10, y: 20, props: { width: 640, height: 360 } };