diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index 7edc710e..eb225fcc 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -6,7 +6,7 @@ import { type FederatedPointerEvent, Text } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; -import { textStyle } from '../../ui'; +import { textStyle, ui } from '../../ui'; import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import { collectHolderItems, collectWorldItems } from '../../viewport/items'; import { @@ -31,7 +31,7 @@ export class GameViewport extends WorldViewport { private playWorld: World | null = null; /** The editor run's world: it takes the mouse, but it is never what this view draws. */ private editorWorld: World | null = null; - /** Screen pixels per camera pixel as last drawn, null without a camera. */ + /** Device pixels per camera pixel as last drawn, null without a camera. */ private factor: number | null = null; /** Told the factor of every redraw: auto and fit change with the view. */ onScaleShown: ((factor: number | null) => void) | null = null; @@ -92,8 +92,9 @@ export class GameViewport extends WorldViewport { protected override currentView(): View | null { const camera = this.activeCamera(); - this.factor = camera ? cameraFactor(camera.props, this.w, this.h, this.scaleMode) : null; - return camera ? cameraView(camera, this.w, this.h, this.scaleMode) : null; + const ratio = ui().app.renderer.resolution; + this.factor = camera ? cameraFactor(camera.props, this.w, this.h, this.scaleMode, ratio) : null; + return camera ? cameraView(camera, this.w, this.h, this.scaleMode, ratio) : null; } private activeCamera(): ActiveCamera | null { @@ -106,8 +107,8 @@ export class GameViewport extends WorldViewport { } /** - * Screen pixels per camera pixel and per world unit, and the camera's picture as it lies on - * screen, in global pixels; null without a camera. + * Device pixels per camera pixel, CSS pixels per world unit, and the camera's picture as it + * lies on screen, in global CSS pixels; null without a camera. */ scaleInfo(): { factor: number; diff --git a/src/viewport/view.ts b/src/viewport/view.ts index b3fca308..a1d6e9db 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -22,39 +22,51 @@ export type GameScale = 'auto' | 'fit' | number; export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 4]; -/** Screen pixels per camera pixel for a camera picture of `sizeW`×`sizeH` in a `w`×`h` view. */ -export function gameScaleFactor(mode: GameScale, sizeW: number, sizeH: number, w: number, h: number): number { - const fit = Math.min(w / sizeW, h / sizeH); +/** + * Device pixels per camera pixel for a camera picture of `sizeW`×`sizeH` in a `w`×`h` view (CSS + * pixels). `ratio` is device pixels per CSS pixel, 1.25 under Windows scaling at 125%: the steps + * count device pixels, so 100% stays one screen pixel per camera pixel whatever the scaling. + */ +export function gameScaleFactor( + mode: GameScale, + sizeW: number, + sizeH: number, + w: number, + h: number, + ratio = 1, +): number { + const fit = Math.min(w / sizeW, h / sizeH) * ratio; if (mode === 'fit') return fit; if (mode !== 'auto') return mode; return Math.max(Math.min(...GAME_SCALE_STEPS), ...GAME_SCALE_STEPS.filter((step) => step <= fit)); } /** What `mode` comes to for this camera in a `w`×`h` view (the picker shows it for auto and fit). */ -export function cameraFactor(props: PropertyBag, w: number, h: number, mode: GameScale): number { +export function cameraFactor(props: PropertyBag, w: number, h: number, mode: GameScale, ratio = 1): number { const size = cameraSize(props); - return gameScaleFactor(mode, size.w, size.h, w, h); + return gameScaleFactor(mode, size.w, size.h, w, h, ratio); } /** * The camera's picture at `mode` in `w`×`h`, centred; the remaining margin is letterbox. 100% is - * one screen pixel per camera pixel, so the camera's zoom still magnifies the world on top. A + * one device pixel per camera pixel, so the camera's zoom still magnifies the world on top. A * picture larger than the view is cut to it, so the rectangle only ever covers what is on screen. - * Fixed steps start on a whole pixel, so pixel art at 100% stays sharp. + * Fixed steps start on a whole device pixel, so pixel art at 100% stays sharp. */ export function cameraView( camera: { x: number; y: number; props: PropertyBag }, w: number, h: number, mode: GameScale = 'fit', + ratio = 1, ): View { const size = cameraSize(camera.props); const frame = cameraFrame(camera.props); - const factor = cameraFactor(camera.props, w, h, mode); + const factor = cameraFactor(camera.props, w, h, mode, ratio) / ratio; const scale = (factor * size.w) / frame.w; const fw = Math.min(size.w * factor, w); const fh = Math.min(size.h * factor, h); - const snap = mode === 'fit' ? (v: number) => v : Math.round; + const snap = mode === 'fit' ? (v: number) => v : (v: number) => Math.round(v * ratio) / ratio; return { x: camera.x, y: camera.y, diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index 4883fa3f..e687ce0b 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -88,6 +88,26 @@ describe('cameraView with a zoomed camera', () => { }); }); +describe('cameraView under Windows scaling at 125%', () => { + const plain = { x: 0, y: 0, props: { width: 640, height: 360, zoom: 1 } }; + + it('100% is one device pixel per camera pixel, starting on a whole device pixel', () => { + const view = cameraView(plain, 801, 601, 1, 1.25); + expect(view.scale * 1.25).toBeCloseTo(1, 12); + expect(view.rect.w).toBeCloseTo(512, 12); + expect(view.rect.h).toBeCloseTo(288, 12); + expect(view.rect.x * 1.25).toBeCloseTo(181, 9); + expect(view.rect.y * 1.25).toBeCloseTo(196, 9); + }); + + it('auto picks the step in device pixels, and fit reports device pixels', () => { + expect(cameraFactor(plain.props, 1100, 700, 'auto', 1.25)).toBe(2); + expect(cameraFactor(plain.props, 1100, 700, 'auto')).toBe(1); + expect(cameraFactor(plain.props, 800, 600, 'fit', 1.25)).toBeCloseTo(1.5625, 9); + expect(cameraView(plain, 800, 600, 'fit', 1.25).scale).toBeCloseTo(1.25, 9); + }); +}); + describe('gameScaleFactor', () => { it('fit is the exact factor that fills the tighter side', () => { expect(gameScaleFactor('fit', 640, 360, 800, 600)).toBeCloseTo(1.25, 9);