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
+187 -195

No files matched your search

+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.
+13 -32
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 };
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<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) {
+13 -28
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 };
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(''));