Give the scene tab a header with Fit and the eye (#245)
The scene view had no header bar of its own, and its eye sat in the top bar. Switching between Scene and Game also showed two different pictures, since nothing tied the free view to the camera. - Theme.headerPadding (4) replaces Theme.padding in the top bar and in TabHeader, for the padding and for minHeight. Both bars shrink from 40 to 32 px. Nothing else uses the new value. - The new ScenePane wraps the scene view in a header bar with Fit and the visibility eye. The eye is gone from the top bar, which keeps the menu, undo and redo. - Fit with an active camera shows the world exactly as the Game tab does at its chosen step: the same zoom, and the camera centre on the same screen pixel. The rule is the pure freeViewOfCamera in viewport/view.ts, which also mirrors the letterbox snapping. - Fit without a camera frames all content on a whole step, as before. - New hook __gm.sceneHeaderAt(name). e2e 46g opens the eye there. - The doc comment above Theme.treeIndent went away in the working tree along with this. It is committed as found. Not checked: the two views only match while they have the same size. With the tilemap panel open the scene view is narrower, and Auto may pick a smaller step there. Tests: - ./check.sh: 178 files, 2575 tests, CHECK EXIT 0 - 2 new unit tests in view.test.ts: three cameras times four steps land on the same screen point as in the Game tab, Auto stays whole - e2e 3h (2 checks): a real click on Fit at Auto and at Fit, crate, camera frame and view box match in both tabs - GM_STEPS=1-3,46: ALL PASSED, 94 checks - Counter-probes: without the centre offset the unit test fails, with Fit ignoring the camera both 3h checks fail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a90ef4c22f
commit
30de4d35a8
15 files changed
+163
-31
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type { AssetTabEditor } from './AssetTabEditor';
|
|||||||
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents';
|
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents';
|
||||||
import { ConsolePanel } from './console/ConsolePanel';
|
import { ConsolePanel } from './console/ConsolePanel';
|
||||||
import { GamePane } from './viewport/GamePane';
|
import { GamePane } from './viewport/GamePane';
|
||||||
|
import { ScenePane } from './viewport/ScenePane';
|
||||||
import { GameViewport } from './viewport/GameViewport';
|
import { GameViewport } from './viewport/GameViewport';
|
||||||
import { SceneViewport } from './viewport/SceneViewport';
|
import { SceneViewport } from './viewport/SceneViewport';
|
||||||
import { TAB_OF_ASSET } from './assetTabs';
|
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). */
|
/** Beside the scene view, while exactly one placed tilemap is selected there (#142). */
|
||||||
readonly tilemapPanel: TilemapPanel;
|
readonly tilemapPanel: TilemapPanel;
|
||||||
readonly tilemapSplitter = new Splitter('x');
|
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();
|
private readonly sceneRow = new HStack();
|
||||||
|
/** The scene tab: its header above `sceneRow`. */
|
||||||
|
readonly scenePane: ScenePane;
|
||||||
readonly console = new ConsolePanel();
|
readonly console = new ConsolePanel();
|
||||||
tab: TabId = 'scene';
|
tab: TabId = 'scene';
|
||||||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
/** 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.tilemapPanel.onTargetChange = () => this.syncTilemapPanel();
|
||||||
this.syncTilemapPanel();
|
this.syncTilemapPanel();
|
||||||
|
this.scenePane = new ScenePane(state, this.viewport, this.sceneRow);
|
||||||
this.assetEditors = {
|
this.assetEditors = {
|
||||||
graph: this.graphEditor,
|
graph: this.graphEditor,
|
||||||
statemachine: this.stateMachineEditor,
|
statemachine: this.stateMachineEditor,
|
||||||
@@ -380,7 +384,7 @@ export class Editor implements PlayHost {
|
|||||||
container: this.containerEditor,
|
container: this.containerEditor,
|
||||||
};
|
};
|
||||||
this.panes = {
|
this.panes = {
|
||||||
scene: this.sceneRow,
|
scene: this.scenePane,
|
||||||
game: this.gamePane,
|
game: this.gamePane,
|
||||||
...this.assetEditors,
|
...this.assetEditors,
|
||||||
list: this.listEditor,
|
list: this.listEditor,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export class TabHeader extends HStack {
|
|||||||
private readonly bg = new Graphics();
|
private readonly bg = new Graphics();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' });
|
||||||
this.minHeight = Theme.controlHeight + Theme.padding * 2;
|
this.minHeight = Theme.controlHeight + Theme.headerPadding * 2;
|
||||||
this.addChildAt(this.bg, 0);
|
this.addChildAt(this.bg, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */
|
||||||
export function sceneHooks({ state, editor }: HookContext) {
|
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
|
* 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`).
|
* 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;
|
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(),
|
viewportInfo: () => viewport.debugInfo(),
|
||||||
/** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */
|
/** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */
|
||||||
viewportRedraw: () => viewport.redraw(),
|
viewportRedraw: () => viewport.redraw(),
|
||||||
@@ -66,7 +69,7 @@ export function sceneHooks({ state, editor }: HookContext) {
|
|||||||
sceneIcon: (id: string) => viewport.iconInfo(id),
|
sceneIcon: (id: string) => viewport.iconInfo(id),
|
||||||
/** The names shown beside the symbols, each with the plate under it, in world units and on screen. */
|
/** The names shown beside the symbols, each with the plate under it, in world units and on screen. */
|
||||||
sceneLabels: () => viewport.labelInfo(),
|
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),
|
sceneShape: (id: string) => viewport.shapeInfo(id),
|
||||||
/** The switches behind the eye while it stands open: per type and part, on, enabled, where to click. */
|
/** The switches behind the eye while it stands open: per type and part, on, enabled, where to click. */
|
||||||
visibilityPopup: () =>
|
visibilityPopup: () =>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
playFrom: () => editor.playFromInfo(),
|
playFrom: () => editor.playFromInfo(),
|
||||||
/** The two run buttons of the top bar: the picture each wears, and whether it takes a click. */
|
/** The two run buttons of the top bar: the picture each wears, and whether it takes a click. */
|
||||||
runButtons: () => editor.runButtons(),
|
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]),
|
toolbarAt: (name: keyof Editor['toolbarControls']) => centreOf(editor.toolbarControls[name]),
|
||||||
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
||||||
editorRun: () => editor.editorRunInfo(),
|
editorRun: () => editor.editorRunInfo(),
|
||||||
|
|||||||
@@ -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
|
* 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
|
* greyed out, or missing where no other type has it either. Clicks keep it open: one usually
|
||||||
* switches several.
|
* switches several.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
import { Graphics } from 'pixi.js';
|
||||||
import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui';
|
import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui';
|
||||||
import type { EditorState, SaveStatus } from '../state';
|
import type { EditorState, SaveStatus } from '../state';
|
||||||
import { SceneVisibilityPopup } from './SceneVisibilityPopup';
|
|
||||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||||
|
|
||||||
/** What the toolbar buttons trigger; the editor supplies the implementations. */
|
/** 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
|
* Top bar (#183): menu, undo and redo on the left, play and pause in the middle, the account on
|
||||||
* middle, the account on the right – icons, no captions. Everything with words is behind the menu: the project name and
|
* 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
|
* 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.
|
* picked where the scenes are, in the Assets panel.
|
||||||
*/
|
*/
|
||||||
@@ -48,8 +47,6 @@ export class Toolbar extends HStack {
|
|||||||
private readonly menuButton: Button;
|
private readonly menuButton: Button;
|
||||||
private readonly undoButton: Button;
|
private readonly undoButton: Button;
|
||||||
private readonly redoButton: 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 playButton: Button;
|
||||||
private readonly pauseButton: Button;
|
private readonly pauseButton: Button;
|
||||||
private readonly status: Label;
|
private readonly status: Label;
|
||||||
@@ -63,8 +60,8 @@ export class Toolbar extends HStack {
|
|||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
actions: ToolbarActions,
|
actions: ToolbarActions,
|
||||||
) {
|
) {
|
||||||
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' });
|
||||||
this.minHeight = Theme.controlHeight + Theme.padding * 2;
|
this.minHeight = Theme.controlHeight + Theme.headerPadding * 2;
|
||||||
this.addChildAt(this.bg, 0);
|
this.addChildAt(this.bg, 0);
|
||||||
|
|
||||||
this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton));
|
this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton));
|
||||||
@@ -74,12 +71,6 @@ export class Toolbar extends HStack {
|
|||||||
this.redoButton = iconButton('redo', () => state.redo());
|
this.redoButton = iconButton('redo', () => state.redo());
|
||||||
this.add(this.undoButton);
|
this.add(this.undoButton);
|
||||||
this.add(this.redoButton);
|
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.add(new Spacer(), 1);
|
||||||
this.playButton = iconButton('play', () => actions.togglePlay());
|
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. */
|
/** 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 {
|
return {
|
||||||
menu: this.menuButton,
|
menu: this.menuButton,
|
||||||
undo: this.undoButton,
|
undo: this.undoButton,
|
||||||
redo: this.redoButton,
|
redo: this.redoButton,
|
||||||
visibility: this.visibilityButton,
|
|
||||||
play: this.playButton,
|
play: this.playButton,
|
||||||
pause: this.pauseButton,
|
pause: this.pauseButton,
|
||||||
};
|
};
|
||||||
@@ -168,7 +158,7 @@ export class Toolbar extends HStack {
|
|||||||
override layout(): void {
|
override layout(): void {
|
||||||
super.layout();
|
super.layout();
|
||||||
const width = this.pauseButton.x + this.pauseButton.w - this.playButton.x;
|
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 rightMost = this.notice.x - this.gap - width;
|
||||||
const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost)));
|
const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost)));
|
||||||
const shift = x - this.playButton.x;
|
const shift = x - this.playButton.x;
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export abstract class FreeViewport extends WorldViewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Device pixels per CSS pixel: 1.25 under Windows scaling at 125%. */
|
/** Device pixels per CSS pixel: 1.25 under Windows scaling at 125%. */
|
||||||
private ratio(): number {
|
protected ratio(): number {
|
||||||
return ui().app.renderer.resolution;
|
return ui().app.renderer.resolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,8 @@ import { dashedLine } from '../../viewport/symbols';
|
|||||||
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
|
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
|
||||||
import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows';
|
import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows';
|
||||||
import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout';
|
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 { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
||||||
import { FreeViewport } from './FreeViewport';
|
import { FreeViewport } from './FreeViewport';
|
||||||
import { PolygonCornerEditor } from './polygonCorners';
|
import { PolygonCornerEditor } from './polygonCorners';
|
||||||
@@ -224,6 +225,17 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return { layerId: paintedLayerId(this.state, target.asset), mode };
|
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. */
|
/** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */
|
||||||
pasteHere(): boolean {
|
pasteHere(): boolean {
|
||||||
const holder = this.holder();
|
const holder = this.holder();
|
||||||
@@ -639,7 +651,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return item.tilemap!.layerSolids(paintedLayerId(this.state, target.asset));
|
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 {
|
private shows(item: DrawItem, part: VisibilityPart): boolean {
|
||||||
return shows(this.state.project.sceneVisibility, item.type, part);
|
return shows(this.state.project.sceneVisibility, item.type, part);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,12 +11,12 @@ export const Theme = {
|
|||||||
rowHeight: 24,
|
rowHeight: 24,
|
||||||
controlHeight: 24,
|
controlHeight: 24,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
|
headerPadding: 4,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
radius: 2,
|
radius: 2,
|
||||||
fieldPadding: 4,
|
fieldPadding: 4,
|
||||||
checkboxSize: 16,
|
checkboxSize: 16,
|
||||||
scrollbarWidth: 4,
|
scrollbarWidth: 4,
|
||||||
/** Horizontal indent per nesting level in tree lists. */
|
|
||||||
treeIndent: 12,
|
treeIndent: 12,
|
||||||
hoverCursor: 'default',
|
hoverCursor: 'default',
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@@ -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 {
|
export function screenToWorld(view: View, sx: number, sy: number): Vec2 {
|
||||||
const cx = view.rect.x + view.rect.w / 2;
|
const cx = view.rect.x + view.rect.w / 2;
|
||||||
const cy = view.rect.y + view.rect.h / 2;
|
const cy = view.rect.y + view.rect.h / 2;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
cameraView,
|
cameraView,
|
||||||
FREE_ZOOM_STEPS,
|
FREE_ZOOM_STEPS,
|
||||||
freeView,
|
freeView,
|
||||||
|
freeViewOfCamera,
|
||||||
GAME_SCALE_STEPS,
|
GAME_SCALE_STEPS,
|
||||||
gameScaleFactor,
|
gameScaleFactor,
|
||||||
isInside,
|
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', () => {
|
describe('gameScaleFactor', () => {
|
||||||
it('fit is the exact factor that fills the tighter side', () => {
|
it('fit is the exact factor that fills the tighter side', () => {
|
||||||
expect(gameScaleFactor('fit', 640, 360, 800, 600)).toBeCloseTo(1.25, 9);
|
expect(gameScaleFactor('fit', 640, 360, 800, 600)).toBeCloseTo(1.25, 9);
|
||||||
|
|||||||
@@ -361,6 +361,40 @@ export async function play(t) {
|
|||||||
(await undoSteps()) === stepsBefore,
|
(await undoSteps()) === stepsBefore,
|
||||||
JSON.stringify({ steps: 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%');
|
g = await pickScale('100%');
|
||||||
check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g));
|
check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g));
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export async function topbar(t) {
|
|||||||
JSON.stringify({ opened, scaled, restored }),
|
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
|
// 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.
|
// greyed out. Real clicks flip the scene view, and the switches are saved but no undo step.
|
||||||
await json('(__gm.showTab("scene"), true)');
|
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' }));
|
scene.objects.push(Object.assign(structuredClone(o), { id: 'vis-col-obj', assetId: 'vis-col', name: 'Vis collider' }));
|
||||||
__gm.state.select(null)`);
|
__gm.state.select(null)`);
|
||||||
const undoBefore = await json('__gm.state.commands.canUndo');
|
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 popup = await json('__gm.visibilityPopup()');
|
||||||
const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part);
|
const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part);
|
||||||
const types = [...new Set((popup ?? []).map((e) => e.type))];
|
const types = [...new Set((popup ?? []).map((e) => e.type))];
|
||||||
|
|||||||
Reference in new issue
Block a user