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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c91c18f495
commit
6d7d0005cf
19 files changed
+185
-193
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user