/** The viewport transforms: plain geometry, so pinned here rather than through the browser. */ import { describe, expect, it } from 'vitest'; import { cameraFactor, cameraView, FREE_ZOOM_STEPS, freeView, freeViewOfCamera, GAME_SCALE_STEPS, gameScaleFactor, isInside, nearestZoomStep, screenToWorld, stepFreeZoom, wheelSteps, worldBounds, worldToScreen, zoomStepBelow, } from '../../src/viewport/view'; describe('freeView', () => { it('puts the view centre in the middle of the widget', () => { const view = freeView(50, -20, 2, 800, 600); expect(worldToScreen(view, 50, -20)).toEqual({ x: 400, y: 300 }); expect(screenToWorld(view, 400, 300)).toEqual({ x: 50, y: -20 }); }); it('scales distances by the zoom', () => { const view = freeView(0, 0, 2, 800, 600); expect(worldToScreen(view, 100, 0)).toEqual({ x: 600, y: 300 }); }); it('round-trips any point', () => { const view = freeView(13, -7, 0.35, 640, 480); const back = screenToWorld(view, ...(Object.values(worldToScreen(view, 123, -456)) as [number, number])); expect(back.x).toBeCloseTo(123, 9); expect(back.y).toBeCloseTo(-456, 9); }); it('covers the widget in world units', () => { const bounds = worldBounds(freeView(0, 0, 2, 800, 600)); expect(bounds).toEqual({ left: -200, top: -150, right: 200, bottom: 150 }); }); }); 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(32); expect(stepFreeZoom(64, 1)).toBe(64); 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(40)).toBe(32); expect(zoomStepBelow(100)).toBe(64); expect(nearestZoomStep(1.37)).toBe(1); expect(nearestZoomStep(0.4)).toBe(0.5); }); it('a mouse notch is a step, a trackpad gathers travel, and turning drops what was left', () => { const wheel = wheelSteps(); expect(wheel(-100)).toBe(1); expect(wheel(100)).toBe(-1); expect([wheel(-20), wheel(-20), wheel(-20)]).toEqual([0, 0, 1]); expect(wheel(-40)).toBe(0); // The 40 up still pending are dropped: 40 down alone are no step yet, 60 are. expect(wheel(40)).toBe(0); expect(wheel(20)).toBe(-1); }); }); describe('cameraView', () => { const camera = { x: 10, y: 20, props: { width: 640, height: 360 } }; it('fits the camera frame into the widget and centres it', () => { // 640×360 into 800×600: width is the tighter fit (1.25 vs 1.67), so bars top and bottom. const view = cameraView(camera, 800, 600); expect(view.scale).toBeCloseTo(1.25, 9); expect(view.rect).toEqual({ x: 0, y: 75, w: 800, h: 450 }); // The camera position sits at the centre of the frame, not of the widget. expect(worldToScreen(view, 10, 20)).toEqual({ x: 400, y: 300 }); }); it('reports the letterbox as outside', () => { const view = cameraView(camera, 800, 600); expect(isInside(view, 400, 300)).toBe(true); expect(isInside(view, 400, 20)).toBe(false); // top bar expect(isInside(view, 400, 580)).toBe(false); // bottom bar }); it('draws a fixed step at exactly that many pixels per unit, starting on a whole pixel', () => { const view = cameraView(camera, 801, 601, 0.5); expect(view.scale).toBe(0.5); expect(view.rect).toEqual({ x: 241, y: 211, w: 320, h: 180 }); }); it('cuts a frame larger than the view to the view, keeping the camera in the middle', () => { const view = cameraView(camera, 800, 600, 2); expect(view.scale).toBe(2); expect(view.rect).toEqual({ x: 0, y: 0, w: 800, h: 600 }); expect(worldToScreen(view, 10, 20)).toEqual({ x: 400, y: 300 }); expect(worldBounds(view)).toEqual({ left: -190, top: -130, right: 210, bottom: 170 }); expect(isInside(view, 1, 1)).toBe(true); }); }); describe('cameraView with a zoomed camera', () => { const zoomed = { x: 0, y: 0, props: { width: 640, height: 360, zoom: 2 } }; it('100% shows the camera picture pixel for pixel, the zoom magnifies the world on top', () => { const view = cameraView(zoomed, 800, 600, 1); expect(view.scale).toBe(2); expect(view.rect).toEqual({ x: 80, y: 120, w: 640, h: 360 }); }); it('fit and auto size the picture, not the world region', () => { expect(cameraFactor(zoomed.props, 800, 600, 'fit')).toBeCloseTo(1.25, 9); expect(cameraView(zoomed, 800, 600, 'fit').scale).toBeCloseTo(2.5, 9); expect(cameraFactor(zoomed.props, 800, 600, 'auto')).toBe(1); }); }); 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('freeViewOfCamera', () => { const cases = [ { camera: { x: 10, y: 20, props: { width: 640, height: 360, zoom: 1 } }, w: 800, h: 600, ratio: 1 }, { camera: { x: -33.5, y: 7, props: { width: 640, height: 360, zoom: 1.37 } }, w: 801, h: 601, ratio: 1.25, }, { camera: { x: 0, y: 0, props: { width: 320, height: 180, zoom: 2 } }, w: 1111, h: 555, ratio: 1.5 }, ]; const modes = ['auto', 'fit', 1, 0.5] as const; it('puts every world point on the screen pixel the game view puts it on', () => { for (const { camera, w, h, ratio } of cases) { for (const mode of modes) { const free = freeViewOfCamera(camera, w, h, mode, ratio); const scene = freeView(free.x, free.y, free.zoom, w, h, ratio); const game = cameraView(camera, w, h, mode, ratio); for (const [wx, wy] of [ [camera.x, camera.y], [camera.x - 300, camera.y + 170], [123, -45], ] as const) { const a = worldToScreen(scene, wx, wy); const b = worldToScreen(game, wx, wy); expect(a.x).toBeCloseTo(b.x, 9); expect(a.y).toBeCloseTo(b.y, 9); } } } }); it('keeps the whole step auto picks, so pixel art stays even', () => { expect(freeViewOfCamera(cases[0]!.camera, 1300, 800, 'auto').zoom).toBe(2); }); }); describe('gameScaleFactor', () => { it('fit is the exact factor that fills the tighter side', () => { expect(gameScaleFactor('fit', 640, 360, 800, 600)).toBeCloseTo(1.25, 9); }); it('auto takes the largest step that still fits', () => { expect(gameScaleFactor('auto', 640, 360, 800, 600)).toBe(1); expect(gameScaleFactor('auto', 640, 360, 1280, 720)).toBe(2); expect(gameScaleFactor('auto', 640, 360, 639, 600)).toBe(0.5); expect(gameScaleFactor('auto', 320, 180, 2000, 2000)).toBe(4); }); it('auto falls back to the smallest step when not even that fits', () => { expect(gameScaleFactor('auto', 4000, 4000, 800, 600)).toBe(0.25); }); it('a fixed step ignores the view size', () => { for (const step of GAME_SCALE_STEPS) expect(gameScaleFactor(step, 640, 360, 10, 10)).toBe(step); }); });