diff --git a/README.md b/README.md index 6a21746b..f654b0c7 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, 1455 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1457 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/editor/Editor.ts b/src/editor/Editor.ts index fbbffb43..c9734412 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -30,6 +30,7 @@ import type { AssetTabEditor } from './AssetTabEditor'; import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents'; import { ConsolePanel } from './console/ConsolePanel'; import { GamePane } from './viewport/GamePane'; +import { ScenePane } from './viewport/ScenePane'; import { GameViewport } from './viewport/GameViewport'; import { SceneViewport } from './viewport/SceneViewport'; import { TAB_OF_ASSET } from './assetTabs'; @@ -210,8 +211,10 @@ export class Editor implements PlayHost { /** Beside the scene view, while exactly one placed tilemap is selected there (#142). */ readonly tilemapPanel: TilemapPanel; readonly tilemapSplitter = new Splitter('x'); - /** The scene tab: its view, and the tilemap panel to its right when there is one. */ + /** The scene view, and the tilemap panel to its right when there is one. */ private readonly sceneRow = new HStack(); + /** The scene tab: its header above `sceneRow`. */ + readonly scenePane: ScenePane; readonly console = new ConsolePanel(); tab: TabId = 'scene'; /** Frame rate, refreshed twice a second; null until the first measurement. */ @@ -367,6 +370,7 @@ export class Editor implements PlayHost { }; this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel(); this.syncTilemapPanel(); + this.scenePane = new ScenePane(state, this.viewport, this.sceneRow); this.assetEditors = { graph: this.graphEditor, statemachine: this.stateMachineEditor, @@ -380,7 +384,7 @@ export class Editor implements PlayHost { container: this.containerEditor, }; this.panes = { - scene: this.sceneRow, + scene: this.scenePane, game: this.gamePane, ...this.assetEditors, list: this.listEditor, diff --git a/src/editor/TabHeader.ts b/src/editor/TabHeader.ts index e59a7d6e..36ebf5b0 100644 --- a/src/editor/TabHeader.ts +++ b/src/editor/TabHeader.ts @@ -6,8 +6,8 @@ export class TabHeader extends HStack { private readonly bg = new Graphics(); constructor() { - super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - this.minHeight = Theme.controlHeight + Theme.padding * 2; + super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' }); + this.minHeight = Theme.controlHeight + Theme.headerPadding * 2; this.addChildAt(this.bg, 0); } diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index 63e16af9..3bdb54db 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -9,7 +9,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, gamePane, containerEditor } = editor; + const { viewport, gameView, gamePane, scenePane, 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`). @@ -47,6 +47,9 @@ export function sceneHooks({ state, editor }: HookContext) { } return null; }, + /** Where to click Fit or the eye in the scene tab's header. */ + sceneHeaderAt: (name: 'fit' | 'visibility') => + centreOf(name === 'fit' ? scenePane.fitButton : scenePane.visibilityButton), viewportInfo: () => viewport.debugInfo(), /** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */ viewportRedraw: () => viewport.redraw(), @@ -66,7 +69,7 @@ export function sceneHooks({ state, editor }: HookContext) { sceneIcon: (id: string) => viewport.iconInfo(id), /** The names shown beside the symbols, each with the plate under it, in world units and on screen. */ sceneLabels: () => viewport.labelInfo(), - /** A collider's outline in the Scene tab: hidden by the eye in the top bar, it is neither seen nor hit. */ + /** A collider's outline in the Scene tab: hidden by the eye in its header, it is neither seen nor hit. */ sceneShape: (id: string) => viewport.shapeInfo(id), /** The switches behind the eye while it stands open: per type and part, on, enabled, where to click. */ visibilityPopup: () => diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index caf52591..1411e22a 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -49,7 +49,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E playFrom: () => editor.playFromInfo(), /** The two run buttons of the top bar: the picture each wears, and whether it takes a click. */ runButtons: () => editor.runButtons(), - /** Where to click one of the five icon buttons: menu, undo, redo, play, pause. */ + /** Where to click one of the icon buttons: menu, undo, redo, play, pause. */ toolbarAt: (name: keyof Editor['toolbarControls']) => centreOf(editor.toolbarControls[name]), /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ editorRun: () => editor.editorRunInfo(), diff --git a/src/editor/panels/SceneVisibilityPopup.ts b/src/editor/panels/SceneVisibilityPopup.ts index f377c65e..f8f64b0d 100644 --- a/src/editor/panels/SceneVisibilityPopup.ts +++ b/src/editor/panels/SceneVisibilityPopup.ts @@ -19,7 +19,7 @@ function gap(w: number): Spacer { } /** - * The eye in the top bar: every type that can be placed, and per type a switch for its symbol, its + * The eye in the scene tab's header: every type that can be placed, and per type a switch for its symbol, its * name and – a collider only – its outline in the scene view. A switch a type does not have is * greyed out, or missing where no other type has it either. Clicks keep it open: one usually * switches several. diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 6eb04951..aae0d5fd 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -1,7 +1,6 @@ import { Graphics } from 'pixi.js'; import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui'; import type { EditorState, SaveStatus } from '../state'; -import { SceneVisibilityPopup } from './SceneVisibilityPopup'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; /** What the toolbar buttons trigger; the editor supplies the implementations. */ @@ -38,8 +37,8 @@ function iconButton(name: IconName, onClick: () => void): Button { } /** - * Top bar (#183): menu, undo, redo and the scene view's eye on the left, play and pause in the - * middle, the account on the right – icons, no captions. Everything with words is behind the menu: the project name and + * Top bar (#183): menu, undo and redo on the left, play and pause in the middle, the account on + * the right – icons, no captions. Everything with words is behind the menu: the project name and * the start-scene switch in Project settings, the project list in Open project. The scene is * picked where the scenes are, in the Assets panel. */ @@ -48,8 +47,6 @@ export class Toolbar extends HStack { private readonly menuButton: Button; private readonly undoButton: Button; private readonly redoButton: Button; - /** What the scene view shows of each type – symbols, names, collider outlines. */ - private readonly visibilityButton: Dropdown; private readonly playButton: Button; private readonly pauseButton: Button; private readonly status: Label; @@ -63,8 +60,8 @@ export class Toolbar extends HStack { private readonly state: EditorState, actions: ToolbarActions, ) { - super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - this.minHeight = Theme.controlHeight + Theme.padding * 2; + super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' }); + this.minHeight = Theme.controlHeight + Theme.headerPadding * 2; this.addChildAt(this.bg, 0); this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton)); @@ -74,12 +71,6 @@ export class Toolbar extends HStack { this.redoButton = iconButton('redo', () => state.redo()); this.add(this.undoButton); this.add(this.redoButton); - this.add(new Spacer(Theme.padding)); - this.visibilityButton = new Dropdown('', { - icon: TOOLBAR_ICON_SVG.eye, - onClick: () => new SceneVisibilityPopup(state).openBelow(this.visibilityButton), - }); - this.add(this.visibilityButton); this.add(new Spacer(), 1); this.playButton = iconButton('play', () => actions.togglePlay()); @@ -123,12 +114,11 @@ export class Toolbar extends HStack { } /** The icon buttons, so a check can click what carries no caption. */ - get controls(): Record<'menu' | 'undo' | 'redo' | 'visibility' | 'play' | 'pause', Button> { + get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause', Button> { return { menu: this.menuButton, undo: this.undoButton, redo: this.redoButton, - visibility: this.visibilityButton, play: this.playButton, pause: this.pauseButton, }; @@ -168,7 +158,7 @@ export class Toolbar extends HStack { override layout(): void { super.layout(); const width = this.pauseButton.x + this.pauseButton.w - this.playButton.x; - const leftMost = this.visibilityButton.x + this.visibilityButton.w + this.gap; + const leftMost = this.redoButton.x + this.redoButton.w + this.gap; const rightMost = this.notice.x - this.gap - width; const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost))); const shift = x - this.playButton.x; diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 9ed919ca..8edb2d1b 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -106,7 +106,7 @@ export abstract class FreeViewport extends WorldViewport { } /** Device pixels per CSS pixel: 1.25 under Windows scaling at 125%. */ - private ratio(): number { + protected ratio(): number { return ui().app.renderer.resolution; } diff --git a/src/editor/viewport/ScenePane.ts b/src/editor/viewport/ScenePane.ts new file mode 100644 index 00000000..9103a5b7 --- /dev/null +++ b/src/editor/viewport/ScenePane.ts @@ -0,0 +1,28 @@ +import { Button, Dropdown, VStack, type Widget } from '../../ui'; +import { SceneVisibilityPopup } from '../panels/SceneVisibilityPopup'; +import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; +import type { EditorState } from '../state'; +import { TabHeader } from '../TabHeader'; +import type { SceneViewport } from './SceneViewport'; + +/** The scene tab: a header with Fit and the eye above the scene view and its tilemap panel. */ +export class ScenePane extends VStack { + readonly header = new TabHeader(); + /** Public for the check scripts. */ + readonly fitButton: Button; + /** What the scene view shows of each type – symbols, names, collider outlines. */ + readonly visibilityButton: Dropdown; + + constructor(state: EditorState, view: SceneViewport, body: Widget) { + super(); + this.fitButton = new Button('Fit', { onClick: () => view.fitScene() }); + this.visibilityButton = new Dropdown('', { + icon: TOOLBAR_ICON_SVG.eye, + onClick: () => new SceneVisibilityPopup(state).openBelow(this.visibilityButton), + }); + this.header.add(this.fitButton); + this.header.add(this.visibilityButton); + this.add(this.header); + this.add(body, 1); + } +} diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 8963afe4..dc22a19d 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -38,7 +38,8 @@ import { dashedLine } from '../../viewport/symbols'; import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons'; import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows'; import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout'; -import { screenToWorld, type View, worldToScreen } from '../../viewport/view'; +import { cameraOfItems } from '../../viewport/activeCamera'; +import { freeViewOfCamera, screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; import { FreeViewport } from './FreeViewport'; import { PolygonCornerEditor } from './polygonCorners'; @@ -224,6 +225,17 @@ export class SceneViewport extends FreeViewport { return { layerId: paintedLayerId(this.state, target.asset), mode }; } + /** + * With a camera switched on, shows the world exactly as the game view does at its scale, so + * switching between the two tabs moves nothing. Without one, frames the whole content. + */ + fitScene(): void { + const camera = cameraOfItems(this.collectFrame().items); + if (!camera) return this.fit(); + this.view = freeViewOfCamera(camera, this.w, this.h, this.state.gameScale, this.ratio()); + this.redraw(); + } + /** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */ pasteHere(): boolean { const holder = this.holder(); @@ -639,7 +651,7 @@ export class SceneViewport extends FreeViewport { return item.tilemap!.layerSolids(paintedLayerId(this.state, target.asset)); } - /** What the project's scene visibility (the eye in the top bar) says about this part of `item`. */ + /** What the project's scene visibility (the eye in the tab header) says about this part of `item`. */ private shows(item: DrawItem, part: VisibilityPart): boolean { return shows(this.state.project.sceneVisibility, item.type, part); } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 945dfbb1..37ea4f42 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -11,12 +11,12 @@ export const Theme = { rowHeight: 24, controlHeight: 24, padding: 8, + headerPadding: 4, gap: 4, radius: 2, fieldPadding: 4, checkboxSize: 16, scrollbarWidth: 4, - /** Horizontal indent per nesting level in tree lists. */ treeIndent: 12, hoverCursor: 'default', colors: { diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 7e5dc9fe..48149337 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -123,6 +123,26 @@ export function cameraView( }; } +/** + * 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 } = cameraView(camera, w, h, mode, ratio); + return { + x: camera.x - (rect.x + rect.w / 2 - w / 2) / scale, + y: camera.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; diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index 8812908e..8703a1f2 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -5,6 +5,7 @@ import { cameraView, FREE_ZOOM_STEPS, freeView, + freeViewOfCamera, GAME_SCALE_STEPS, gameScaleFactor, isInside, @@ -158,6 +159,44 @@ describe('cameraView under Windows scaling at 125%', () => { }); }); +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); diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index c93ea81b..a2eb8d51 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -361,6 +361,40 @@ export async function play(t) { (await undoSteps()) === stepsBefore, JSON.stringify({ steps: await undoSteps(), stepsBefore }), ); + // 3h. Fit in the scene tab's header frames what the game view shows, at the game view's scale: + // the crate and the camera frame land on the same screen pixels in both tabs, so switching + // between them moves nothing – at auto's whole step and at fit's fractional one alike. + const sameAsGame = async (label) => { + const game = await pickScale(label); + const inGame = await json(`__gm.gameRect('o1')`); + await showTab('scene'); + const fitAt = await json(`__gm.sceneHeaderAt('fit')`); + await click(fitAt.x, fitAt.y); + const inScene = await json(`__gm.objectRect('o1')`); + const cameraInScene = await json(`__gm.objectRect('o3')`); + const view = await json('__gm.viewportRect()'); + await showTab('game'); + const near = (a, b) => ['x', 'y', 'w', 'h'].every((k) => Math.abs(a[k] - b[k]) < 0.01); + // `objectRect` gives the centre, the game frame its top-left corner. + const { x, y, w, h } = game.frame; + const frameCentred = { x: x + w / 2, y: y + h / 2, w, h }; + return { + ok: near(inGame, inScene) && near(frameCentred, cameraInScene) && near(game.view, view), + detail: JSON.stringify({ + label, + inGame, + inScene, + frame: game.frame, + cameraInScene, + view: game.view, + sceneView: view, + }), + }; + }; + for (const label of ['Auto', 'Fit']) { + const { ok, detail } = await sameAsGame(label); + check(`Fit in the scene tab shows the world exactly as the game view does at ${label}`, ok, detail); + } g = await pickScale('100%'); check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g)); await showTab('scene'); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index b3f957a3..6db57b68 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -168,7 +168,7 @@ export async function topbar(t) { JSON.stringify({ opened, scaled, restored }), ); - // 46g. The eye beside redo: every placeable type with a switch for its symbol and its name, and a + // 46g. The eye in the scene tab's header: every placeable type with a switch for its symbol and its name, and a // outline of a collider and of a tilemap's solid cells besides. Sprite, Text and Tilemap have no symbol, so that switch is // greyed out. Real clicks flip the scene view, and the switches are saved but no undo step. await json('(__gm.showTab("scene"), true)'); @@ -204,7 +204,9 @@ export async function topbar(t) { scene.objects.push(Object.assign(structuredClone(o), { id: 'vis-col-obj', assetId: 'vis-col', name: 'Vis collider' })); __gm.state.select(null)`); const undoBefore = await json('__gm.state.commands.canUndo'); - await clickToolbar('visibility'); + await t.showTab('scene'); + const eye = await json(`__gm.sceneHeaderAt('visibility')`); + await t.click(eye.x, eye.y); const popup = await json('__gm.visibilityPopup()'); const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part); const types = [...new Set((popup ?? []).map((e) => e.type))];