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
+187
-195
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user