diff --git a/README.md b/README.md index 49d4dcb2..efdcad47 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts index 57201e47..1f0468c4 100644 --- a/src/core/properties/camera.ts +++ b/src/core/properties/camera.ts @@ -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 { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index de24b0e3..10d86de4 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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(); } diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index d6133dbc..b3387f97 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -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), diff --git a/src/editor/settings.ts b/src/editor/settings.ts index dce7faf9..5498a6ea 100644 --- a/src/editor/settings.ts +++ b/src/editor/settings.ts @@ -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; + const { playFrom, gameScale } = read as Partial; 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. diff --git a/src/editor/viewport/GamePane.ts b/src/editor/viewport/GamePane.ts new file mode 100644 index 00000000..bb7c0968 --- /dev/null +++ b/src/editor/viewport/GamePane.ts @@ -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)}%`; +} diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index 7de2affa..7edc710e 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -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 & { 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. diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 0952aa34..b3fca308 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -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 { diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index 1a5ad1aa..4883fa3f 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -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); + }); }); diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 13ec1996..7e43460a 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -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',