Let the game view show the picture at a chosen scale (#225)
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Failing after 9m28s

The game view always stretched the camera picture to fit the pane.
Pixel art came out blurred at odd factors, and there was no way to
see the game at its real size. The view now has a header with a
scale drop-down, and 100 % is the default.

- The header of the new GamePane offers Auto, Fit, 25 %, 50 %,
  100 %, 200 % and 400 %. Auto and Fit show the factor they land
  on, e.g. "Auto · 100%", and follow the pane when it resizes.
- 100 % means one screen pixel per pixel of the camera resolution
  (width x height). The camera zoom scales the world on top of
  that, so a zoomed camera still fills the same frame.
- Auto takes the largest fixed step that fits the whole picture
  and falls back to 25 % when nothing fits. Fit keeps the old
  behaviour.
- A picture larger than the pane is cropped around the camera
  centre. Fixed steps start on a whole pixel so pixel art stays
  sharp at 100 %.
- The choice is kept in the browser like "Play starts". Nothing
  is written into the project.

Found on the way: the first draft applied the step to the world
instead of the camera resolution. The camera zoom then had no
effect at 100 %, which the existing check 12c caught (zoom 1.37
measured as scale 1).

The CI smokes were not run locally, because that would reseed
projects the user works in. All three smoke cameras are 640x360
and fit the 670x383 harness view at 100 %.

Tests:
- ./check.sh: 156 files, 2298 tests, CHECK EXIT 0 (before 2290).
- 8 unit tests in tests/viewport/view.test.ts for whole-pixel
  steps, cropping, Auto and Fit with fallback, and zoomed cameras.
- 7 e2e checks in 03g. e2e 1-17,28,30,38-40: ALL PASSED, 727
  checks.
- Counter-checks: no cropping, no rounding, no factor reported to
  the header, and no zoom share each fail their own tests.
- Check total: 1367 (CI at c1a702b) + 7 = 1374, computed only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-24 07:02:48 +02:00
1 parent c1a702bc39
commit 9ef360ba1b
10 files changed
+291 -21

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1367 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1374 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+7 -4
View File
@@ -16,10 +16,13 @@ export interface WorldRect {
*/
export function cameraFrame(props: PropertyBag): { w: number; h: number } {
const zoom = Math.max(0.01, Number(props.zoom ?? DEFAULTS.zoom));
return {
w: Number(props.width ?? DEFAULTS.width) / zoom,
h: Number(props.height ?? DEFAULTS.height) / zoom,
};
const size = cameraSize(props);
return { w: size.w / zoom, h: size.h / zoom };
}
/** The picture the camera produces, in its own pixels – what the game view's 100% shows 1:1. */
export function cameraSize(props: PropertyBag): { w: number; h: number } {
return { w: Number(props.width ?? DEFAULTS.width), h: Number(props.height ?? DEFAULTS.height) };
}
export function rectAround(cx: number, cy: number, w: number, h: number): WorldRect {
+8 -1
View File
@@ -28,6 +28,7 @@ import { TabStrip, type TabPlace } from './TabStrip';
import type { AssetTabEditor } from './AssetTabEditor';
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents';
import { ConsolePanel } from './console/ConsolePanel';
import { GamePane } from './viewport/GamePane';
import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
import { TAB_OF_ASSET } from './assetTabs';
@@ -183,6 +184,7 @@ export class Editor implements PlayHost {
readonly viewport: SceneViewport;
/** Game tab: the running world through its camera. */
readonly gameView: GameViewport;
readonly gamePane: GamePane;
readonly graphEditor: GraphEditor;
readonly stateMachineEditor: StateMachineEditor;
readonly shaderEditor: ShaderEditor;
@@ -258,6 +260,7 @@ export class Editor implements PlayHost {
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
this.gamePane = new GamePane(this.gameView);
this.graphEditor = new GraphEditor(state);
this.stateMachineEditor = new StateMachineEditor(state);
this.shaderEditor = new ShaderEditor(state);
@@ -373,7 +376,7 @@ export class Editor implements PlayHost {
};
this.panes = {
scene: this.sceneRow,
game: this.gameView,
game: this.gamePane,
...this.assetEditors,
list: this.listEditor,
management: this.managementPane,
@@ -391,6 +394,7 @@ export class Editor implements PlayHost {
this.centre.add(this.panes[tab], 1);
this.panes[tab].visible = tab === 'scene';
}
this.gameView.visible = false;
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
// the workspace: switching tabs must not move it.
this.centre.add(this.consoleSplitter);
@@ -693,9 +697,12 @@ export class Editor implements PlayHost {
for (const [key, pane] of this.pluginPanes) pane.visible = key === tab;
// The view checks its own flag before it redraws, and the row around it is what switches now.
this.viewport.visible = tab === 'scene';
this.gameView.visible = tab === 'game';
this.syncTilemapPanel();
this.tabs.setActive(strip);
this.centre.relayout();
// Hidden, it skipped every change; its pane may come back at the same size and not relayout.
this.gameView.redraw();
ui().focus.validate();
}
+12 -1
View File
@@ -8,7 +8,7 @@ import { centreOf, type HookContext } from './hookHelpers';
/** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */
export function sceneHooks({ state, editor }: HookContext) {
const { viewport, gameView, containerEditor } = editor;
const { viewport, gameView, gamePane, containerEditor } = editor;
/**
* A holder's objects the way the editor sees them: the content of placed containers is resolved
* from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`).
@@ -72,6 +72,17 @@ export function sceneHooks({ state, editor }: HookContext) {
gameScreen: (id: string) => gameView.objectScreen(id),
gameRect: (id: string) => gameView.objectRect(id),
gameCenter: () => centreOf(gameView),
/** The game view's scale mode and what it came to, the view's box and the picker in its header (#225). */
gameScale: () => {
const origin = gameView.getGlobalPosition();
return {
mode: gameView.scaleMode,
...gameView.scaleInfo(),
view: { x: origin.x, y: origin.y, w: gameView.w, h: gameView.h },
label: gamePane.scalePicker.text,
picker: centreOf(gamePane.scalePicker),
};
},
/** Which typeface a text ended up drawn in, and how wide it made it. */
textInfo: (id: string) => viewport.textInfo(id),
gameTextInfo: (id: string) => gameView.textInfo(id),
+7 -2
View File
@@ -7,6 +7,8 @@
* gets the defaults and keeps working – a setting is not worth an error.
*/
import { GAME_SCALE_STEPS, type GameScale } from '../viewport/view';
const KEY = 'game-maker.editor';
/** Which scene Play starts: the one named as the start scene, or whatever is open right now. */
@@ -14,9 +16,10 @@ export type PlayFrom = 'start' | 'active';
export interface EditorSettings {
playFrom: PlayFrom;
gameScale: GameScale;
}
const DEFAULTS: EditorSettings = { playFrom: 'start' };
const DEFAULTS: EditorSettings = { playFrom: 'start', gameScale: 1 };
let current: EditorSettings | null = null;
@@ -27,8 +30,10 @@ export function settings(): EditorSettings {
const raw = window.localStorage.getItem(KEY);
const read: unknown = raw ? JSON.parse(raw) : null;
if (read && typeof read === 'object') {
const { playFrom } = read as Partial<EditorSettings>;
const { playFrom, gameScale } = read as Partial<EditorSettings>;
if (playFrom === 'start' || playFrom === 'active') current.playFrom = playFrom;
if (gameScale === 'auto' || gameScale === 'fit' || GAME_SCALE_STEPS.includes(gameScale as number))
current.gameScale = gameScale as GameScale;
}
} catch {
// Unreadable or refused: the defaults stand.
+47
View File
@@ -0,0 +1,47 @@
import { Dropdown, Menu, type MenuItem, VStack } from '../../ui';
import { GAME_SCALE_STEPS, type GameScale } from '../../viewport/view';
import { TabHeader } from '../TabHeader';
import type { GameViewport } from './GameViewport';
/** Room for the longest label, "Auto · 400%". */
const SCALE_WIDTH = 110;
/** The game tab (#225): a header with the scale picker above the game view. */
export class GamePane extends VStack {
readonly header = new TabHeader();
/** Public for the check scripts. */
readonly scalePicker: Dropdown;
constructor(readonly view: GameViewport) {
super();
this.scalePicker = new Dropdown(scaleLabel(view.scaleMode, null), {
width: SCALE_WIDTH,
onClick: () => this.openScalePicker(),
});
this.header.add(this.scalePicker);
this.add(this.header);
this.add(view, 1);
view.onScaleShown = (factor) => this.scalePicker.setText(scaleLabel(view.scaleMode, factor));
}
private openScalePicker(): void {
const modes: GameScale[] = ['auto', 'fit', ...GAME_SCALE_STEPS];
const items: MenuItem[] = modes.map((mode) => ({
label: scaleLabel(mode, null),
current: mode === this.view.scaleMode,
onSelect: () => this.view.setScaleMode(mode),
}));
new Menu(items).openBelow(this.scalePicker);
}
}
/** Auto and fit also say what they came to, once there is a camera to size. */
export function scaleLabel(mode: GameScale, factor: number | null): string {
if (typeof mode === 'number') return percent(mode);
const name = mode === 'auto' ? 'Auto' : 'Fit';
return factor === null ? name : `${name} · ${percent(factor)}`;
}
function percent(factor: number): string {
return `${Math.round(factor * 100)}%`;
}
+44 -5
View File
@@ -1,5 +1,5 @@
/**
* The scene seen through its active camera, letterboxed into the widget: the running world while
* The scene seen through its active camera at the chosen scale, letterboxed: the running world while
* playing, the model as a still preview otherwise.
*/
import { type FederatedPointerEvent, Text } from 'pixi.js';
@@ -9,7 +9,15 @@ import type { World } from '../../runtime/World';
import { textStyle } from '../../ui';
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
import { cameraView, isInside, screenToWorld, type View } from '../../viewport/view';
import {
cameraFactor,
cameraView,
type GameScale,
isInside,
screenToWorld,
type View,
} from '../../viewport/view';
import { settings, updateSettings } from '../settings';
import { type Frame, WorldViewport } from './WorldViewport';
const LETTERBOX_COLOR = 0x000000;
@@ -23,6 +31,10 @@ 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. */
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;
constructor(state: EditorState) {
super(state);
@@ -61,17 +73,27 @@ export class GameViewport extends WorldViewport {
/**
* Skipped while the tab is hidden: the preview costs a collect of the whole scene and would
* otherwise run on every selection change behind another tab. Showing the tab relayouts the
* stack, which resizes this widget and thereby redraws it.
* otherwise run on every selection change behind another tab. Showing the tab redraws it.
*/
override redraw(): void {
if (!this.visible) return;
super.redraw();
}
get scaleMode(): GameScale {
return settings().gameScale;
}
/** Remembered in this browser, like which scene Play starts. */
setScaleMode(mode: GameScale): void {
updateSettings({ gameScale: mode });
this.redraw();
}
protected override currentView(): View | null {
const camera = this.activeCamera();
return camera ? cameraView(camera, this.w, this.h) : null;
this.factor = camera ? cameraFactor(camera.props, this.w, this.h, this.scaleMode) : null;
return camera ? cameraView(camera, this.w, this.h, this.scaleMode) : null;
}
private activeCamera(): ActiveCamera | null {
@@ -83,6 +105,22 @@ export class GameViewport extends WorldViewport {
return collectHolderItems(this.state.project, this.state.activeScene, LETTERBOX_COLOR, this.editorWorld);
}
/**
* 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.
*/
scaleInfo(): {
factor: number;
scale: number;
frame: { x: number; y: number; w: number; h: number };
} | null {
const view = this.currentView();
if (!view || this.factor === null) return null;
const origin = this.getGlobalPosition();
const { x, y, w, h } = view.rect;
return { factor: this.factor, scale: view.scale, frame: { x: origin.x + x, y: origin.y + y, w, h } };
}
/** `camera`: object id of the camera the view is through, empty when none is switched on. */
override debugInfo(): ReturnType<WorldViewport['debugInfo']> & { camera: string } {
return { ...super.debugInfo(), camera: this.activeCamera()?.id ?? '' };
@@ -100,6 +138,7 @@ export class GameViewport extends WorldViewport {
}
protected override beforeItems(view: View | null): void {
this.onScaleShown?.(view ? this.factor : null);
this.hint.visible = view === null;
this.hint.position.set(this.w / 2, this.h / 2);
// Which tab holds the keyboard is otherwise nowhere to be seen.
+44 -7
View File
@@ -1,5 +1,5 @@
import type { PropertyBag, Vec2 } from '../core/model/Properties';
import { cameraFrame, rectAround, type WorldRect } from '../core/properties/camera';
import { cameraFrame, cameraSize, rectAround, type WorldRect } from '../core/properties/camera';
/**
* The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local
@@ -17,13 +17,50 @@ export function freeView(x: number, y: number, zoom: number, w: number, h: numbe
return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } };
}
/** The camera's frame fitted into `w`×`h` and centred; the remaining margin is letterbox. */
export function cameraView(camera: { x: number; y: number; props: PropertyBag }, w: number, h: number): View {
/** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */
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);
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 {
const size = cameraSize(props);
return gameScaleFactor(mode, size.w, size.h, w, h);
}
/**
* 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
* 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.
*/
export function cameraView(
camera: { x: number; y: number; props: PropertyBag },
w: number,
h: number,
mode: GameScale = 'fit',
): View {
const size = cameraSize(camera.props);
const frame = cameraFrame(camera.props);
const scale = Math.min(w / frame.w, h / frame.h);
const fw = frame.w * scale;
const fh = frame.h * scale;
return { x: camera.x, y: camera.y, scale, rect: { x: (w - fw) / 2, y: (h - fh) / 2, w: fw, h: fh } };
const factor = cameraFactor(camera.props, w, h, mode);
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;
return {
x: camera.x,
y: camera.y,
scale,
rect: { x: snap((w - fw) / 2), y: snap((h - fh) / 2), w: fw, h: fh },
};
}
export function screenToWorld(view: View, sx: number, sy: number): Vec2 {
+55
View File
@@ -1,8 +1,11 @@
/** The viewport transforms: plain geometry, so pinned here rather than through the browser. */
import { describe, expect, it } from 'vitest';
import {
cameraFactor,
cameraView,
freeView,
GAME_SCALE_STEPS,
gameScaleFactor,
isInside,
screenToWorld,
worldBounds,
@@ -52,4 +55,56 @@ describe('cameraView', () => {
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('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);
});
});
+66
View File
@@ -291,6 +291,72 @@ export async function play(t) {
info.rendering && info.camera === 'o3' && (await json(`__gm.locate('No active camera')`)) === null,
JSON.stringify(info),
);
// 3g. Game view scale (#225): 100% by default, picked in the header. A fixed step draws that
// many pixels per unit whatever the view's size, a frame larger than the view is cut to it,
// fit fills the tighter side and auto takes the largest step that still fits.
const scale = () => json('__gm.gameScale()');
const pickScale = async (label) => {
const { picker } = await scale();
await click(picker.x, picker.y);
await clickText(label);
return scale();
};
const centred = ({ frame, view }) =>
Math.abs(frame.x + frame.w / 2 - (view.x + view.w / 2)) <= 1 &&
Math.abs(frame.y + frame.h / 2 - (view.y + view.h / 2)) <= 1;
let g = await scale();
const crateAt100 = (await json(`__gm.gameRect('o1')`)).w;
check(
'the game view starts at 100%, one pixel per world unit',
g.mode === 1 && g.scale === 1 && g.label === '100%',
JSON.stringify(g),
);
g = await pickScale('25%');
const crateAt25 = (await json(`__gm.gameRect('o1')`)).w;
check(
'at 25% the 640x360 frame is 160x90, centred on a whole pixel, and the crate a quarter as wide',
g.scale === 0.25 &&
g.frame.w === 160 &&
g.frame.h === 90 &&
Number.isInteger(g.frame.x - g.view.x) &&
centred(g) &&
Math.abs(crateAt25 * 4 - crateAt100) < 0.01,
JSON.stringify({ g, crateAt25, crateAt100 }),
);
await shot('10e-game-scale-25');
g = await pickScale('400%');
check(
'at 400% the frame is larger than the view and cut to it, the camera still in the middle',
g.scale === 4 &&
JSON.stringify(g.frame) === JSON.stringify(g.view) &&
(await json(`__gm.gameRect('o1')`)).w === crateAt100 * 4,
JSON.stringify(g),
);
g = await pickScale('Fit');
const fit = Math.min(g.view.w / 640, g.view.h / 360);
check(
'fit fills the tighter side exactly and names the factor it came to',
g.mode === 'fit' &&
Math.abs(g.factor - fit) < 1e-9 &&
g.label === `Fit · ${Math.round(fit * 100)}%` &&
centred(g),
JSON.stringify(g),
);
g = await pickScale('Auto');
const auto = Math.max(0.25, ...[0.25, 0.5, 1, 2, 4].filter((step) => step <= fit));
check(
'auto takes the largest step that still fits',
g.mode === 'auto' && g.factor === auto && g.label === `Auto · ${auto * 100}%` && g.frame.w === 640 * auto,
JSON.stringify({ g, fit }),
);
check(
'the scale is remembered in this browser, not in the project',
(await evaluate(`JSON.parse(localStorage.getItem('game-maker.editor')).gameScale`)) === 'auto' &&
!(await evaluate(`JSON.stringify(__gm.state.project).includes('gameScale')`)),
);
g = await pickScale('100%');
check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g));
await showTab('scene');
check(
'the fixture is left as it was found',