The tilemap painting tools move out of the inspector into their own panel beside the scene view, shown only while exactly one placed tilemap is selected in the scene tab; the free viewports keep their content in place on screen when they are resized (#142)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f53279d065
commit
5046ddb810
9 files changed
+399
-66
No files matched your search
@@ -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
|
||||
|
||||
+33
-1
@@ -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<CentreTab, …>` 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).
|
||||
|
||||
+17
-2
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(',');
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+125
-26
@@ -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)`);
|
||||
|
||||
Reference in new issue
Block a user