A placed tilemap grows and shrinks at every edge: the new GRID EDGES block in the inspector adds or removes one column or row at Left, Top, Right or Bottom as one undo step, shifting the cells of all layers and moving the placement through its own matrix so the content stays in place on screen; removed painted cells are kept outside the grid and return when the edge grows again, and the Columns and Rows fields of a placement keep the content in place too (#136)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b32d3cbf3
commit
4728f4fab5
9 files changed
+588
-11
No files matched your search
@@ -2,15 +2,34 @@
|
||||
* Painting a tilemap and managing its layers (#128). One stroke is one command whatever it covers:
|
||||
* the pointer decides where a stroke ends, so an undo has to take back the whole line and not the
|
||||
* last cell of it. The grid size and the cell size are ordinary properties and go through
|
||||
* `SetAssetPropertyCommand`.
|
||||
* `SetAssetPropertyCommand`; on a placed tilemap the grid size goes through
|
||||
* `ResizeTilemapEdgeCommand`, which keeps the painted cells where they are in the scene (#136).
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import type { TilemapLayer } from '../model/Asset';
|
||||
import { cellKey } from '../model/tilemap';
|
||||
import type { Asset, TilemapLayer } from '../model/Asset';
|
||||
import type { ObjectRef } from '../model/instance';
|
||||
import { apply } from '../model/matrix';
|
||||
import type { Position } from '../model/Properties';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import {
|
||||
cellKey,
|
||||
edgeCompensation,
|
||||
edgeSizeKey,
|
||||
shiftedCells,
|
||||
TILEMAP_COLUMNS_KEY,
|
||||
TILEMAP_MAX_CELLS,
|
||||
type TilemapEdge,
|
||||
tilemapColumns,
|
||||
tilemapRows,
|
||||
tilemapTileSize,
|
||||
} from '../model/tilemap';
|
||||
import type { Project } from '../model/Project';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { localMatrixOf, transformOfObject } from '../properties/transform';
|
||||
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 {
|
||||
@@ -61,6 +80,79 @@ export class PaintTilesCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a placed tilemap's origin goes when `edge` gains `count` cells, starting from `from`, so the
|
||||
* painted cells stay where they are in the scene (#136). The shift is worked out in the tilemap's
|
||||
* own frame and carried through its turn, size and mirror, so a rotated map holds still as well.
|
||||
*/
|
||||
export function heldPosition(
|
||||
asset: Asset,
|
||||
object: SceneObject,
|
||||
from: Position,
|
||||
edge: TilemapEdge,
|
||||
count: number,
|
||||
tileSize: number,
|
||||
): Position {
|
||||
const at = apply(
|
||||
localMatrixOf(from, transformOfObject(asset, object)),
|
||||
edgeCompensation(edge, count, tileSize),
|
||||
);
|
||||
return { x: at.x, y: at.y, z: from.z };
|
||||
}
|
||||
|
||||
/** The placed tilemap's current columns or rows, whichever `key` names. */
|
||||
export function placedGridSize(project: Project, asset: Asset, object: SceneObject, key: string): number {
|
||||
const props = resolveSceneObjectProps(project, asset, object);
|
||||
return key === TILEMAP_COLUMNS_KEY ? tilemapColumns(props) : tilemapRows(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds (`count` > 0) or removes columns or rows at one edge of a placed tilemap (#136), as one undo
|
||||
* step. The size is the placement's own, as when Columns and Rows are edited in the inspector. At the
|
||||
* left and top edge the cells of every layer move along, because cell `0,0` is always the top-left
|
||||
* corner – and cells live on the asset, so they move in every other placement too. Removing throws
|
||||
* nothing away: what falls off lies outside the grid and comes back with the edge.
|
||||
*/
|
||||
export class ResizeTilemapEdgeCommand extends ObjectEditCommand {
|
||||
readonly label: string;
|
||||
private shifted: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
|
||||
|
||||
constructor(
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
private readonly edge: TilemapEdge,
|
||||
private readonly count: number,
|
||||
) {
|
||||
super(holderId, objectId);
|
||||
const what = edgeSizeKey(edge) === TILEMAP_COLUMNS_KEY ? 'column' : 'row';
|
||||
this.label = `${count >= 0 ? 'Add' : 'Remove'} ${what}${Math.abs(count) === 1 ? '' : 's'} (${edge})`;
|
||||
}
|
||||
|
||||
protected override apply(ref: ObjectRef, project: Project): void {
|
||||
const asset = requireTilemap(project, ref.object.assetId);
|
||||
const key = edgeSizeKey(this.edge);
|
||||
const size = placedGridSize(project, asset, ref.object, key) + this.count;
|
||||
if (size < 1) throw new CommandError('A tilemap keeps at least one column and one row');
|
||||
if (size > TILEMAP_MAX_CELLS)
|
||||
throw new CommandError(`A tilemap has at most ${TILEMAP_MAX_CELLS} columns and rows`);
|
||||
const tile = tilemapTileSize(resolveSceneObjectProps(project, asset, ref.object));
|
||||
ref.setPosition(heldPosition(asset, ref.object, ref.object.position, this.edge, this.count, tile));
|
||||
ref.setOverride(key, size);
|
||||
this.shifted = [];
|
||||
if (this.edge !== 'left' && this.edge !== 'top') return;
|
||||
const dx = this.edge === 'left' ? this.count : 0;
|
||||
const dy = this.edge === 'top' ? this.count : 0;
|
||||
for (const layer of asset.layers) {
|
||||
this.shifted.push({ layer, cells: layer.cells });
|
||||
layer.cells = shiftedCells(layer.cells, dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
protected override revert(): void {
|
||||
for (const { layer, cells } of this.shifted) layer.cells = cells;
|
||||
}
|
||||
}
|
||||
|
||||
export class AddTilemapLayerCommand implements Command {
|
||||
readonly label = 'Add layer';
|
||||
readonly layerId = newId();
|
||||
|
||||
@@ -17,6 +17,8 @@ import { findAssetOf, type Project } from './Project';
|
||||
export const TILEMAP_COLUMNS_KEY = 'columns';
|
||||
export const TILEMAP_ROWS_KEY = 'rows';
|
||||
export const TILEMAP_TILE_KEY = 'tileSize';
|
||||
/** The most columns or rows a grid may have – the schema's limit, and the edge buttons' too. */
|
||||
export const TILEMAP_MAX_CELLS = 1000;
|
||||
|
||||
/**
|
||||
* The grid is read from a property bag rather than from the asset: a placed tilemap may override
|
||||
@@ -163,6 +165,54 @@ export function cellOrigin(props: PropertyBag, x: number, y: number): { x: numbe
|
||||
return { x: x * tile - w / 2, y: y * tile - h / 2 };
|
||||
}
|
||||
|
||||
/** A side of the grid that grows or shrinks (#136). */
|
||||
export type TilemapEdge = 'left' | 'top' | 'right' | 'bottom';
|
||||
|
||||
export const TILEMAP_EDGES: readonly TilemapEdge[] = ['left', 'top', 'right', 'bottom'];
|
||||
|
||||
/** Left and right change the columns, top and bottom the rows. */
|
||||
export function edgeSizeKey(edge: TilemapEdge): string {
|
||||
return edge === 'left' || edge === 'right' ? TILEMAP_COLUMNS_KEY : TILEMAP_ROWS_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* One layer's cells moved by whole cells. Growing at the left or top edge is this: cell `0,0` is
|
||||
* always the top-left corner, so what was painted moves one column or row further in. Nothing is
|
||||
* dropped – a cell pushed below zero lies outside the grid and comes back when the edge grows again.
|
||||
*/
|
||||
export function shiftedCells(cells: Record<string, string>, dx: number, dy: number): Record<string, string> {
|
||||
const moved: Record<string, string> = {};
|
||||
for (const [key, tileId] of Object.entries(cells)) {
|
||||
const at = parseCellKey(key);
|
||||
if (at) moved[cellKey(at.x + dx, at.y + dy)] = tileId;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the origin moves, in the tilemap's own frame, for the painted cells to stay where they are
|
||||
* when `edge` gains `count` cells (negative removes them). The grid is centred on the origin, so
|
||||
* every added column pushes the content half a cell away from that edge; the origin follows by as
|
||||
* much the other way.
|
||||
*/
|
||||
export function edgeCompensation(
|
||||
edge: TilemapEdge,
|
||||
count: number,
|
||||
tileSize: number,
|
||||
): { x: number; y: number } {
|
||||
const half = (count * tileSize) / 2;
|
||||
switch (edge) {
|
||||
case 'left':
|
||||
return { x: -half, y: 0 };
|
||||
case 'right':
|
||||
return { x: half, y: 0 };
|
||||
case 'top':
|
||||
return { x: 0, y: -half };
|
||||
case 'bottom':
|
||||
return { x: 0, y: half };
|
||||
}
|
||||
}
|
||||
|
||||
/** The cells of the rectangle between two corners, both included and clipped to the grid. */
|
||||
export function cellsInRect(
|
||||
props: PropertyBag,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from './kinds';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { TILE_FPS_KEY, TILE_KIND_KEY, TILE_PLAYBACK_KEY, TILE_SOLID_KEY } from '../model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||||
import { SLICE_SCALE_KEY } from '../model/texture';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
@@ -254,8 +254,24 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
// how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto
|
||||
// that cell, so one tilemap can mix tiles drawn at different resolutions.
|
||||
tilemap: [
|
||||
{ key: TILEMAP_COLUMNS_KEY, label: 'Columns', kind: 'number', default: 20, min: 1, max: 1000, step: 1 },
|
||||
{ key: TILEMAP_ROWS_KEY, label: 'Rows', kind: 'number', default: 15, min: 1, max: 1000, step: 1 },
|
||||
{
|
||||
key: TILEMAP_COLUMNS_KEY,
|
||||
label: 'Columns',
|
||||
kind: 'number',
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: TILEMAP_MAX_CELLS,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: TILEMAP_ROWS_KEY,
|
||||
label: 'Rows',
|
||||
kind: 'number',
|
||||
default: 15,
|
||||
min: 1,
|
||||
max: TILEMAP_MAX_CELLS,
|
||||
step: 1,
|
||||
},
|
||||
{ key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 },
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
RevertPositionToTemplateCommand,
|
||||
} from '../../core/commands/applyCommands';
|
||||
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
||||
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
|
||||
import { findUsages } from '../../core/model/usages';
|
||||
import {
|
||||
ClearOverrideCommand,
|
||||
MoveSceneObjectCommand,
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
deviatesFromTemplate,
|
||||
resolveSceneObjectProps,
|
||||
} from '../../core/properties/resolve';
|
||||
import { Button } from '../../ui';
|
||||
import { Button, Theme } from '../../ui';
|
||||
import { createContainer, removeObjects } from '../actions';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { defSignature } from './assetSection';
|
||||
@@ -47,7 +50,7 @@ import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './h
|
||||
import type { EditorState, ListSlot } from '../state';
|
||||
import { parentRow } from './parentRow';
|
||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { overrideValue } from './valueSpecs';
|
||||
import { overrideValue, tilemapSizeValue } from './valueSpecs';
|
||||
import { buildVariable } from './variableRows';
|
||||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||||
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
||||
@@ -82,7 +85,9 @@ export function objectSignature(
|
||||
? 'instance'
|
||||
: '';
|
||||
const rows = schemaForObject(project, asset, object);
|
||||
return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',');
|
||||
// A tilemap warns when it is placed more than once, so the number of placements shapes it too.
|
||||
const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : '';
|
||||
return `${object.parentId}|${kind}${placed}|` + rows.map(defSignature).join(',');
|
||||
}
|
||||
|
||||
/** Several objects: what they are, and the actions that apply to all of them at once. */
|
||||
@@ -204,9 +209,36 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
|
||||
markObjectRows(host, object, asset, target);
|
||||
|
||||
if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object);
|
||||
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay
|
||||
* where they are in the scene (#136). The cells belong to the asset, so they move in every other
|
||||
* placement too – which only this one's position is corrected for, hence the note.
|
||||
*/
|
||||
function gridEdgesSection(host: InspectorHost, holderId: string, object: SceneObject): void {
|
||||
addTitle(host, 'GRID EDGES');
|
||||
for (const edge of TILEMAP_EDGES) {
|
||||
const { row } = labelledRow(edge[0]!.toUpperCase() + edge.slice(1));
|
||||
for (const count of [-1, 1])
|
||||
row.add(
|
||||
new Button(count < 0 ? '−' : '+', {
|
||||
variant: 'ghost',
|
||||
minWidth: Theme.controlHeight,
|
||||
onClick: () => host.state.execute(new ResizeTilemapEdgeCommand(holderId, object.id, edge, count)),
|
||||
}),
|
||||
);
|
||||
host.body.add(row);
|
||||
}
|
||||
const others = findUsages(host.state.project, object.assetId).length - 1;
|
||||
if (others > 0) {
|
||||
addNote(host, `Placed ${others} more time${others === 1 ? '' : 's'}: at the left and top`, 'warning');
|
||||
addNote(host, 'edge the cells move there as well.', 'warning');
|
||||
} else addNote(host, 'The painted cells stay where they are in the scene.', 'small');
|
||||
}
|
||||
|
||||
function assetLink(host: InspectorHost, label: string, asset: Asset): void {
|
||||
const { row } = labelledRow(label);
|
||||
row.add(
|
||||
@@ -284,7 +316,10 @@ function propertyRow(
|
||||
variable = false,
|
||||
): FieldRow {
|
||||
const bases = () => deviations(host.state, holderId, objectId, def.key);
|
||||
const overrideSpec = overrideValue(host.state, holderId, objectId, def.key);
|
||||
const overrideSpec =
|
||||
owner.type === 'tilemap' && (def.key === TILEMAP_COLUMNS_KEY || def.key === TILEMAP_ROWS_KEY)
|
||||
? tilemapSizeValue(host.state, holderId, objectId, def.key)
|
||||
: overrideValue(host.state, holderId, objectId, def.key);
|
||||
const fieldSlot = (field: string): ListSlot => ({
|
||||
kind: 'object',
|
||||
holderId,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { heldPosition, placedGridSize, ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
|
||||
import { findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import type { Position } from '../../core/model/Properties';
|
||||
import { findAsset, findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { TILEMAP_COLUMNS_KEY, type TilemapEdge, tilemapTileSize } from '../../core/model/tilemap';
|
||||
import { coerceValue, schemaFor } from '../../core/properties/PropertySchema';
|
||||
import { resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import type { EditorState } from '../state';
|
||||
import { currentProps, currentRef } from './objectQueries';
|
||||
import type { PreviewedValue } from './previewHandlers';
|
||||
@@ -36,6 +41,57 @@ export function overrideValue(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns or Rows of a placed tilemap (#136): the grid grows and shrinks at the right or bottom edge
|
||||
* while the painted cells stay where they are in the scene, so the origin moves along. The preview
|
||||
* works from where the edit started rather than step by step – dragging the value back and forth
|
||||
* would otherwise leave rounding drift behind, and restoring it before the commit would not be exact.
|
||||
*/
|
||||
export function tilemapSizeValue(
|
||||
state: EditorState,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
key: string,
|
||||
): PreviewedValue<ObjectRef> {
|
||||
const plain = overrideValue(state, holderId, objectId, key);
|
||||
const edge: TilemapEdge = key === TILEMAP_COLUMNS_KEY ? 'right' : 'bottom';
|
||||
let start: { position: Position; size: number } | undefined;
|
||||
// Writes the value, then puts the origin where the size now asks for, counted from the start.
|
||||
const holding = (ref: ObjectRef, write: () => void): void => {
|
||||
const asset = findAsset(state.project, ref.object.assetId);
|
||||
if (!asset) return write();
|
||||
start ??= {
|
||||
position: { ...ref.object.position },
|
||||
size: placedGridSize(state.project, asset, ref.object, key),
|
||||
};
|
||||
write();
|
||||
const props = resolveSceneObjectProps(state.project, asset, ref.object);
|
||||
const size = placedGridSize(state.project, asset, ref.object, key);
|
||||
ref.setPosition(
|
||||
heldPosition(asset, ref.object, start.position, edge, size - start.size, tilemapTileSize(props)),
|
||||
);
|
||||
};
|
||||
return {
|
||||
...plain,
|
||||
write: (ref, value) => holding(ref, () => ref.setOverride(key, value)),
|
||||
remove: (ref) => holding(ref, () => ref.setOverride(key, undefined)),
|
||||
commit: (value) => {
|
||||
start = undefined;
|
||||
const ref = currentRef(state, holderId, objectId);
|
||||
const asset = ref && findAsset(state.project, ref.object.assetId);
|
||||
const def = asset && schemaFor('tilemap').find((d) => d.key === key);
|
||||
if (!ref || !asset || !def) return plain.commit(value);
|
||||
const next = Number(coerceValue(def, value));
|
||||
return new ResizeTilemapEdgeCommand(
|
||||
holderId,
|
||||
objectId,
|
||||
edge,
|
||||
next - placedGridSize(state.project, asset, ref.object, key),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeParamValue(
|
||||
state: EditorState,
|
||||
assetId: string,
|
||||
|
||||
Reference in new issue
Block a user