diff --git a/README.md b/README.md index f775f298..416a64e7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,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, 1027 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1032 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 2562be48..7bd9bba3 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -33,6 +33,7 @@ import { AnimationEditor } from './animation/AnimationEditor'; import { TileEditor } from './tile/TileEditor'; import { AssetPanel } from './panels/AssetPanel'; import { HierarchyPanel } from './panels/HierarchyPanel'; +import { TilemapPanel } from './panels/TilemapPanel'; import { Toolbar } from './panels/Toolbar'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { ProjectSession } from './ProjectSession'; @@ -142,6 +143,11 @@ export class Editor implements PlayHost { readonly hierarchy: HierarchyPanel; readonly assets: AssetPanel; readonly inspector: Inspector; + /** 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. */ + private readonly sceneRow = new HStack(); readonly console = new ConsolePanel(); tab: TabId = 'scene'; /** Frame rate, refreshed twice a second; null until the first measurement. */ @@ -168,6 +174,8 @@ export class Editor implements PlayHost { private fpsElapsed = 0; private leftWidth = 250; private rightWidth = 350; + /** Starts out as wide as the inspector, then goes its own way. */ + private tilemapWidth = this.rightWidth; private consoleHeight = 300; /** The asset panel's part of the left column's height; the scene objects get the rest. */ private assetsShare = 0.5; @@ -194,6 +202,7 @@ export class Editor implements PlayHost { this.listEditor = new ListEditor(state); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); + this.tilemapPanel = new TilemapPanel(state); this.play = new PlaySession(state, this); this.editorRun = new EditorRun(state, { // The game view is the one place where no key means anything to the editor, so that is where @@ -250,8 +259,19 @@ export class Editor implements PlayHost { this.tabs.minHeight = Theme.controlHeight + 8; // Written out per tab rather than derived, because a `Record` is what makes // adding a tab a type error everywhere it has to be handled instead of a silent omission. + this.sceneRow.add(this.viewport, 1); + this.sceneRow.add(this.tilemapSplitter); + this.sceneRow.add(this.tilemapPanel); + this.tilemapPanel.minWidth = this.tilemapWidth; + this.tilemapSplitter.onDrag = (delta) => { + this.tilemapWidth = clamp(this.tilemapWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH); + this.tilemapPanel.minWidth = this.tilemapWidth; + this.sceneRow.relayout(); + }; + this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel(); + this.syncTilemapPanel(); this.panes = { - scene: this.viewport, + scene: this.sceneRow, game: this.gameView, graph: this.graphEditor, statemachine: this.stateMachineEditor, @@ -397,11 +417,23 @@ export class Editor implements PlayHost { ); for (const key of TAB_ORDER) this.panes[key].visible = key === tab; 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.syncTilemapPanel(); this.tabs.setActive(tab); this.centre.relayout(); ui().focus.validate(); } + /** The tilemap panel belongs to the scene tab and needs a map to paint; otherwise the view gets its room. */ + private syncTilemapPanel(): void { + const shown = this.tab === 'scene' && this.tilemapPanel.hasTarget; + if (this.tilemapPanel.visible === shown) return; + this.tilemapPanel.visible = shown; + this.tilemapSplitter.visible = shown; + this.sceneRow.relayout(); + } + /** * Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which * on an untoggled ghost tab sees nothing but the label (the background is not drawn at all). diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 4f45bee4..c8bc9375 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -29,7 +29,7 @@ import { currentFontPicker } from './font/FontPicker'; import { currentNodePalette } from './graph/NodePalette'; import { currentSoundPicker } from './sound/SoundPicker'; import { currentImagePicker } from './texture/ImagePicker'; -import { currentTilemapSection } from './inspector/tilemapSection'; +import { currentTilemapSection } from './panels/TilemapPanel'; import type { EditorState, TilemapTool } from './state'; /** @@ -554,7 +554,22 @@ export function installDebugHook(app: Application, state: EditorState, editor: E gameInfo: (objectId: string) => gameView.tilemapInfo(objectId), /** Where the scene view outlines the map as blocking: the merged rectangles, in cells (#132). */ solids: (objectId: string) => viewport.solidInfo(objectId), - /** Where the widgets of the inspector section sit: their texts are on screen elsewhere too. */ + /** + * The painting panel beside the scene view (#142): whether it is on screen, its box, the + * scene view's box next to it and its splitter – null boxes while it is hidden. + */ + panel: () => { + // The editor hides the panel itself on every other tab, so its own flag is the whole answer. + const shown = editor.tilemapPanel.visible; + return { + shown, + title: editor.tilemapPanel.title, + panel: shown ? rectOf(editor.tilemapPanel) : null, + splitter: shown ? centreOf(editor.tilemapSplitter) : null, + viewport: rectOf(viewport), + }; + }, + /** Where the widgets of the painting panel sit: their texts are on screen elsewhere too. */ toolAt: (tool: string) => { const button = currentTilemapSection()?.tools.get(tool as TilemapTool); return button ? centreOf(button) : null; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index d4812f73..159a211b 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -47,7 +47,6 @@ import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './h import type { EditorState, ListSlot } from '../state'; import { parentRow } from './parentRow'; import { listOpener, previewingHandlers, structFields } from './previewHandlers'; -import { buildTilemapSection, tilemapSignature } from './tilemapSection'; import { overrideValue } from './valueSpecs'; import { buildVariable } from './variableRows'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; @@ -83,8 +82,7 @@ export function objectSignature( ? 'instance' : ''; const rows = schemaForObject(project, asset, object); - const painting = asset.type === 'tilemap' ? `|${tilemapSignature(state, asset)}` : ''; - return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',') + painting; + return `${object.parentId}|${kind}|` + rows.map(defSignature).join(','); } /** Several objects: what they are, and the actions that apply to all of them at once. */ @@ -207,7 +205,6 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object markObjectRows(host, object, asset, target); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); - if (asset.type === 'tilemap') buildTilemapSection(host, asset); } function assetLink(host: InspectorHost, label: string, asset: Asset): void { diff --git a/src/editor/inspector/tilemapSection.ts b/src/editor/panels/TilemapPanel.ts similarity index 61% rename from src/editor/inspector/tilemapSection.ts rename to src/editor/panels/TilemapPanel.ts index 8303a8a0..6e28959d 100644 --- a/src/editor/inspector/tilemapSection.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -1,7 +1,8 @@ /** * The painting side of a placed tilemap (#128): which layer is painted on, with which tile and - * with which tool. It hangs below the ordinary rows of the object, the way a container's content - * section does – the grid size and the cell size are plain properties and are shown above. + * with which tool. It is a panel of its own beside the scene view (#142), shown only while exactly + * one placed tilemap is selected there – the grid size and the cell size are plain properties and + * stay in the inspector. * * Nothing here belongs to the model except the layers themselves: tool, tile and layer are * editor state (`state.tilemapPaint`), so opening a project does not decide what one paints with. @@ -14,12 +15,27 @@ import { SetTilemapLayerHiddenCommand, } from '../../core/commands/tilemapCommands'; import type { TilemapAsset } from '../../core/model/Asset'; +import { findObject } from '../../core/model/hierarchy'; +import { resolvedHolder } from '../../core/model/instance'; +import { findAssetOf } from '../../core/model/Project'; import { findLayer, isLayerVisible } from '../../core/model/tilemap'; -import { Button, Checkbox, Dropdown, HStack, Menu, TextField, Theme } from '../../ui'; +import { + Button, + Checkbox, + Dropdown, + HStack, + Menu, + Panel, + ScrollContainer, + TextField, + Theme, + ui, + VStack, +} from '../../ui'; import type { EditorState, TilemapFocus, TilemapTool } from '../state'; import { TilePalette } from '../tile/TilePalette'; -import { labelledRow } from './fieldFactory'; -import { addGap, addNote, addTitle, type InspectorHost } from './host'; +import { labelledRow } from '../inspector/fieldFactory'; +import { addGap, addNote, addTitle, type InspectorHost } from '../inspector/host'; const FOCUS: { focus: TilemapFocus; label: string }[] = [ { focus: 'all', label: 'All layers' }, @@ -35,7 +51,21 @@ const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [ ]; /** - * The layer that is painted on. The one chosen in the inspector when it still exists, otherwise + * The tilemap that is painted on: the one scene object selected, placed in the active scene. A + * second object in the selection means there is no single map to paint, so there is none (#142). + */ +export function paintedTilemap(state: EditorState): { objectId: string; asset: TilemapAsset } | null { + const s = state.selection; + const scene = state.activeScene; + if (s?.kind !== 'object' || s.objectIds.length !== 1 || !scene) return null; + // Looked up in the scene as shown, so an object of another scene or a container is not found. + const object = findObject(resolvedHolder(state.project, scene), s.objectId); + const asset = object && findAssetOf(state.project, object.assetId, 'tilemap'); + return asset ? { objectId: s.objectId, asset } : null; +} + +/** + * The layer that is painted on. The one chosen in the panel when it still exists, otherwise * the topmost – so a fresh selection can be painted on without choosing anything first. */ export function paintedLayerId(state: EditorState, asset: TilemapAsset): string { @@ -63,11 +93,89 @@ export interface TilemapSectionWidgets { let built: TilemapSectionWidgets | null = null; +/** Null while the panel has no tilemap to paint, so a check cannot click a widget that is gone. */ export function currentTilemapSection(): TilemapSectionWidgets | null { return built; } -export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void { +/** + * The panel itself. Whether it is on screen is the editor's call – it also depends on the tab – + * and `onTargetChange` tells it when the answer may have changed. + */ +export class TilemapPanel extends Panel { + private readonly scroll = new ScrollContainer(); + private host: InspectorHost; + /** Describes what the rows were built for; a change means a rebuild. */ + private signature = ''; + /** A rebuild that was due while the name field had focus – the same rule as the inspector's. */ + private rebuildWhenLeft = false; + /** As set, before the header upper-cases it – for the check scripts. */ + title = 'Tilemap'; + onTargetChange: () => void = () => {}; + + constructor(private readonly state: EditorState) { + super({ title: 'Tilemap', padding: 0, gap: 0 }); + this.host = this.emptyHost(); + this.scroll.setContent(this.host.body); + this.content.add(this.scroll, 1); + state.on('selection', () => this.sync()); + state.on('load', () => this.sync()); + state.on('project', () => { + if (this.currentSignature() === this.signature) return; + if (ui().focus.isWithin(this)) this.rebuildWhenLeft = true; + else this.sync(); + }); + ui().focus.changeHandlers.push(() => { + if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.sync(); + }); + this.sync(); + } + + /** Whether there is a map to paint – the editor adds "and the scene tab is showing". */ + get hasTarget(): boolean { + return built !== null; + } + + private emptyHost(): InspectorHost { + return { + state: this.state, + project: () => this.state.project, + liveVariables: () => null, + body: new VStack({ padding: Theme.padding, gap: Theme.gap }), + fields: new Map(), + nameField: null, + }; + } + + private currentSignature(): string { + const target = paintedTilemap(this.state); + if (!target) return ''; + const { objectId, asset } = target; + return `${objectId}|${asset.id}|${asset.name}|${tilemapSignature(this.state, asset)}`; + } + + private sync(): void { + const target = paintedTilemap(this.state); + // With the panel gone nothing shows the brush any more, and a click on the next map would paint. + // The tile and the layer stay as they were: they are what one comes back to. + if (!target && this.state.tilemapPaint.tool !== 'select') { + this.state.setTilemapPaint({ tool: 'select' }); + return; + } + this.rebuildWhenLeft = false; + this.signature = this.currentSignature(); + this.host.body.destroy({ children: true }); + this.host = this.emptyHost(); + built = null; + this.title = target ? `Tilemap · ${target.asset.name}` : 'Tilemap'; + this.setTitle(this.title); + if (target) buildTilemapSection(this.host, target.asset); + this.scroll.setContent(this.host.body); + this.onTargetChange(); + } +} + +function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void { const { state } = host; const paint = state.tilemapPaint; const painted = paintedLayerId(state, asset); @@ -184,12 +292,12 @@ function tileIdsOf(state: EditorState): string[] { } /** - * What the section is built from, for the inspector's rebuild check: the layers, the tiles on offer + * What the section is built from, for the panel's rebuild check: the layers, the tiles on offer * and what is painted with. The cells themselves are deliberately absent – a stroke must not * rebuild the panel under the pointer that is drawing it – and so are the tiles' pictures and * names, which the palette repaints by itself. */ -export function tilemapSignature(state: EditorState, asset: TilemapAsset): string { +function tilemapSignature(state: EditorState, asset: TilemapAsset): string { const paint = state.tilemapPaint; const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(','); const tiles = tileIdsOf(state).join(','); diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 73f47eaf..1d1a2f99 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -19,6 +19,8 @@ export abstract class FreeViewport extends WorldViewport { /** World point at the viewport centre, and zoom. */ protected view = { x: 0, y: 0, zoom: 1 }; private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null; + /** Global position of the top-left corner at the last resize, for `resize()` to keep the world in place. */ + private screenOrigin = { x: 0, y: 0 }; protected constructor(state: EditorState) { super(state); @@ -57,6 +59,22 @@ export abstract class FreeViewport extends WorldViewport { this.redraw(); } + /** + * Keeps the world where it is on screen when the view's edges move – a panel appearing beside it, + * a splitter, the console – instead of holding the centre, which shifted the content by half the + * change. The parent positions before it resizes, so the global origin is already the new one. + */ + override resize(w: number, h: number): void { + if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) { + const before = this.screenOrigin; + const after = this.getGlobalPosition(); + this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.view.zoom; + this.view.y += (after.y + h / 2 - (before.y + this.h / 2)) / this.view.zoom; + } + super.resize(w, h); + this.screenOrigin = this.getGlobalPosition(); + } + protected resetView(): void { this.view = { x: 0, y: 0, zoom: 1 }; } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index c96b0c21..d9371559 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -39,7 +39,7 @@ import type { CellRect } from '../../core/model/tilemapCollision'; import { footprintCorners } from '../../core/properties/bounds'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; import { type EditorState, objectSelection, type TilemapTool } from '../state'; -import { paintedLayerId } from '../inspector/tilemapSection'; +import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; import { collectHolderItems, type DrawItem } from '../../viewport/items'; import type { LayerFocus } from '../../viewport/tilemap'; @@ -187,18 +187,15 @@ export class SceneViewport extends FreeViewport { } /** - * The selected tilemap shows its layers the way the inspector asks (#138), whatever the tool: - * the painted layer is worth seeing alone before the brush is taken up, too. Every other map, - * and this one once it is deselected, is drawn whole. + * The painted tilemap shows its layers the way its panel asks (#138), whatever the tool: the + * painted layer is worth seeing alone before the brush is taken up, too. Every other map, and + * this one once it is deselected, is drawn whole. */ protected override tilemapFocus(item: DrawItem): LayerFocus | null { const mode = this.state.tilemapPaint.focus; - const s = this.state.selection; - if (mode === 'all' || item.type !== 'tilemap' || s?.kind !== 'object' || s.objectId !== item.id) - return null; - const object = findObject(this.resolved() ?? { objects: [] }, item.id); - const asset = object && findAssetOf(this.state.project, object.assetId, 'tilemap'); - return asset ? { layerId: paintedLayerId(this.state, asset), mode } : null; + const target = mode === 'all' ? null : paintedTilemap(this.state); + if (!target || target.objectId !== item.id) return null; + return { layerId: paintedLayerId(this.state, target.asset), mode }; } private onPointerDown(e: FederatedPointerEvent): void { @@ -344,22 +341,18 @@ export class SceneViewport extends FreeViewport { } /** - * The tilemap a click paints on: the primary selection, placed here, with a painting tool + * The tilemap a click paints on: the one selected (see `paintedTilemap`), with a painting tool * chosen. Anything else – another object, the select tool, a modifier held – leaves the view * doing what it always did. */ private paintTargetOf(objectId: string): { item: DrawItem; asset: TilemapAsset; layerId: string } | null { - const paint = this.state.tilemapPaint; - if (paint.tool === 'select') return null; - const s = this.state.selection; - if (s?.kind !== 'object' || s.objectId !== objectId) return null; + if (this.state.tilemapPaint.tool === 'select') return null; + const target = paintedTilemap(this.state); + if (!target || target.objectId !== objectId) return null; const item = this.lastItems.find((i) => i.id === objectId); if (!item || item.type !== 'tilemap') return null; - const object = findObject(this.resolved() ?? { objects: [] }, objectId); - const asset = object && findAssetOf(this.state.project, object.assetId, 'tilemap'); - if (!asset) return null; - const layerId = paintedLayerId(this.state, asset); - return layerId ? { item, asset, layerId } : null; + const layerId = paintedLayerId(this.state, target.asset); + return layerId ? { item, asset: target.asset, layerId } : null; } /** The cell under a pointer position, in the tilemap's own grid. */ @@ -690,9 +683,9 @@ export class SceneViewport extends FreeViewport { * paints blind – and a rectangle drag shows the box it would fill. */ private drawPaintOverlay(items: DrawItem[], view: View): void { - const s = this.state.selection; - if (this.state.tilemapPaint.tool === 'select' || s?.kind !== 'object') return; - const item = items.find((i) => i.id === s.objectId && i.type === 'tilemap'); + const target = this.state.tilemapPaint.tool === 'select' ? null : paintedTilemap(this.state); + if (!target) return; + const item = items.find((i) => i.id === target.objectId && i.type === 'tilemap'); if (!item) return; const tile = tilemapTileSize(item.props); const columns = tilemapColumns(item.props); diff --git a/tests/editor/painted-tilemap.test.ts b/tests/editor/painted-tilemap.test.ts new file mode 100644 index 00000000..82515a1e --- /dev/null +++ b/tests/editor/painted-tilemap.test.ts @@ -0,0 +1,71 @@ +/** + * Which tilemap the painting panel and the scene view's brush work on (#142): exactly one placed + * map, selected in the active scene. Everything else – nothing, two objects, the asset itself, a + * map in a scene that is not showing – leaves nothing to paint. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { paintedTilemap } from '../../src/editor/panels/TilemapPanel'; +import { EditorState, objectSelection } from '../../src/editor/state'; + +function setup() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const create = (type: 'tilemap' | 'sprite' | 'scene', name: string) => { + const command = new CreateAssetCommand(type, name); + expect(state.execute(command)).toBe(true); + return command.asset.id; + }; + // The first scene there is becomes the active one by itself. + const sceneId = create('scene', 'Main'); + expect(state.activeScene?.id).toBe(sceneId); + const place = (holderId: string, assetId: string) => { + const command = new PlaceAssetCommand(holderId, assetId, { x: 0, y: 0 }); + expect(state.execute(command)).toBe(true); + return command.objectId; + }; + const tilemapId = create('tilemap', 'Ground'); + return { state, sceneId, tilemapId, create, place }; +} + +describe('the painted tilemap', () => { + it('is the one placed map selected in the active scene', () => { + const { state, sceneId, tilemapId, place } = setup(); + const map = place(sceneId, tilemapId); + state.select(objectSelection(sceneId, [map])); + expect(paintedTilemap(state)).toMatchObject({ objectId: map, asset: { id: tilemapId } }); + }); + + it('is none without a selection, for another object and for the asset itself', () => { + const { state, sceneId, tilemapId, create, place } = setup(); + expect(paintedTilemap(state)).toBeNull(); + const sprite = place(sceneId, create('sprite', 'Box')); + state.select(objectSelection(sceneId, [sprite])); + expect(paintedTilemap(state)).toBeNull(); + state.select({ kind: 'asset', assetId: tilemapId }); + expect(paintedTilemap(state)).toBeNull(); + }); + + it('is none while a second object is selected with it, map or not', () => { + const { state, sceneId, tilemapId, create, place } = setup(); + const map = place(sceneId, tilemapId); + const other = place(sceneId, tilemapId); + const sprite = place(sceneId, create('sprite', 'Box')); + state.select(objectSelection(sceneId, [map, other], map)); + expect(paintedTilemap(state)).toBeNull(); + state.select(objectSelection(sceneId, [map, sprite], map)); + expect(paintedTilemap(state)).toBeNull(); + state.select(objectSelection(sceneId, [map])); + expect(paintedTilemap(state)?.objectId).toBe(map); + }); + + it('is none for a map in a scene that is not the one showing', () => { + const { state, tilemapId, create, place } = setup(); + const otherScene = create('scene', 'Elsewhere'); + const map = place(otherScene, tilemapId); + state.select(objectSelection(otherScene, [map])); + expect(paintedTilemap(state)).toBeNull(); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 12958f85..9c515d1b 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -18,29 +18,38 @@ const COLUMNS = 6; const ROWS = 4; /** - * A widget of the painting section, scrolled into the inspector first. The section sits below the - * rows of the object, so on this window it starts out below the panel – and a click on a point - * outside the panel lands on whatever is drawn there instead. + * A widget of the tilemap panel (#142), scrolled into view first. On a short window the layers sit + * below the fold – and a click on a point outside the panel lands on whatever is drawn there instead. */ -async function inInspector(t, hook) { - const anchor = await t.json("__gm.inspectorField('Columns')"); +async function inPanel(t, hook) { for (let i = 0; i < 15; i++) { - const rect = await t.json('__gm.inspectorRect()'); + const rect = (await t.json('__gm.tilemap.panel()')).panel; + if (!rect) return null; const point = await t.json(hook); if (point && point.y > rect.y + 8 && point.y < rect.y + rect.h - 8) return point; - await t.wheel(anchor.x, anchor.y, 200); + await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, point && point.y < rect.y ? -200 : 200); await t.settle(); } return null; } /** Clicks such a widget; returns false when it could not be brought into view. */ -async function clickInInspector(t, hook) { - const point = await inInspector(t, hook); +async function clickInPanel(t, hook) { + const point = await inPanel(t, hook); if (point) await t.click(point.x, point.y); return Boolean(point); } +/** Whether a box lies inside another, give or take a pixel of rounding. */ +function inside(inner, outer) { + return ( + inner.x >= outer.x - 1 && + inner.y >= outer.y - 1 && + inner.x + inner.w <= outer.x + outer.w + 1 && + inner.y + inner.h <= outer.y + outer.h + 1 + ); +} + /** The four quarters of the cell, read in one call: a running animation must not step between them. */ async function cellQuarters(t, x, y) { const centre = await t.json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, ${y})`); @@ -122,22 +131,59 @@ export async function tilemap(t) { overrides: {}, parentId: '', instanceEdits: {} }); __gm.lookAt(0, ${LANE});`); await settle(); + const unselected = await json('__gm.tilemap.panel()'); + const cellUnselected = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); await settle(); - // 40b. The inspector paints: a tool, the tiles of the project, the layers. + // 40b. Painting has a panel of its own beside the scene view (#142): tools, tiles and layers. + const panel = await json('__gm.tilemap.panel()'); + const inspectorRect = await json('__gm.inspectorRect()'); + check( + 'selecting a placed tilemap opens the tilemap panel between the scene view and the inspector', + !unselected.shown && + panel.shown && + panel.title === 'Tilemap · Tilemap' && + Math.abs(panel.panel.w - inspectorRect.w) <= 1 && + panel.viewport.x + panel.viewport.w <= panel.panel.x && + panel.panel.x + panel.panel.w <= inspectorRect.x && + Math.abs(panel.panel.y - panel.viewport.y) <= 1 && + Math.abs(panel.panel.h - panel.viewport.h) <= 1 && + panel.viewport.w < unselected.viewport.w, + JSON.stringify({ unselected, panel, inspectorRect }), + ); const section = await json( '(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.tileButtonAt("at-grass") !== null,' + ' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()', ); + // The view keeps the world where it is on screen, rather than its centre, which shifted the map + // by half the panel's width. + const cellWithPanel = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); check( - 'a selected tilemap offers tools, tiles and layers in the inspector', - section.tools && section.grass && Boolean(section.layers), - JSON.stringify(section), + 'the map stays put on screen when the panel opens', + Math.abs(cellWithPanel.x - cellUnselected.x) <= 1 && Math.abs(cellWithPanel.y - cellUnselected.y) <= 1, + JSON.stringify({ cellUnselected, cellWithPanel }), ); + // Staying put leaves the right of the map behind the panel; the steps below paint across it. + await evaluate(`__gm.lookAt(0, ${LANE})`); + await settle(); + // Found by its title rather than a hook: whatever is left of the section in the inspector would + // have no hook pointing at it any more. + const paintingTitles = await json('__gm.locateAll("PAINTING")'); + check( + 'the panel offers tools, tiles and layers, and the inspector no longer does', + section.tools && + section.grass && + Boolean(section.layers) && + paintingTitles.length === 1 && + inside({ ...paintingTitles[0], w: 0, h: 0 }, panel.panel) && + Boolean(await json("__gm.inspectorField('Columns')")), + JSON.stringify({ section, paintingTitles }), + ); + await shot('40-tilemap-panel'); // The tiles are a grid of pictures (#139), and each shows its block's lone tile: the four convex // corners, read off the palette in one call like a cell of the map. - await inInspector(t, '__gm.tilemap.tileButtonAt("at-grass")'); + await inPanel(t, '__gm.tilemap.tileButtonAt("at-grass")'); const swatch = await json(`(() => { const s = __gm.tilemap.tileSwatch("at-grass"); if (!s) return null; @@ -158,16 +204,16 @@ export async function tilemap(t) { JSON.stringify({ swatch, want: lone }), ); check( - 'and puts several tiles side by side in the inspector', + 'and puts several tiles side by side in the panel', swatch !== null && swatch.columns >= 3, JSON.stringify(swatch), ); // Clicked through the hook rather than by its text: the tile's own row in the asset panel // reads "Grass" as well, and a click there would only select the tile. check( - 'the tile can be picked in the section', - await clickInInspector(t, '__gm.tilemap.tileButtonAt("at-grass")'), - JSON.stringify(await json('__gm.inspectorRect()')), + 'the tile can be picked in the panel', + await clickInPanel(t, '__gm.tilemap.tileButtonAt("at-grass")'), + JSON.stringify(await json('__gm.tilemap.panel()')), ); await settle(); const paint = await json('__gm.tilemap.paint()'); @@ -208,7 +254,7 @@ export async function tilemap(t) { ); // 40d. The rectangle tool fills the box a drag spans. - await clickInInspector(t, '__gm.tilemap.toolAt("rect")'); + await clickInPanel(t, '__gm.tilemap.toolAt("rect")'); await settle(); const corner = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); const far = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`); @@ -257,7 +303,7 @@ export async function tilemap(t) { ); // 40g. Erasing takes cells out again. - await clickInInspector(t, '__gm.tilemap.toolAt("erase")'); + await clickInPanel(t, '__gm.tilemap.toolAt("erase")'); await settle(); const one = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`); await click(one.x, one.y); @@ -272,7 +318,7 @@ export async function tilemap(t) { await settle(); // 40h. A second layer draws above the first, and hiding it leaves its cells alone. - await clickInInspector(t, '__gm.tilemap.addLayerAt()'); + await clickInPanel(t, '__gm.tilemap.addLayerAt()'); await settle(); let layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); check( @@ -280,7 +326,7 @@ export async function tilemap(t) { layers.length === 2 && layers[1].cells === 0, JSON.stringify(layers), ); - await clickInInspector(t, '__gm.tilemap.tileButtonAt("at-grass")'); + await clickInPanel(t, '__gm.tilemap.tileButtonAt("at-grass")'); await settle(); const above = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 0)`); await click(above.x, above.y); @@ -293,7 +339,7 @@ export async function tilemap(t) { ); // Hiding the upper layer (the list is drawn top-down, newest first) leaves its cell in the model // and takes it out of the drawing. - const row = await inInspector(t, '__gm.tilemap.layerRowAt(0)'); + const row = await inPanel(t, '__gm.tilemap.layerRowAt(0)'); if (row) await click(row.visible.x, row.visible.y); await settle(); const hidden = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); @@ -309,7 +355,7 @@ export async function tilemap(t) { // 40m. The painted layer is named in the field below the list, and Show singles it out in the // scene view (#138). It sits here, not at the end, because here both layers exist: the upper // one is painted on and holds the cell (4,0), the lower one the 3 × 3 block. - const nameField = await inInspector(t, '__gm.tilemap.layerNameAt()'); + const nameField = await inPanel(t, '__gm.tilemap.layerNameAt()'); if (nameField) { await click(nameField.x, nameField.y); await press('a', { ctrl: true }); @@ -338,7 +384,7 @@ export async function tilemap(t) { const upperWhole = await quarter(4, 0); /** Chooses an entry of the Show dropdown: the topmost hit, which is the open menu. */ const show = async (label) => { - const button = await inInspector(t, '__gm.tilemap.focusAt()'); + const button = await inPanel(t, '__gm.tilemap.focusAt()'); if (button) await click(button.x, button.y); await settle(); const entry = await t.locate(label, true); @@ -372,6 +418,10 @@ export async function tilemap(t) { ); // Only the selected map is singled out; deselected it is drawn whole, and the model never changed. + // The brush is taken up first, so the deselection has a tool to put down (#142). + await evaluate("__gm.state.setTilemapPaint({ tool: 'brush' })"); + const paintBefore = await json('__gm.tilemap.paint()'); + const cellSelected = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); await evaluate('__gm.state.select(null)'); await settle(); const deselected = await quarter(1, 1); @@ -381,7 +431,56 @@ export async function tilemap(t) { colourDistance(deselected, lowerWhole) <= 12 && layers.every((l) => !l.hidden), JSON.stringify({ deselected, lowerWhole, layers }), ); + const gone = await json('__gm.tilemap.panel()'); + const cellGone = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); + const paintAfter = await json('__gm.tilemap.paint()'); + check( + 'deselecting closes the panel, gives the scene view its room back and puts the brush down', + !gone.shown && + gone.viewport.w > panel.viewport.w && + Math.abs(cellGone.x - cellSelected.x) <= 1 && + Math.abs(cellGone.y - cellSelected.y) <= 1 && + paintAfter.tool === 'select' && + paintAfter.tileId === paintBefore.tileId && + paintAfter.layerId === paintBefore.layerId, + JSON.stringify({ gone, cellSelected, cellGone, paintBefore, paintAfter }), + ); + + // Two objects selected are no single map to paint, even when both are tilemaps; and the panel + // belongs to the scene tab, so another tab takes it along while the map stays selected. + const second = await json( + `__gm.state.activeScene.objects.find((o) => o.id !== ${JSON.stringify(MAP_OBJECT)} && !o.parentId).id`, + ); + await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT, second], objectId: MAP_OBJECT }); + await settle(); + const withTwo = (await json('__gm.tilemap.panel()')).shown; await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); + await settle(); + await openTab(t, 'graph'); + await settle(); + const onOtherTab = (await json('__gm.tilemap.panel()')).shown; + await openTab(t, 'scene'); + await settle(); + const back = await json('__gm.tilemap.panel()'); + check( + 'the panel stays away with two objects selected and on another tab, and comes back with the scene', + withTwo === false && onOtherTab === false && back.shown && Math.abs(back.panel.w - panel.panel.w) <= 1, + JSON.stringify({ second, withTwo, onOtherTab, back }), + ); + const cellBack = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); + await drag(back.splitter.x, back.splitter.y, back.splitter.x - 60, back.splitter.y); + await settle(); + const wider = await json('__gm.tilemap.panel()'); + const cellWider = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); + check( + 'the splitter widens the panel at the expense of the scene view, and the map stays put', + Math.abs(wider.panel.w - back.panel.w - 60) <= 1 && + Math.abs(back.viewport.w - wider.viewport.w - 60) <= 1 && + Math.abs(cellWider.x - cellBack.x) <= 1, + JSON.stringify({ back, wider, cellBack, cellWider }), + ); + await drag(wider.splitter.x, wider.splitter.y, wider.splitter.x + 60, wider.splitter.y); + await settle(); await show('All layers'); await mutate( "__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']", @@ -390,7 +489,7 @@ export async function tilemap(t) { await settle(); // 40i. Back to Select: the map is moved again instead of painted on. - await clickInInspector(t, '__gm.tilemap.toolAt("select")'); + await clickInPanel(t, '__gm.tilemap.toolAt("select")'); await settle(); const before = (await t.object(MAP_OBJECT)).position; const grab = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 1)`);