import { GAME_SCALE_STEPS, type GameScale } from '../core/model/playSettings'; import type { PropertyBag, Vec2 } from '../core/model/Properties'; import { cameraFrame, cameraSize, isPixelPerfect, rectAround, type WorldRect, } from '../core/properties/camera'; import { gridSteps, lookFrom } from './pixelGrid'; /** * The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local * pixels) at `scale` pixels per world unit. `grid` is set through a pixel perfect camera: the steps * per unit its objects are drawn on. */ export interface View { x: number; y: number; scale: number; rect: { x: number; y: number; w: number; h: number }; grid?: number; } /** * 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, 32, 48, 64] 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)); } /** * A wheel event in notches of a mouse wheel, positive downwards like `deltaY` (#260). A notch is * 100 pixels (see `WHEEL_STEP`) or 3 lines; a page is counted as one notch. */ export function wheelNotches(deltaY: number, deltaMode: number): number { if (deltaMode === 1) return deltaY / 3; if (deltaMode === 2) return Math.sign(deltaY); return deltaY / 100; } /** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */ const WHEEL_STEP = 50; /** * Gathers wheel travel into whole zoom steps: 1 up, -1 down, 0 while it is not a step yet. Travel * left over is dropped when the direction turns, so a trackpad does not step back on its own. */ export function wheelSteps(): (deltaY: number) => 1 | -1 | 0 { let travel = 0; return (deltaY) => { if (Math.sign(deltaY) !== Math.sign(travel)) travel = 0; travel += deltaY; if (Math.abs(travel) < WHEEL_STEP) return 0; const direction = travel < 0 ? 1 : -1; travel = 0; return direction; }; } export { GAME_SCALE_STEPS, type GameScale }; /** * 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, ratio = 1): number { const size = cameraSize(props); 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 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 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, 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 : (v: number) => Math.round(v * ratio) / ratio; const rect = { x: snap((w - fw) / 2), y: snap((h - fh) / 2), w: fw, h: fh }; if (mode === 'fit' || !isPixelPerfect(camera.props)) return { x: camera.x, y: camera.y, scale, rect }; return onPixelGrid(camera, scale, rect, ratio); } /** A pixel perfect camera's view: the world's origin on a whole device pixel, objects on its grid. */ function onPixelGrid( camera: { x: number; y: number }, scale: number, rect: View['rect'], ratio: number, ): View { return { x: lookFrom(camera.x, rect.x + rect.w / 2, scale, ratio), y: lookFrom(camera.y, rect.y + rect.h / 2, scale, ratio), scale, rect, grid: gridSteps(scale * ratio), }; } /** * An overlay camera's picture (#302), laid on the main camera's: fitted into the main picture whole, * centred, never distorted – the same size and zoom give the very same view. What the main picture * loses to the edge of the screen the overlay loses with it. `fixed`: the main picture is at a fixed * scale rather than fit, the only one a pixel perfect overlay snaps at. */ export function overlayView( camera: { x: number; y: number; props: PropertyBag }, main: View, mainFrame: { w: number; h: number }, ratio = 1, fixed = false, ): View { const frame = cameraFrame(camera.props); const pictureW = mainFrame.w * main.scale; const pictureH = mainFrame.h * main.scale; const scale = Math.min(pictureW / frame.w, pictureH / frame.h); const w = Math.min(frame.w * scale, main.rect.w); const h = Math.min(frame.h * scale, main.rect.h); const cx = main.rect.x + main.rect.w / 2; const cy = main.rect.y + main.rect.h / 2; const rect = { x: cx - w / 2, y: cy - h / 2, w, h }; if (!fixed || !isPixelPerfect(camera.props)) return { x: camera.x, y: camera.y, scale, rect }; return onPixelGrid(camera, scale, rect, ratio); } /** * The free view (centre and zoom in device pixels) that shows the world exactly as `cameraView` * does in a view of the same size: same scale, and the camera's centre on the same screen pixel, * which is the middle of the letterboxed picture rather than the middle of the view. */ export function freeViewOfCamera( camera: { x: number; y: number; props: PropertyBag }, w: number, h: number, mode: GameScale, ratio = 1, ): { x: number; y: number; zoom: number } { const { rect, scale, x, y } = cameraView(camera, w, h, mode, ratio); return { x: x - (rect.x + rect.w / 2 - w / 2) / scale, y: y - (rect.y + rect.h / 2 - h / 2) / scale, zoom: scale * ratio, }; } export function screenToWorld(view: View, sx: number, sy: number): Vec2 { const cx = view.rect.x + view.rect.w / 2; const cy = view.rect.y + view.rect.h / 2; return { x: (sx - cx) / view.scale + view.x, y: (sy - cy) / view.scale + view.y }; } export function worldToScreen(view: View, wx: number, wy: number): Vec2 { const cx = view.rect.x + view.rect.w / 2; const cy = view.rect.y + view.rect.h / 2; return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy }; } export function worldBounds(view: View): WorldRect { return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale); } /** Whether a viewport-local point lies on the drawn rectangle rather than in the letterbox. */ export function isInside(view: View, sx: number, sy: number): boolean { const { x, y, w, h } = view.rect; return sx >= x && sx <= x + w && sy >= y && sy <= y + h; }