diff --git a/README.md b/README.md index 102ef1a1..370ce69b 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ way in. ## In one screen - An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, collider, container, - texture, font, sound, animation, state machine, shader, particles, variables) with properties and - no position. + texture, font, sound, animation, state machine, shader, particles, variables, tile, tilemap) with + properties and no position. - A **scene object** places an asset: position, sparse **overrides** of its properties, an optional parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑. - A **container** is a reusable group edited once and placed many times; its content is resolved @@ -65,6 +65,9 @@ beside it – so locally take the gate plus the step your change touches and lea | [Particles](https://git.nockal.com/meleki/game-maker/wiki/Particles) | Particle graphs: a particle as a function of its age | | [State machines](https://git.nockal.com/meleki/game-maker/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` | | [Containers](https://git.nockal.com/meleki/game-maker/wiki/Containers) | Templates, instances, screens | +| [Tiles](https://git.nockal.com/meleki/game-maker/wiki/Tiles) | The tile asset, plain tiles and the two autotile kinds, the pictures as animation steps, the Tile tab | +| [Tilemaps](https://git.nockal.com/meleki/game-maker/wiki/Tilemaps) | The tilemap asset, painting in the scene view, layers, what it looks like in the game | +| [Plugins](https://git.nockal.com/meleki/game-maker/wiki/Plugins) | Extending the editor: the plugin file, how it is loaded, switching one on and off, API v1 | | [Keyboard](https://git.nockal.com/meleki/game-maker/wiki/Keyboard) | Every shortcut | | [Architecture](https://git.nockal.com/meleki/game-maker/wiki/Architecture) | Stack, source layout, the rules that keep it organised | | [Development](https://git.nockal.com/meleki/game-maker/wiki/Development) | Commands, the e2e suite, the example games, CI | diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 308ad34a..c293b7c8 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -101,7 +101,7 @@ export interface TextureImage { * result, which carries `bytes` and – since original names are kept – a `name` of its own. A * spread would put the *file's* name where the asset's belongs and silently overrule the caller. */ -export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset { +function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset { return { id, name, @@ -147,8 +147,8 @@ export class CreateTextureCommand extends ImportFileCommand { } } -export function buildFontAsset(name: string, file: string, id = newId()): FontAsset { - return { id, name, props: defaultsFor('font'), type: 'font', file }; +function buildFontAsset(name: string, file: string): FontAsset { + return { id: newId(), name, props: defaultsFor('font'), type: 'font', file }; } export class CreateFontCommand extends ImportFileCommand { @@ -157,8 +157,8 @@ export class CreateFontCommand extends ImportFileCommand { } } -export function buildSoundAsset(name: string, file: string, id = newId()): SoundAsset { - return { id, name, props: defaultsFor('sound'), type: 'sound', file }; +function buildSoundAsset(name: string, file: string): SoundAsset { + return { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file }; } export class CreateSoundCommand extends ImportFileCommand { diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 396f98d4..334491ab 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -57,7 +57,7 @@ export function requireObjectRef(project: Project, holderId: string, objectId: s * Rejects an object that only exists because a container template says so. Its structure – what * exists, what it is parented to – is the template's business, not the instance's. */ -export function requireStructural(project: Project, holder: ObjectTree, objectId: string): void { +function requireStructural(project: Project, holder: ObjectTree, objectId: string): void { if (!isTemplateContent(holder, objectId)) return; // Not resolvable at all is a plain "not found", which the caller's own lookup words better. const object = objectRef(project, holder, objectId)?.object; diff --git a/src/core/commands/tileCommands.ts b/src/core/commands/tileCommands.ts index 2e268439..bb27142a 100644 --- a/src/core/commands/tileCommands.ts +++ b/src/core/commands/tileCommands.ts @@ -5,6 +5,7 @@ */ import type { Project } from '../model/Project'; import { resolvePicture } from '../model/texture'; +import { moveItem } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireTile } from './require'; @@ -76,12 +77,10 @@ export class MoveTilePictureCommand implements Command { private move(project: Project, from: number, to: number): void { const asset = requireTile(project, this.assetId); - const picture = asset.pictures[from]; - if (picture === undefined) throw new CommandError(`${asset.name} has no picture ${from + 1}`); - if (to < 0 || to >= asset.pictures.length) + if (asset.pictures[from] === undefined) + throw new CommandError(`${asset.name} has no picture ${from + 1}`); + if (!moveItem(asset.pictures, from, to)) throw new CommandError(`${asset.name} has no place ${to + 1} to move it to`); - asset.pictures.splice(from, 1); - asset.pictures.splice(to, 0, picture); } } diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 051d90e5..bbf8aedc 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -12,6 +12,8 @@ import { apply } from '../model/matrix'; import type { Position } from '../model/Properties'; import type { SceneObject } from '../model/SceneObject'; import { + type Cell, + cellAt, cellKey, edgeCompensation, edgeSizeKey, @@ -26,15 +28,14 @@ import { import type { Project } from '../model/Project'; import { resolveSceneObjectProps } from '../properties/resolve'; import { localMatrixOf, transformOfObject } from '../properties/transform'; +import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { nextNumberedName } from './naming'; import { type Command, CommandError } from './Command'; import { requireTilemap, requireTilemapLayer } from './require'; import { ObjectEditCommand } from './sceneObjectCommands'; /** One cell as a stroke wants it: `tileId` empty erases. */ -export interface CellEdit { - x: number; - y: number; +export interface CellEdit extends Cell { tileId: string; } @@ -57,7 +58,7 @@ export class PaintTilesCommand implements Command { /** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */ static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean { - return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.tileId); + return edits.some((edit) => cellAt(layer, edit.x, edit.y) !== edit.tileId); } execute(project: Project): void { @@ -170,15 +171,14 @@ export class AddTilemapLayerCommand implements Command { undo(project: Project): void { const asset = requireTilemap(project, this.assetId); - asset.layers = asset.layers.filter((layer) => layer.id !== this.layerId); + asset.layers = removeById(asset.layers, this.layerId); } } /** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */ export class RemoveTilemapLayerCommand implements Command { readonly label = 'Remove layer'; - private removed: TilemapLayer | null = null; - private index = -1; + private removed: Removed | undefined; constructor( private readonly assetId: string, @@ -188,15 +188,13 @@ export class RemoveTilemapLayerCommand implements Command { execute(project: Project): void { const asset = requireTilemap(project, this.assetId); if (asset.layers.length <= 1) throw new CommandError('A tilemap keeps at least one layer'); - this.index = asset.layers.findIndex((layer) => layer.id === this.layerId); - const layer = asset.layers[this.index]; - if (!layer) throw new CommandError(`Layer ${this.layerId} not found`); - this.removed = layer; - asset.layers.splice(this.index, 1); + const removed = removeFirst(asset.layers, (layer) => layer.id === this.layerId); + if (!removed) throw new CommandError(`Layer ${this.layerId} not found`); + this.removed = removed; } undo(project: Project): void { - if (this.removed) requireTilemap(project, this.assetId).layers.splice(this.index, 0, this.removed); + if (this.removed) restoreAt(requireTilemap(project, this.assetId).layers, [this.removed]); } } @@ -219,11 +217,8 @@ export class MoveTilemapLayerCommand implements Command { } private move(project: Project, from: number, to: number): void { - const asset = requireTilemap(project, this.assetId); - const layer = asset.layers[from]; - if (!layer || to < 0 || to >= asset.layers.length) throw new CommandError('No such layer'); - asset.layers.splice(from, 1); - asset.layers.splice(to, 0, layer); + if (!moveItem(requireTilemap(project, this.assetId).layers, from, to)) + throw new CommandError('No such layer'); } } diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index ce8e646b..290c76fe 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -26,14 +26,14 @@ export const TILE_COLLISION_SCALE_MIN = 0.1; export const TILE_COLLISION_SCALE_MAX = 2; /** Only the two that make sense for a tile that is always on screen; there is no end to hold. */ -export const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; +const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; export type TilePlayback = (typeof TILE_PLAYBACKS)[number]; /** Quadrant columns of a block: one tile for `none`, two for the autotile blocks. */ -export const BLOCK_COLUMNS: Record = { none: 2, floor: 4, wall: 4 }; +const BLOCK_COLUMNS: Record = { none: 2, floor: 4, wall: 4 }; /** Quadrant rows of a block: `none` is one tile tall, `floor` three and `wall` two. */ -export const BLOCK_ROWS: Record = { none: 2, floor: 6, wall: 4 }; +const BLOCK_ROWS: Record = { none: 2, floor: 6, wall: 4 }; export function tileKind(asset: TileAsset): TileKind { const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none'); diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index faf09237..8f70a6b5 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -49,11 +49,17 @@ export function tilemapSize(props: PropertyBag): { w: number; h: number } { return { w: tilemapColumns(props) * tile, h: tilemapRows(props) * tile }; } +/** A cell of the grid by column and row; `0,0` is the top-left corner. */ +export interface Cell { + x: number; + y: number; +} + export function cellKey(x: number, y: number): string { return `${x},${y}`; } -export function parseCellKey(key: string): { x: number; y: number } | null { +export function parseCellKey(key: string): Cell | null { const [x, y] = key.split(',').map(Number); return Number.isInteger(x) && Number.isInteger(y) ? { x: x as number, y: y as number } : null; } @@ -152,7 +158,7 @@ export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] { * The cell a point in the tilemap's own frame falls into. The grid is centred on the object's * position, as every footprint is, so the top-left corner lies half a map up and to the left. */ -export function cellAtPoint(props: PropertyBag, x: number, y: number): { x: number; y: number } { +export function cellAtPoint(props: PropertyBag, x: number, y: number): Cell { const tile = tilemapTileSize(props); const { w, h } = tilemapSize(props); return { x: Math.floor((x + w / 2) / tile), y: Math.floor((y + h / 2) / tile) }; @@ -214,12 +220,8 @@ export function edgeCompensation( } /** The cells of the rectangle between two corners, both included and clipped to the grid. */ -export function cellsInRect( - props: PropertyBag, - a: { x: number; y: number }, - b: { x: number; y: number }, -): { x: number; y: number }[] { - const cells: { x: number; y: number }[] = []; +export function cellsInRect(props: PropertyBag, a: Cell, b: Cell): Cell[] { + const cells: Cell[] = []; const x0 = Math.max(0, Math.min(a.x, b.x)); const x1 = Math.min(tilemapColumns(props) - 1, Math.max(a.x, b.x)); const y0 = Math.max(0, Math.min(a.y, b.y)); @@ -234,12 +236,7 @@ export function cellsInRect( * count, or a fill would leak through the gap between two corners. Filling with the tile already * there changes nothing, so that returns no cells. */ -export function floodFill( - props: PropertyBag, - layer: TilemapLayer, - start: { x: number; y: number }, - tileId: string, -): { x: number; y: number }[] { +export function floodFill(props: PropertyBag, layer: TilemapLayer, start: Cell, tileId: string): Cell[] { if (!inGrid(props, start.x, start.y)) return []; const from = cellAt(layer, start.x, start.y); if (from === tileId) return []; @@ -269,13 +266,10 @@ export function floodFill( * The cells a stroke from one cell to another covers. A pointer jumps between events, so without * this a fast drag would paint dots instead of a line. */ -export function cellsAlong( - from: { x: number; y: number }, - to: { x: number; y: number }, -): { x: number; y: number }[] { +export function cellsAlong(from: Cell, to: Cell): Cell[] { const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y)); if (steps === 0) return [{ ...to }]; - const cells: { x: number; y: number }[] = []; + const cells: Cell[] = []; for (let i = 1; i <= steps; i++) cells.push({ x: Math.round(from.x + ((to.x - from.x) * i) / steps), diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index 6820a230..0cf9bd04 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -16,10 +16,7 @@ export interface SceneObjectRef { object: SceneObject; } -export function objectsWhere( - project: Project, - predicate: (object: SceneObject) => boolean, -): SceneObjectRef[] { +function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] { const result: SceneObjectRef[] = []; for (const holder of objectHolders(project)) { for (const object of holder.objects) if (predicate(object)) result.push({ holder, object }); diff --git a/src/core/util/list.ts b/src/core/util/list.ts index aa6fd03d..dc989cc2 100644 --- a/src/core/util/list.ts +++ b/src/core/util/list.ts @@ -25,6 +25,14 @@ export function restoreAt(list: T[], removed: readonly Removed[]): void { for (const entry of [...removed].sort((a, b) => a.index - b.index)) list.splice(entry.index, 0, entry.item); } +/** In place; `false` means `from` or `to` is no place in the list, and nothing moved. */ +export function moveItem(list: T[], from: number, to: number): boolean { + const inside = (index: number) => Number.isInteger(index) && index >= 0 && index < list.length; + if (!inside(from) || !inside(to)) return false; + list.splice(to, 0, ...list.splice(from, 1)); + return true; +} + /** Returns a new list; unlike the helpers above this one does not mutate. */ export function removeById(list: readonly T[], id: string): T[] { return list.filter((item) => item.id !== id); diff --git a/src/editor/AssetTabEditor.ts b/src/editor/AssetTabEditor.ts index 37608631..ab111f4c 100644 --- a/src/editor/AssetTabEditor.ts +++ b/src/editor/AssetTabEditor.ts @@ -1,19 +1,8 @@ -import { Graphics } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; import { findAsset } from '../core/model/Project'; -import { - Button, - Dropdown, - HStack, - Label, - Menu, - type MenuItem, - Spacer, - Theme, - VStack, - type Widget, -} from '../ui'; +import { Button, Dropdown, Label, Menu, type MenuItem, Spacer, VStack, type Widget } from '../ui'; import type { EditorState } from './state'; +import { TabHeader } from './TabHeader'; /** * The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much. @@ -46,7 +35,6 @@ export abstract class AssetTabEditor< protected readonly info: Label; /** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */ canvas!: C; - private readonly headerBg = new Graphics(); constructor( protected readonly state: EditorState, @@ -62,9 +50,7 @@ export abstract class AssetTabEditor< /** Builds the header (with `strip` below it) around `canvas`, wires the state events and shows the first asset. */ protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void { this.canvas = canvas; - const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - header.minHeight = Theme.controlHeight + Theme.padding * 2; - header.addChildAt(this.headerBg, 0); + const header = new TabHeader(); header.add(new Label(this.options.noun, { style: 'muted' })); header.add(this.picker); for (const widget of extras) header.add(widget); @@ -153,12 +139,4 @@ export abstract class AssetTabEditor< items.push({ label: `No ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined }); new Menu(items, { searchable: true }).openBelow(this.picker); } - - override redraw(): void { - super.redraw(); - this.headerBg.clear(); - const h = Theme.controlHeight + Theme.padding * 2; - this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); - this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); - } } diff --git a/src/editor/TabHeader.ts b/src/editor/TabHeader.ts new file mode 100644 index 00000000..c153e8fc --- /dev/null +++ b/src/editor/TabHeader.ts @@ -0,0 +1,18 @@ +import { Graphics } from 'pixi.js'; +import { HStack, Theme } from '../ui'; + +/** The bar across the top of a tab: its controls in a row on the header colour, a line below. */ +export class TabHeader extends HStack { + private readonly bg = new Graphics(); + + constructor() { + super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + this.minHeight = Theme.controlHeight + Theme.padding * 2; + this.addChildAt(this.bg, 0); + } + + override redraw(): void { + this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader); + this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border); + } +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index c8bc9375..1ea04fc4 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 './panels/TilemapPanel'; +import { currentTilemapWidgets } from './panels/TilemapPanel'; import type { EditorState, TilemapTool } from './state'; /** @@ -571,33 +571,33 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** 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); + const button = currentTilemapWidgets()?.tools.get(tool as TilemapTool); return button ? centreOf(button) : null; }, tileButtonAt: (assetId: string) => - currentTilemapSection()?.palette?.swatchGlobal(assetId)?.centre ?? null, + currentTilemapWidgets()?.palette?.swatchGlobal(assetId)?.centre ?? null, /** A tile's box in the palette: its centre, where its picture sits and the palette's columns (#139). */ tileSwatch: (assetId: string) => { - const palette = currentTilemapSection()?.palette; + const palette = currentTilemapWidgets()?.palette; const swatch = palette?.swatchGlobal(assetId); return palette && swatch ? { ...swatch, columns: palette.columns } : null; }, addLayerAt: () => { - const button = currentTilemapSection()?.addLayer; + const button = currentTilemapWidgets()?.addLayer; return button ? centreOf(button) : null; }, /** The name field of the painted layer and the Show dropdown, with what they hold (#138). */ layerNameAt: () => { - const field = currentTilemapSection()?.layerName; + const field = currentTilemapWidgets()?.layerName; return field ? { ...centreOf(field), value: field.value } : null; }, focusAt: () => { - const button = currentTilemapSection()?.focus; + const button = currentTilemapWidgets()?.focus; return button ? { ...centreOf(button), label: button.text } : null; }, /** One row of the layer list, top-down as drawn: the layer, its switch and its buttons. */ layerRowAt: (index: number) => { - const row = currentTilemapSection()?.layers[index]; + const row = currentTilemapWidgets()?.layers[index]; return row ? { id: row.id, diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index 40a28b5e..73419f4c 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -1,11 +1,12 @@ -import type { Graphics } from 'pixi.js'; -import { clamp, drawGrid } from '../../ui'; +import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js'; +import { clamp, drawGrid, zoomFactor } from '../../ui'; import type { Point } from '../framing'; /** * What every surface that shows nodes shares with the graph editor: how far it zooms, how the grid * is drawn and how the wheel zooms about the pointer. The graph editor and a plugin's node board - * look and move the same because they both come through here. + * look and move the same because they both come through here; the Tile tab's board borrows the + * panning and zooming. */ /** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */ @@ -40,6 +41,33 @@ export function zoomedAbout( return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom }; } +/** + * Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer + * when its content takes none. `set` receives each new view and which gesture made it. + */ +export function panAndZoom( + surface: Container, + get: () => GraphView, + set: (view: GraphView, gesture: 'pan' | 'zoom') => void, + limits: { min: number; max: number } = ZOOM_LIMITS, +): void { + let panning: { start: Point; pan: Point } | null = null; + surface.on('pointerdown', (e: FederatedPointerEvent) => { + panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } }; + }); + surface.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!panning) return; + const { start, pan } = panning; + set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'); + }); + surface.on('pointerup', () => (panning = null)); + surface.on('pointerupoutside', () => (panning = null)); + surface.on('wheel', (e: FederatedWheelEvent) => { + set(zoomedAbout(get(), surface.toLocal(e.global), zoomFactor(e.deltaY), limits), 'zoom'); + e.preventDefault(); + }); +} + export function drawGraphGrid(g: Graphics, view: GraphView, w: number, h: number): void { g.clear(); const step = GRID_STEP * view.zoom; diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index 47251fb0..98184696 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -14,6 +14,7 @@ import { RenameTilemapLayerCommand, SetTilemapLayerHiddenCommand, } from '../../core/commands/tilemapCommands'; +import type { Command } from '../../core/commands/Command'; import type { TilemapAsset } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; @@ -117,16 +118,16 @@ export function paintedTilemap(state: EditorState): { objectId: string; asset: T */ export function paintedLayerId(state: EditorState, asset: TilemapAsset): string { const chosen = state.tilemapPaint.layerId; - if (asset.layers.some((layer) => layer.id === chosen)) return chosen; + if (findLayer(asset, chosen)) return chosen; return asset.layers[asset.layers.length - 1]?.id ?? ''; } /** - * The widgets of the section as they were last built. Every one of them carries an icon or a name + * The widgets of the panel as they were last built. Every one of them carries an icon or a name * that is on screen elsewhere too – the tile's row in the asset panel, the layer's name – so a * check cannot find them by their text; this is what it clicks instead. */ -export interface TilemapSectionWidgets { +export interface TilemapPanelWidgets { tools: Map; /** Null while the project has no tile. */ palette: TilePalette | null; @@ -138,10 +139,10 @@ export interface TilemapSectionWidgets { addLayer: Button; } -let built: TilemapSectionWidgets | null = null; +let built: TilemapPanelWidgets | 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 { +export function currentTilemapWidgets(): TilemapPanelWidgets | null { return built; } @@ -225,13 +226,13 @@ export class TilemapPanel extends Panel { built = null; this.title = target ? `Tilemap · ${target.asset.name}` : 'Tilemap'; this.setTitle(this.title); - if (target) buildTilemapSection(this.host, target.asset); + if (target) buildPanelContent(this.host, target.asset); this.scroll.setContent(this.host.body); this.onTargetChange(); } } -function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void { +function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { const { state } = host; const paint = state.tilemapPaint; const painted = paintedLayerId(state, asset); @@ -253,7 +254,7 @@ function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void { })), ).openBelow(focus), }); - const widgets: TilemapSectionWidgets = { + const widgets: TilemapPanelWidgets = { tools: new Map(), palette: null, layers: [], @@ -312,24 +313,11 @@ function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void { }); choose.toggled = layer.id === painted; row.add(choose, 1); - const up = new Button('↑', { - variant: 'ghost', - width: 24, - onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index + 1)), - }); - const down = new Button('↓', { - variant: 'ghost', - width: 24, - onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index - 1)), - }); - const remove = new Button('✕', { - variant: 'ghost', - width: 24, - onClick: () => state.execute(new RemoveTilemapLayerCommand(asset.id, layer.id)), - }); - row.add(up); - row.add(down); - row.add(remove); + const small = (label: string, command: () => Command) => + row.add(new Button(label, { variant: 'ghost', width: 24, onClick: () => state.execute(command()) })); + const up = small('↑', () => new MoveTilemapLayerCommand(asset.id, index, index + 1)); + const down = small('↓', () => new MoveTilemapLayerCommand(asset.id, index, index - 1)); + const remove = small('✕', () => new RemoveTilemapLayerCommand(asset.id, layer.id)); widgets.layers.push({ id: layer.id, name: choose, visible, up, down, remove }); host.body.add(row); } @@ -351,7 +339,7 @@ function tileIdsOf(state: EditorState): string[] { } /** - * What the section is built from, for the panel's rebuild check: the layers, the tiles on offer + * What the panel is built from, for its 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. diff --git a/src/editor/plugins/NodeBoard.ts b/src/editor/plugins/NodeBoard.ts index a93ba309..5b6f4cd0 100644 --- a/src/editor/plugins/NodeBoard.ts +++ b/src/editor/plugins/NodeBoard.ts @@ -1,9 +1,10 @@ -import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; +import { Container, Graphics } from 'pixi.js'; import type { NodeDef } from '../../core/graph'; -import { Button, HStack, Label, type Size, Spacer, Theme, VStack, Widget, zoomFactor } from '../../ui'; +import { Button, Label, type Size, Spacer, Theme, VStack, Widget } from '../../ui'; import { type Box, frameBoxes } from '../framing'; -import { drawGraphGrid, FIT_MARGIN, type GraphView, ZOOM_LIMITS, zoomedAbout } from '../graph/graphView'; +import { drawGraphGrid, FIT_MARGIN, type GraphView, panAndZoom, ZOOM_LIMITS } from '../graph/graphView'; import { NodeView } from '../graph/NodeView'; +import { TabHeader } from '../TabHeader'; /** One node as a plugin hands it over: a title at a position in graph units. */ export interface BoardNode { @@ -50,7 +51,6 @@ class BoardCanvas extends Widget { private readonly layer = new Container(); private readonly views: NodeView[] = []; private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 }; - private panning: { start: { x: number; y: number }; pan: { x: number; y: number } } | null = null; /** Framed once there is a size and something to frame; after that the view is the user's. */ private needsFit = true; @@ -59,22 +59,14 @@ class BoardCanvas extends Widget { this.layer.eventMode = 'none'; this.addChild(this.bg, this.grid, this.layer, this.clip); this.layer.mask = this.clip; - this.on('pointerdown', (e: FederatedPointerEvent) => { - this.panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...this.view.pan } }; - }); - this.on('globalpointermove', (e: FederatedPointerEvent) => { - if (!this.panning) return; - const { start, pan } = this.panning; - this.view.pan = { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y }; - this.applyView(); - }); - this.on('pointerup', () => (this.panning = null)); - this.on('pointerupoutside', () => (this.panning = null)); - this.on('wheel', (e: FederatedWheelEvent) => { - this.view = zoomedAbout(this.view, this.toLocal(e.global), zoomFactor(e.deltaY)); - this.applyView(); - e.preventDefault(); - }); + panAndZoom( + this, + () => this.view, + (view) => { + this.view = view; + this.applyView(); + }, + ); } setNodes(nodes: readonly BoardNode[]): void { @@ -138,14 +130,11 @@ export class NodeBoard extends VStack { readonly fitButton: Button; private readonly canvas = new BoardCanvas(); private readonly count = new Label('', { style: 'small', align: 'right' }); - private readonly headerBg = new Graphics(); constructor() { super(); this.fitButton = new Button('Fit', { onClick: () => this.fit() }); - const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - header.minHeight = Theme.controlHeight + Theme.padding * 2; - header.addChildAt(this.headerBg, 0); + const header = new TabHeader(); header.add(this.fitButton); header.add(new Spacer(), 1); header.add(this.count); @@ -169,12 +158,4 @@ export class NodeBoard extends VStack { debugInfo(): ReturnType { return this.canvas.debugInfo(); } - - override redraw(): void { - super.redraw(); - const h = Theme.controlHeight + Theme.padding * 2; - this.headerBg.clear(); - this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); - this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); - } } diff --git a/src/editor/plugins/PluginsPane.ts b/src/editor/plugins/PluginsPane.ts index 5ae915f5..8d105ea0 100644 --- a/src/editor/plugins/PluginsPane.ts +++ b/src/editor/plugins/PluginsPane.ts @@ -1,5 +1,5 @@ -import { Graphics } from 'pixi.js'; import { Checkbox, HStack, Label, ScrollContainer, Spacer, Theme, VStack } from '../../ui'; +import { TabHeader } from '../TabHeader'; import type { PluginManager, PluginRecord } from './loader'; const NAME_WIDTH = 200; @@ -65,7 +65,6 @@ class PluginRow extends HStack { * reading a name out of it would mean running it. */ export class PluginsPane extends VStack { - private readonly headerBg = new Graphics(); private readonly hint: Label; private readonly rows = new VStack(); private readonly scroll = new ScrollContainer(); @@ -75,9 +74,7 @@ export class PluginsPane extends VStack { constructor() { super(); - const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - header.minHeight = Theme.controlHeight + Theme.padding * 2; - header.addChildAt(this.headerBg, 0); + const header = new TabHeader(); header.add(new Label('Plugins', { style: 'bodyBold' })); header.add(new Spacer(Theme.padding)); this.hint = new Label('', { style: 'small' }); @@ -154,12 +151,4 @@ export class PluginsPane extends VStack { this.scroll.relayoutContent(); this.relayout(); } - - override redraw(): void { - super.redraw(); - const h = Theme.controlHeight + Theme.padding * 2; - this.headerBg.clear(); - this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); - this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); - } } diff --git a/src/editor/state.ts b/src/editor/state.ts index cfe90352..c3da48f8 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -59,7 +59,7 @@ export type ListSlot = { field?: string } & ( | { kind: 'node'; assetId: string; nodeId: string; key: string } ); -export function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean { +function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean { if (!a || !b) return a === b; if (a.field !== b.field) return false; switch (a.kind) { diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 89494e33..250e324f 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -1,11 +1,4 @@ -import { - Container, - type FederatedPointerEvent, - type FederatedWheelEvent, - Graphics, - Sprite, - Text, -} from 'pixi.js'; +import { Container, Graphics, Sprite, Text } from 'pixi.js'; import type { TileAsset } from '../../core/model/Asset'; import { autotileCombinations, @@ -26,10 +19,10 @@ import { tilePlayback, } from '../../core/model/tile'; import { findAssetOf } from '../../core/model/Project'; -import { type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; +import { type Size, Theme, textStyle, Widget } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { type Box, frameBoxes } from '../framing'; -import { type GraphView, zoomedAbout } from '../graph/graphView'; +import { type GraphView, panAndZoom } from '../graph/graphView'; import type { EditorState } from '../state'; /** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */ @@ -66,7 +59,6 @@ export class TileCanvas extends Widget { private readonly tiles: BoardTile[] = []; private readonly labels: Text[] = []; private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 }; - private panning: { start: { x: number; y: number }; pan: { x: number; y: number } } | null = null; private assetId = ''; /** Seconds the preview has been running – the only thing that moves without an edit. */ private time = 0; @@ -84,23 +76,16 @@ export class TileCanvas extends Widget { this.addChild(this.bg, this.content, this.hint, this.clip); this.content.mask = this.clip; - this.on('pointerdown', (e: FederatedPointerEvent) => { - this.panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...this.view.pan } }; - }); - this.on('globalpointermove', (e: FederatedPointerEvent) => { - if (!this.panning) return; - const { start, pan } = this.panning; - this.view.pan = { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y }; - this.applyView(); - }); - this.on('pointerup', () => (this.panning = null)); - this.on('pointerupoutside', () => (this.panning = null)); - this.on('wheel', (e: FederatedWheelEvent) => { - this.view = zoomedAbout(this.view, this.toLocal(e.global), zoomFactor(e.deltaY), ZOOM_LIMITS); - this.needsFit = false; - this.applyView(); - e.preventDefault(); - }); + panAndZoom( + this, + () => this.view, + (view, gesture) => { + this.view = view; + if (gesture === 'zoom') this.needsFit = false; + this.applyView(); + }, + ZOOM_LIMITS, + ); state.on('project', () => this.redraw()); state.on('load', () => this.show('')); diff --git a/tests/core/list.test.ts b/tests/core/list.test.ts index 61e876d9..24e7a7ca 100644 --- a/tests/core/list.test.ts +++ b/tests/core/list.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { removeById, removeFirst, restoreAt } from '../../src/core/util/list'; +import { moveItem, removeById, removeFirst, restoreAt } from '../../src/core/util/list'; const items = () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; @@ -30,6 +30,35 @@ describe('removeFirst', () => { }); }); +describe('moveItem', () => { + const ids = (list: { id: string }[]) => list.map((i) => i.id); + + it('moves forwards and backwards, and the reverse move restores the order', () => { + const list = items(); + expect(moveItem(list, 0, 2)).toBe(true); + expect(ids(list)).toEqual(['b', 'c', 'a']); + expect(moveItem(list, 2, 0)).toBe(true); + expect(ids(list)).toEqual(['a', 'b', 'c']); + moveItem(list, 2, 1); + expect(ids(list)).toEqual(['a', 'c', 'b']); + }); + + it('refuses a place outside the list or between two places and leaves the list as it was', () => { + for (const [from, to] of [ + [-1, 0], + [3, 0], + [0, -1], + [0, 3], + [0.5, 1], + [0, 1.5], + ] as const) { + const list = items(); + expect(moveItem(list, from, to)).toBe(false); + expect(ids(list)).toEqual(['a', 'b', 'c']); + } + }); +}); + describe('removeById', () => { it('returns a new list without that item and does not mutate the old one', () => { const list = items();