Files
feuerwerk/src/viewport/view.ts
T
adminandClaude Opus 5.5 1665576fb9
game-maker / gate (push) Successful in 3m6s
game-maker / suite (push) Successful in 16m1s
Let a pixel perfect camera draw on the game view's pixel grid
A camera that follows smoothly stands between pixels most of the time,
and pixel art then wanders between them. The new camera switch draws
what the camera shows on the game view's own grid, while positions in
the model keep their fractions.

- `Pixel perfect` is a camera row, off by default. It works at every
  fixed game scale and at Auto, but not at Fit and not in the Scene
  tab, where one works with the fractions.
- The grid has one step per device pixel of a world unit. At 100% that
  is whole units, at 500% fifths. The view carries it (`View.grid`),
  so no view draws with the grid of another.
- The view looks from a point that puts the world's origin on a whole
  device pixel (`lookFrom`). This also covers odd camera sizes and
  views that cut the picture to an odd size.
- Sprites, texts and tilemaps snap their top left corner when they are
  unturned, so an odd size lands on whole pixels too. A turned object
  snaps its pivot (`snapPlacement` in `pixelGrid.ts`).
- Under a camera shader the picture is taken the same way, and the
  objects in it use the picture's grid. With Camera pixels that is one
  step per camera pixel.
- An overlay camera has its own switch and snaps only while the main
  picture is at a fixed scale.
- Particles are drawn where they are.
- New test hook `__gm.gameDrawnRect(id)` reads where a sprite's picture
  is drawn, which `gameRect` (the model's) cannot tell.

Found: the e2e fixture at 300% drew the crate at y 205.5 device pixels
without moving the camera, because the view cut to an odd height puts
its centre on half a pixel. With the switch on it is at 206.

Tests:
- Gate: 237 files, 3587 tests (+10 in pixel-grid.test.ts)
- e2e 2,3: ALL PASSED, 75 checks (+1, 3j)
- Counter-checks: without the origin snap 3 unit tests fail, with the
  centre snapped instead of the corner 2 fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:30:31 +02:00

225 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}