The tab headers are one TabHeader, the node board and the tile board pan and zoom through one helper, reordering tile pictures and tilemap layers goes through one moveItem, and a tilemap cell is one named type (#85)
game-maker / gate (push) Successful in 1m42s
game-maker / suite (push) Successful in 6m9s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 16:01:12 +02:00
1 parent c91c18f495
commit 6d7d0005cf
19 files changed
+185 -193

No files matched your search

+5 -2
View File
@@ -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 |
+5 -5
View File
@@ -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<TextureAsset> {
}
}
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<FontAsset> {
@@ -157,8 +157,8 @@ export class CreateFontCommand extends ImportFileCommand<FontAsset> {
}
}
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<SoundAsset> {
+1 -1
View File
@@ -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;
+4 -5
View File
@@ -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);
}
}
+13 -18
View File
@@ -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<TilemapLayer> | 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');
}
}
+3 -3
View File
@@ -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<TileKind, number> = { none: 2, floor: 4, wall: 4 };
const BLOCK_COLUMNS: Record<TileKind, number> = { 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<TileKind, number> = { none: 2, floor: 6, wall: 4 };
const BLOCK_ROWS: Record<TileKind, number> = { none: 2, floor: 6, wall: 4 };
export function tileKind(asset: TileAsset): TileKind {
const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none');
+13 -19
View File
@@ -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),
+1 -4
View File
@@ -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 });
+8
View File
@@ -25,6 +25,14 @@ export function restoreAt<T>(list: T[], removed: readonly Removed<T>[]): 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<T>(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<T extends { id: string }>(list: readonly T[], id: string): T[] {
return list.filter((item) => item.id !== id);
+3 -25
View File
@@ -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);
}
}
+18
View File
@@ -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);
}
}
+8 -8
View File
@@ -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,
+31 -3
View File
@@ -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;
+15 -27
View File
@@ -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<TilemapTool, Button>;
/** 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.
+12 -31
View File
@@ -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 };
panAndZoom(
this,
() => this.view,
(view) => {
this.view = view;
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();
});
},
);
}
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<BoardCanvas['debugInfo']> {
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);
}
}
+2 -13
View File
@@ -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);
}
}
+1 -1
View File
@@ -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) {
+12 -27
View File
@@ -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 };
panAndZoom(
this,
() => this.view,
(view, gesture) => {
this.view = view;
if (gesture === 'zoom') this.needsFit = false;
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();
});
},
ZOOM_LIMITS,
);
state.on('project', () => this.redraw());
state.on('load', () => this.show(''));
+30 -1
View File
@@ -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();