Let the agent edit animations, frames, tiles and tilemaps (#429)
After stage 5a the agent could build behaviors and scenes, but not animate an object, cut a texture into frames or paint a tilemap. Stage 5b adds three areas with 30 commands, 115 in all. - animation (12): set the root, add, retarget and make rows relative, set, move, copy and delete keys, easing, insert or remove frames, and curve points. - textures (8): add, change and delete frames, slice a grid, nine slice, sorting line and collision outline. - tiles (10): the images of a tile, painting tilemaps, layers (add, copy, move, change) and the border of a placed tilemap. - get_asset names the targets of an animation with their animatable properties. A tilemap up to 400 cells shows its cells, a bigger one only their count. - After such a call the editor opens the tab of the animation, curve, texture, tile or tilemap. A frame edit selects the frame there. - One paint step takes rectangles and single cells. Rectangles go first, so a cell can cut a hole into a rectangle. Cells outside the grid, ids that are no tile and more than 20 000 cells are refused. The grid is the tilemap's largest, a grown placement included. Left out on purpose: Generate collision, because it reads the pixels of the texture, and pasting keys, which goes through the clipboard. Found on the way: - Nine slice without fill now keeps the fill the image has. The model's default is repeat, not stretch as first described. - Painting ran the other way round, rectangles overwrote cells. Tests: - agent-art (new, 11) and the other agent tests green, 64/64. 5 bends of the code, all red. - e2e 71 on atx: 23/23, the new check opens the agent's animation in its tab and reads its targets. - Gate on atx: 321 files, 4969 tests. - Live on a test project: a Spin animation of 360 degrees in 30 frames matched 12 degrees per frame to 0.01 frames in the game. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d8fd6ff52
commit
d01e2b832e
10 files changed
+1110
-7
No files matched your search
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
AddAnimationTrackCommand,
|
||||
KeyStraightRunCommand,
|
||||
MoveAnimationKeysCommand,
|
||||
RemoveAnimationKeysCommand,
|
||||
RemoveAnimationTracksCommand,
|
||||
RetargetAnimationTrackCommand,
|
||||
SetAnimationKeyCommand,
|
||||
SetAnimationRootCommand,
|
||||
SetKeyInterpolationCommand,
|
||||
SetTrackRelativeCommand,
|
||||
ShiftAnimationFramesCommand,
|
||||
} from '../../../core/commands/animationCommands';
|
||||
import { SetCurvePointsCommand } from '../../../core/commands/curveCommands';
|
||||
import {
|
||||
ANIMATION_ROOT_TYPES,
|
||||
EASING_DIRECTIONS,
|
||||
EASING_SHAPES,
|
||||
easingValue,
|
||||
type KeyRef,
|
||||
} from '../../../core/model/animation';
|
||||
import type { AnimationKey, KeyInterpolation, VectorPoint } from '../../../core/model/Asset';
|
||||
import type { PropertyValue } from '../../../core/model/Properties';
|
||||
import type { ArgSchema } from '../argCheck';
|
||||
import {
|
||||
anyValue,
|
||||
ASSET,
|
||||
type Args,
|
||||
type CatalogCommand,
|
||||
flag,
|
||||
id,
|
||||
ids,
|
||||
listOf,
|
||||
number,
|
||||
numberOf,
|
||||
object,
|
||||
oneOf,
|
||||
text,
|
||||
theTab,
|
||||
whole,
|
||||
} from './shared';
|
||||
|
||||
const TRACK = id('Id of the row, as get_asset lists it under tracks.');
|
||||
const TARGET: ArgSchema = {
|
||||
type: 'string',
|
||||
description: 'What the row animates: "" for the root itself, or an id get_asset lists under targets.',
|
||||
};
|
||||
const PROPERTY = id('Key of the property, as get_asset lists it for the target.');
|
||||
const FRAME = whole('A frame, from 0.', 0);
|
||||
|
||||
const KEYS: ArgSchema = {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: object({ track: TRACK, frame: FRAME }, ['track', 'frame']),
|
||||
description: 'Keys by their row and frame.',
|
||||
};
|
||||
|
||||
const keyRefs = (args: Args): KeyRef[] =>
|
||||
(args.keys as { track: string; frame: number }[]).map((key) => ({ trackId: key.track, frame: key.frame }));
|
||||
|
||||
const KEY: ArgSchema = object({ frame: FRAME, value: anyValue }, ['frame', 'value']);
|
||||
const keyOf = (args: Args, name: string): AnimationKey => {
|
||||
const key = args[name] as { frame: number; value: PropertyValue };
|
||||
return { frame: key.frame, value: key.value };
|
||||
};
|
||||
|
||||
/** Every easing a key can name, but the curve ones: those take the id of a Curve asset. */
|
||||
const EASINGS = EASING_SHAPES.flatMap(({ value }) =>
|
||||
value === 'linear' || value === 'step'
|
||||
? [value]
|
||||
: value === 'curve'
|
||||
? []
|
||||
: EASING_DIRECTIONS.map((direction) => easingValue(value, direction.value)),
|
||||
);
|
||||
|
||||
const HANDLE = object({ x: number('x'), y: number('y') }, ['x', 'y']);
|
||||
const POINT: ArgSchema = object(
|
||||
{
|
||||
x: number('Share of the way, 0 to 1.'),
|
||||
y: number('Value; 0 to 1 is start to end, outside overshoots.'),
|
||||
in: {
|
||||
...HANDLE,
|
||||
description: 'Bezier handle towards the point before, as an offset; leave out for a corner.',
|
||||
},
|
||||
out: { ...HANDLE, description: 'Bezier handle towards the point after, as an offset.' },
|
||||
mode: oneOf(['corner', 'smooth', 'symmetric'], 'How the handles move together when dragged.'),
|
||||
},
|
||||
['x', 'y'],
|
||||
);
|
||||
|
||||
/** Timelines of Animation assets and the points of Curve assets. */
|
||||
export const ANIMATION_COMMANDS: readonly CatalogCommand[] = [
|
||||
{
|
||||
name: 'set_animation_root',
|
||||
description:
|
||||
`Sets what an animation animates: a ${ANIMATION_ROOT_TYPES.join(', ')} asset, or "" for nothing. ` +
|
||||
'Its rows reach the root and the objects inside it; get_asset lists them under targets.',
|
||||
args: object({ asset: ASSET, root: { type: 'string', description: 'Id of the asset, or "".' } }, [
|
||||
'asset',
|
||||
'root',
|
||||
]),
|
||||
build: (args) => ({ command: new SetAnimationRootCommand(text(args, 'asset'), text(args, 'root')) }),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'add_animation_track',
|
||||
description: 'Adds a row to the timeline for one property of the root or of an object inside it.',
|
||||
args: object({ asset: ASSET, target: TARGET, property: PROPERTY }, ['asset', 'target', 'property']),
|
||||
build: (args) => {
|
||||
const command = new AddAnimationTrackCommand(
|
||||
text(args, 'asset'),
|
||||
text(args, 'target'),
|
||||
text(args, 'property'),
|
||||
);
|
||||
return { command, made: { id: command.trackId } };
|
||||
},
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'remove_animation_tracks',
|
||||
description: 'Removes rows with all their keys.',
|
||||
args: object({ asset: ASSET, tracks: ids('Ids of the rows.') }, ['asset', 'tracks']),
|
||||
build: (args) => ({
|
||||
command: new RemoveAnimationTracksCommand(text(args, 'asset'), listOf(args, 'tracks')),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'retarget_animation_track',
|
||||
description: 'Aims a row at another property, of the same kind, and keeps its keys.',
|
||||
args: object({ asset: ASSET, track: TRACK, target: TARGET, property: PROPERTY }, [
|
||||
'asset',
|
||||
'track',
|
||||
'target',
|
||||
'property',
|
||||
]),
|
||||
build: (args) => ({
|
||||
command: new RetargetAnimationTrackCommand(
|
||||
text(args, 'asset'),
|
||||
text(args, 'track'),
|
||||
text(args, 'target'),
|
||||
text(args, 'property'),
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'set_track_relative',
|
||||
description:
|
||||
'Makes a number row relative (its keys add to the value the object had when the animation started) or absolute.',
|
||||
args: object({ asset: ASSET, track: TRACK, relative: flag('Relative.') }, ['asset', 'track', 'relative']),
|
||||
build: (args) => ({
|
||||
command: new SetTrackRelativeCommand(
|
||||
text(args, 'asset'),
|
||||
text(args, 'track'),
|
||||
args.relative as boolean,
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'set_animation_key',
|
||||
description:
|
||||
'Sets the key of a row on a frame, replacing one there. Without a value it takes the value the row has on ' +
|
||||
'that frame. The last key of all rows decides how long the animation runs; fps and playback are properties.',
|
||||
args: object({ asset: ASSET, track: TRACK, frame: FRAME, value: anyValue }, ['asset', 'track', 'frame']),
|
||||
build: (args) => ({
|
||||
command: new SetAnimationKeyCommand(
|
||||
text(args, 'asset'),
|
||||
text(args, 'track'),
|
||||
numberOf(args, 'frame'),
|
||||
args.value as PropertyValue | undefined,
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'set_key_easing',
|
||||
description:
|
||||
'How keys blend into the next one (number and color rows only): ' +
|
||||
`${EASINGS.join(', ')}, or "curve:<id of a Curve asset>".`,
|
||||
args: object({ asset: ASSET, keys: KEYS, easing: id('The easing.') }, ['asset', 'keys', 'easing']),
|
||||
build: (args) => ({
|
||||
command: new SetKeyInterpolationCommand(
|
||||
text(args, 'asset'),
|
||||
keyRefs(args),
|
||||
text(args, 'easing') as KeyInterpolation,
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'remove_animation_keys',
|
||||
description: 'Removes keys.',
|
||||
args: object({ asset: ASSET, keys: KEYS }, ['asset', 'keys']),
|
||||
build: (args) => ({ command: new RemoveAnimationKeysCommand(text(args, 'asset'), keyRefs(args)) }),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'move_animation_keys',
|
||||
description:
|
||||
'Moves keys by whole frames together; one landing on a key that stays replaces it. With copy the keys stay ' +
|
||||
'and copies move.',
|
||||
args: object(
|
||||
{ asset: ASSET, keys: KEYS, by: whole('Frames, negative is earlier.'), copy: flag('Copy.') },
|
||||
['asset', 'keys', 'by'],
|
||||
),
|
||||
build: (args) => ({
|
||||
command: new MoveAnimationKeysCommand(
|
||||
text(args, 'asset'),
|
||||
keyRefs(args),
|
||||
numberOf(args, 'by'),
|
||||
args.copy === true,
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'shift_animation_frames',
|
||||
description:
|
||||
'Inserts frames at a frame (count above 0), moving every later key, or removes them (below 0) with the keys on them.',
|
||||
args: object({ asset: ASSET, at: FRAME, count: whole('How many frames.') }, ['asset', 'at', 'count']),
|
||||
build: (args) => ({
|
||||
command: new ShiftAnimationFramesCommand(
|
||||
text(args, 'asset'),
|
||||
numberOf(args, 'at'),
|
||||
numberOf(args, 'count'),
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'key_straight_run',
|
||||
description:
|
||||
'Keys a number row in a straight line from one key to another: both set, the first linear, the keys between ' +
|
||||
'them removed – a driven video at 1x.',
|
||||
args: object({ asset: ASSET, track: TRACK, from: KEY, to: KEY }, ['asset', 'track', 'from', 'to']),
|
||||
build: (args) => ({
|
||||
command: new KeyStraightRunCommand(
|
||||
text(args, 'asset'),
|
||||
text(args, 'track'),
|
||||
keyOf(args, 'from'),
|
||||
keyOf(args, 'to'),
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'set_curve_points',
|
||||
description:
|
||||
'Sets all points of a Curve asset, which an easing or a Lerp node follows from (0, 0) to (1, 1). The first ' +
|
||||
'point is moved to x 0, the last to x 1.',
|
||||
args: object(
|
||||
{ asset: ASSET, points: { type: 'array', minItems: 2, items: POINT, description: 'Left to right.' } },
|
||||
['asset', 'points'],
|
||||
),
|
||||
build: (args) => ({
|
||||
command: new SetCurvePointsCommand(text(args, 'asset'), args.points as VectorPoint[]),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
];
|
||||
@@ -33,9 +33,12 @@ export interface Frame {
|
||||
|
||||
export type Args = Record<string, unknown>;
|
||||
|
||||
/** What a step worked on, for the editor to show the user: an asset, objects of a holder, nodes of a graph. */
|
||||
/**
|
||||
* What a step worked on, for the editor to show the user: an asset (in the inspector, or with `open`
|
||||
* in its tab, there with one of its texture frames picked), objects of a holder, nodes of a graph.
|
||||
*/
|
||||
export type StepTarget =
|
||||
| { kind: 'asset'; asset: string }
|
||||
| { kind: 'asset'; asset: string; open?: true; frame?: string }
|
||||
| { kind: 'objects'; in: string; ids: string[] }
|
||||
| { kind: 'nodes'; asset: string; ids: string[] };
|
||||
|
||||
@@ -53,6 +56,11 @@ export interface CatalogCommand {
|
||||
|
||||
export const id = (description: string): ArgSchema => ({ type: 'string', minLength: 1, description });
|
||||
export const number = (description: string): ArgSchema => ({ type: 'number', description });
|
||||
export const whole = (description: string, minimum?: number): ArgSchema => ({
|
||||
type: 'integer',
|
||||
description,
|
||||
...(minimum === undefined ? {} : { minimum }),
|
||||
});
|
||||
export const flag = (description: string): ArgSchema => ({ type: 'boolean', description });
|
||||
export const ids = (description: string): ArgSchema => ({
|
||||
type: 'array',
|
||||
@@ -92,6 +100,7 @@ export const numberOf = (args: Args, key: string): number => args[key] as number
|
||||
export const listOf = (args: Args, key: string): string[] => args[key] as string[];
|
||||
|
||||
export const theAsset = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset') });
|
||||
export const theTab = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset'), open: true });
|
||||
export const madeAsset = (_args: Args, made: Record<string, string>): StepTarget => ({
|
||||
kind: 'asset',
|
||||
asset: made.id!,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
AddFrameCommand,
|
||||
RemoveFrameCommand,
|
||||
RenameFrameCommand,
|
||||
SetFrameCollisionCommand,
|
||||
SetFrameRectCommand,
|
||||
SetSliceCommand,
|
||||
SetSortLineCommand,
|
||||
SliceFramesCommand,
|
||||
} from '../../../core/commands/frameCommands';
|
||||
import type { FrameFill } from '../../../core/model/Asset';
|
||||
import { type CutTarget, findCut } from '../../../core/model/cutTarget';
|
||||
import type { Vec2 } from '../../../core/model/Properties';
|
||||
import { cutFill, DEFAULT_FILL, type FrameRect } from '../../../core/model/texture';
|
||||
import type { ArgSchema } from '../argCheck';
|
||||
import {
|
||||
ASSET,
|
||||
type Args,
|
||||
type CatalogCommand,
|
||||
flag,
|
||||
id,
|
||||
number,
|
||||
numberOf,
|
||||
object,
|
||||
oneOf,
|
||||
type StepTarget,
|
||||
text,
|
||||
theTab,
|
||||
whole,
|
||||
} from './shared';
|
||||
|
||||
const TEXTURE = id('Id of the texture.');
|
||||
const FRAME = id('Id of the frame, as get_asset lists it under frames.');
|
||||
const CUT_FRAME: ArgSchema = {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'Id of a frame of the texture; leave out for a pixel art or vector art as a whole.',
|
||||
};
|
||||
const RECT = {
|
||||
x: number('Left edge, image pixels.'),
|
||||
y: number('Top edge, image pixels.'),
|
||||
w: number('Width.'),
|
||||
h: number('Height.'),
|
||||
};
|
||||
|
||||
const rectOf = (args: Args): FrameRect => ({
|
||||
x: numberOf(args, 'x'),
|
||||
y: numberOf(args, 'y'),
|
||||
w: numberOf(args, 'w'),
|
||||
h: numberOf(args, 'h'),
|
||||
});
|
||||
|
||||
const cutOf = (args: Args): CutTarget => ({
|
||||
assetId: text(args, 'asset'),
|
||||
...(typeof args.frame === 'string' ? { frameId: args.frame } : {}),
|
||||
});
|
||||
|
||||
const theFrame = (args: Args, made: Record<string, string> = {}): StepTarget => {
|
||||
const frame = made.id ?? (args.frame as string | undefined);
|
||||
return { kind: 'asset', asset: text(args, 'asset'), open: true, ...(frame ? { frame } : {}) };
|
||||
};
|
||||
|
||||
/** The pictures cut from textures – what sprites and tiles wear – and how a picture is drawn and sorted. */
|
||||
export const TEXTURE_COMMANDS: readonly CatalogCommand[] = [
|
||||
{
|
||||
name: 'add_frame',
|
||||
description:
|
||||
'Cuts a frame out of a texture: a named rectangle a sprite or tile can wear. It is kept inside the image.',
|
||||
args: object({ asset: TEXTURE, ...RECT, name: id('Its name; "Frame" with a number when left out.') }, [
|
||||
'asset',
|
||||
'x',
|
||||
'y',
|
||||
'w',
|
||||
'h',
|
||||
]),
|
||||
build: (args) => {
|
||||
const command = new AddFrameCommand(text(args, 'asset'), rectOf(args), args.name as string | undefined);
|
||||
return { command, made: { id: command.frameId } };
|
||||
},
|
||||
shows: theFrame,
|
||||
},
|
||||
{
|
||||
name: 'set_frame_rect',
|
||||
description: 'Moves or resizes a frame of a texture.',
|
||||
args: object({ asset: TEXTURE, frame: FRAME, ...RECT }, ['asset', 'frame', 'x', 'y', 'w', 'h']),
|
||||
build: (args) => ({
|
||||
command: new SetFrameRectCommand(text(args, 'asset'), text(args, 'frame'), rectOf(args)),
|
||||
}),
|
||||
shows: theFrame,
|
||||
},
|
||||
{
|
||||
name: 'rename_frame',
|
||||
description: 'Renames a frame; a name taken in the texture gets a number.',
|
||||
args: object({ asset: TEXTURE, frame: FRAME, name: id('The name.') }, ['asset', 'frame', 'name']),
|
||||
build: (args) => ({
|
||||
command: new RenameFrameCommand(text(args, 'asset'), text(args, 'frame'), text(args, 'name')),
|
||||
}),
|
||||
shows: theFrame,
|
||||
},
|
||||
{
|
||||
name: 'remove_frame',
|
||||
description: 'Removes a frame. Sprites and tiles wearing it show no picture until it comes back.',
|
||||
args: object({ asset: TEXTURE, frame: FRAME }, ['asset', 'frame']),
|
||||
build: (args) => ({ command: new RemoveFrameCommand(text(args, 'asset'), text(args, 'frame')) }),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'slice_frames',
|
||||
description:
|
||||
'Cuts a texture into a grid of equal frames, left to right and top to bottom, named "Frame" with numbers. ' +
|
||||
'With replace the frames already there go first.',
|
||||
args: object(
|
||||
{
|
||||
asset: TEXTURE,
|
||||
columns: whole('Columns.', 1),
|
||||
rows: whole('Rows.', 1),
|
||||
offsetX: number('Pixels skipped at the left edge.'),
|
||||
offsetY: number('Pixels skipped at the top edge.'),
|
||||
spacingX: number('Pixels between two columns.'),
|
||||
spacingY: number('Pixels between two rows.'),
|
||||
replace: flag('Remove the frames there first.'),
|
||||
},
|
||||
['asset', 'columns', 'rows'],
|
||||
),
|
||||
build: (args) => {
|
||||
const extra = (key: string) => (typeof args[key] === 'number' ? args[key] : 0);
|
||||
const options = {
|
||||
columns: numberOf(args, 'columns'),
|
||||
rows: numberOf(args, 'rows'),
|
||||
offsetX: extra('offsetX'),
|
||||
offsetY: extra('offsetY'),
|
||||
spacingX: extra('spacingX'),
|
||||
spacingY: extra('spacingY'),
|
||||
};
|
||||
return { command: new SliceFramesCommand(text(args, 'asset'), options, args.replace === true) };
|
||||
},
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'set_nine_slice',
|
||||
description:
|
||||
'Nine-slices a picture: the borders, in image pixels from its edges, keep their size when a sprite wearing ' +
|
||||
'it is resized, and the middle stretches or repeats. All borders 0 takes the slice off.',
|
||||
args: object(
|
||||
{
|
||||
asset: ASSET,
|
||||
frame: CUT_FRAME,
|
||||
left: number('Left border.'),
|
||||
top: number('Top border.'),
|
||||
right: number('Right border.'),
|
||||
bottom: number('Bottom border.'),
|
||||
fill: oneOf(['stretch', 'repeat'], 'How the middle and the edges fill; left out keeps what it has.'),
|
||||
},
|
||||
['asset', 'left', 'top', 'right', 'bottom'],
|
||||
),
|
||||
build: (args, { project }) => {
|
||||
const target = cutOf(args);
|
||||
const found = findCut(project, target);
|
||||
return {
|
||||
command: new SetSliceCommand(target, {
|
||||
border: {
|
||||
left: numberOf(args, 'left'),
|
||||
top: numberOf(args, 'top'),
|
||||
right: numberOf(args, 'right'),
|
||||
bottom: numberOf(args, 'bottom'),
|
||||
},
|
||||
fill: (args.fill as FrameFill | undefined) ?? (found ? cutFill(found.cut) : DEFAULT_FILL),
|
||||
}),
|
||||
};
|
||||
},
|
||||
shows: theFrame,
|
||||
},
|
||||
{
|
||||
name: 'set_sort_line',
|
||||
description:
|
||||
'Where a sprite wearing a picture stands when its layer sorts by Y: whole pixels up from the bottom edge.',
|
||||
args: object({ asset: ASSET, frame: CUT_FRAME, line: number('Pixels up from the bottom.') }, [
|
||||
'asset',
|
||||
'line',
|
||||
]),
|
||||
build: (args) => ({ command: new SetSortLineCommand(cutOf(args), numberOf(args, 'line')) }),
|
||||
shows: theFrame,
|
||||
},
|
||||
{
|
||||
name: 'set_frame_collision',
|
||||
description:
|
||||
'Sets the outlines a tile made of this frame blocks with, in pixels from its top-left corner. An outline ' +
|
||||
'needs three corners or more; an empty list takes them all off.',
|
||||
args: object(
|
||||
{
|
||||
asset: TEXTURE,
|
||||
frame: FRAME,
|
||||
outlines: {
|
||||
type: 'array',
|
||||
items: { type: 'array', items: object({ x: number('x'), y: number('y') }, ['x', 'y']) },
|
||||
description: 'Each a list of corners.',
|
||||
},
|
||||
},
|
||||
['asset', 'frame', 'outlines'],
|
||||
),
|
||||
build: (args) => ({
|
||||
command: new SetFrameCollisionCommand(
|
||||
text(args, 'asset'),
|
||||
text(args, 'frame'),
|
||||
args.outlines as Vec2[][],
|
||||
),
|
||||
}),
|
||||
shows: theFrame,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,271 @@
|
||||
import { type Command, CommandError } from '../../../core/commands/Command';
|
||||
import { CompoundCommand } from '../../../core/commands/CompoundCommand';
|
||||
import { requireTilemap, requireTilemapLayer } from '../../../core/commands/require';
|
||||
import {
|
||||
AddTilePictureCommand,
|
||||
MoveTilePictureCommand,
|
||||
RemoveTilePictureCommand,
|
||||
} from '../../../core/commands/tileCommands';
|
||||
import {
|
||||
AddTilemapLayerCommand,
|
||||
type CellEdit,
|
||||
DuplicateTilemapLayerCommand,
|
||||
MoveTilemapLayerCommand,
|
||||
PaintTilesCommand,
|
||||
RemoveTilemapLayerCommand,
|
||||
RenameTilemapLayerCommand,
|
||||
ResizeTilemapEdgeCommand,
|
||||
SetTilemapLayerHiddenCommand,
|
||||
SetTilemapLayerSortByYCommand,
|
||||
SetTilemapLayerZCommand,
|
||||
} from '../../../core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../../core/model/Asset';
|
||||
import { findAsset, objectHolders, type Project } from '../../../core/model/Project';
|
||||
import { TILEMAP_EDGES, type TilemapEdge, tilemapColumns, tilemapRows } from '../../../core/model/tilemap';
|
||||
import type { ArgSchema } from '../argCheck';
|
||||
import {
|
||||
type Args,
|
||||
type CatalogCommand,
|
||||
flag,
|
||||
HOLDER,
|
||||
id,
|
||||
number,
|
||||
numberOf,
|
||||
object,
|
||||
oneOf,
|
||||
text,
|
||||
theObject,
|
||||
theTab,
|
||||
whole,
|
||||
} from './shared';
|
||||
|
||||
const TILE = id('Id of the tile asset.');
|
||||
const TILEMAP = id('Id of the tilemap asset.');
|
||||
const LAYER = id('Id of the layer, as get_asset lists it under layers.');
|
||||
const INDEX = whole('Place in the list, from 0.', 0);
|
||||
const PAINT_TILE: ArgSchema = { type: 'string', description: 'Id of a tile asset, or "" to erase.' };
|
||||
|
||||
/** The most cells one step paints, so a slip in a rectangle cannot freeze the editor. */
|
||||
export const MAX_PAINTED_CELLS = 20000;
|
||||
|
||||
/** Tile pictures, painting tilemaps and their layers. */
|
||||
export const TILE_COMMANDS: readonly CatalogCommand[] = [
|
||||
{
|
||||
name: 'add_tile_picture',
|
||||
description:
|
||||
'Adds a picture (a texture or one of its frames) to a tile, at the end or before the one at index. Several ' +
|
||||
'pictures are the animation of the tile; its kind (an autotile, for one), rate and playback are properties.',
|
||||
args: object({ asset: TILE, picture: id('Id of the texture or frame.'), at: INDEX }, [
|
||||
'asset',
|
||||
'picture',
|
||||
]),
|
||||
build: (args) => ({
|
||||
command: new AddTilePictureCommand(
|
||||
text(args, 'asset'),
|
||||
text(args, 'picture'),
|
||||
args.at as number | undefined,
|
||||
),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'remove_tile_picture',
|
||||
description: 'Removes the picture at an index from a tile.',
|
||||
args: object({ asset: TILE, index: INDEX }, ['asset', 'index']),
|
||||
build: (args) => ({
|
||||
command: new RemoveTilePictureCommand(text(args, 'asset'), numberOf(args, 'index')),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'move_tile_picture',
|
||||
description: 'Moves a picture of a tile to another place in its list.',
|
||||
args: object({ asset: TILE, from: INDEX, to: INDEX }, ['asset', 'from', 'to']),
|
||||
build: (args) => ({
|
||||
command: new MoveTilePictureCommand(text(args, 'asset'), numberOf(args, 'from'), numberOf(args, 'to')),
|
||||
}),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'paint_tiles',
|
||||
description:
|
||||
'Paints cells of a tilemap layer with tiles, or erases them. Cell 0,0 is the top-left corner; columns and ' +
|
||||
'rows are properties. Rectangles are painted first, then single cells, so a cell can cut into one. An autotile picks its ' +
|
||||
`picture from its neighbours by itself. At most ${MAX_PAINTED_CELLS} cells a step.`,
|
||||
args: object(
|
||||
{
|
||||
asset: TILEMAP,
|
||||
layer: { ...LAYER, description: 'Id of the layer; the first one when left out.' },
|
||||
cells: {
|
||||
type: 'array',
|
||||
items: object({ x: whole('Column.', 0), y: whole('Row.', 0), tile: PAINT_TILE }, [
|
||||
'x',
|
||||
'y',
|
||||
'tile',
|
||||
]),
|
||||
description: 'Single cells.',
|
||||
},
|
||||
rects: {
|
||||
type: 'array',
|
||||
items: object(
|
||||
{
|
||||
x: whole('Left column.', 0),
|
||||
y: whole('Top row.', 0),
|
||||
w: whole('Columns.', 1),
|
||||
h: whole('Rows.', 1),
|
||||
tile: PAINT_TILE,
|
||||
},
|
||||
['x', 'y', 'w', 'h', 'tile'],
|
||||
),
|
||||
description: 'Rectangles of cells, filled.',
|
||||
},
|
||||
},
|
||||
['asset'],
|
||||
),
|
||||
build: (args, { project }) => {
|
||||
const map = requireTilemap(project, text(args, 'asset'));
|
||||
const layer = (args.layer as string | undefined) ?? map.layers[0]?.id ?? '';
|
||||
requireTilemapLayer(project, map.id, layer);
|
||||
return { command: new PaintTilesCommand(map.id, layer, paintedCells(project, map, args)) };
|
||||
},
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'add_tilemap_layer',
|
||||
description: 'Adds an empty layer on top of a tilemap.',
|
||||
args: object({ asset: TILEMAP }, ['asset']),
|
||||
build: (args) => {
|
||||
const command = new AddTilemapLayerCommand(text(args, 'asset'));
|
||||
return { command, made: { id: command.layerId } };
|
||||
},
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'duplicate_tilemap_layer',
|
||||
description: 'Copies a layer with what is painted on it; the copy comes right above it.',
|
||||
args: object({ asset: TILEMAP, layer: LAYER }, ['asset', 'layer']),
|
||||
build: (args) => {
|
||||
const command = new DuplicateTilemapLayerCommand(text(args, 'asset'), text(args, 'layer'));
|
||||
return { command, made: { id: command.layerId } };
|
||||
},
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'remove_tilemap_layer',
|
||||
description: 'Removes a layer with what is painted on it; the last layer stays.',
|
||||
args: object({ asset: TILEMAP, layer: LAYER }, ['asset', 'layer']),
|
||||
build: (args) => ({ command: new RemoveTilemapLayerCommand(text(args, 'asset'), text(args, 'layer')) }),
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'move_tilemap_layer',
|
||||
description: 'Moves a layer to another place in the drawing order; the last one is drawn on top.',
|
||||
args: object({ asset: TILEMAP, layer: LAYER, to: INDEX }, ['asset', 'layer', 'to']),
|
||||
build: (args, { project }) => {
|
||||
const map = requireTilemap(project, text(args, 'asset'));
|
||||
const from = map.layers.findIndex((layer) => layer.id === text(args, 'layer'));
|
||||
if (from < 0) throw new CommandError(`${map.name} has no layer ${text(args, 'layer')}`);
|
||||
return { command: new MoveTilemapLayerCommand(map.id, from, numberOf(args, 'to')) };
|
||||
},
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'set_tilemap_layer',
|
||||
description:
|
||||
'Changes a layer: its name, its z (added to the z of the placed tilemap), whether it sorts by Y with the ' +
|
||||
'objects on its z, whether it is hidden. Give only what changes.',
|
||||
args: object(
|
||||
{
|
||||
asset: TILEMAP,
|
||||
layer: LAYER,
|
||||
name: id('Its name.'),
|
||||
z: number('Its z.'),
|
||||
sortByY: flag('Sort by Y.'),
|
||||
hidden: flag('Hidden.'),
|
||||
},
|
||||
['asset', 'layer'],
|
||||
),
|
||||
build: (args) => {
|
||||
const asset = text(args, 'asset');
|
||||
const layer = text(args, 'layer');
|
||||
const steps: Command[] = [];
|
||||
if (typeof args.name === 'string') steps.push(new RenameTilemapLayerCommand(asset, layer, args.name));
|
||||
if (typeof args.z === 'number') steps.push(new SetTilemapLayerZCommand(asset, layer, args.z));
|
||||
if (typeof args.sortByY === 'boolean')
|
||||
steps.push(new SetTilemapLayerSortByYCommand(asset, layer, args.sortByY));
|
||||
if (typeof args.hidden === 'boolean')
|
||||
steps.push(new SetTilemapLayerHiddenCommand(asset, layer, args.hidden));
|
||||
if (steps.length === 0) throw new CommandError('Name what to change: name, z, sortByY or hidden');
|
||||
return { command: new CompoundCommand('Change layer', steps) };
|
||||
},
|
||||
shows: theTab,
|
||||
},
|
||||
{
|
||||
name: 'resize_placed_tilemap',
|
||||
description:
|
||||
'Adds (count above 0) or removes columns or rows at one edge of a placed tilemap, keeping the painted cells ' +
|
||||
'where they are in the scene. What falls off comes back when the edge grows again.',
|
||||
args: object(
|
||||
{
|
||||
in: HOLDER,
|
||||
object: id('Id of the placed tilemap.'),
|
||||
edge: oneOf(TILEMAP_EDGES, 'The edge.'),
|
||||
count: whole('How many.'),
|
||||
},
|
||||
['in', 'object', 'edge', 'count'],
|
||||
),
|
||||
build: (args) => ({
|
||||
command: new ResizeTilemapEdgeCommand(
|
||||
text(args, 'in'),
|
||||
text(args, 'object'),
|
||||
text(args, 'edge') as TilemapEdge,
|
||||
numberOf(args, 'count'),
|
||||
),
|
||||
}),
|
||||
shows: theObject,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The cells a paint step writes, refused when one names no tile or lies outside the grid – the
|
||||
* largest one the tilemap has, in the asset or in any of its placements.
|
||||
*/
|
||||
export function paintedCells(project: Project, map: TilemapAsset, args: Args): CellEdit[] {
|
||||
const edits: CellEdit[] = [];
|
||||
const add = (x: number, y: number, tileId: string) => {
|
||||
if (edits.length >= MAX_PAINTED_CELLS)
|
||||
throw new CommandError(`More than ${MAX_PAINTED_CELLS} cells in one step – split it`);
|
||||
edits.push({ x, y, tileId });
|
||||
};
|
||||
for (const rect of (args.rects ?? []) as { x: number; y: number; w: number; h: number; tile: string }[]) {
|
||||
for (let y = rect.y; y < rect.y + rect.h; y++)
|
||||
for (let x = rect.x; x < rect.x + rect.w; x++) add(x, y, rect.tile);
|
||||
}
|
||||
for (const cell of (args.cells ?? []) as { x: number; y: number; tile: string }[])
|
||||
add(cell.x, cell.y, cell.tile);
|
||||
if (edits.length === 0) throw new CommandError('Give cells or rects to paint');
|
||||
const { columns, rows } = gridOf(project, map);
|
||||
const outside = edits.find((edit) => edit.x >= columns || edit.y >= rows);
|
||||
if (outside)
|
||||
throw new CommandError(
|
||||
`Cell ${outside.x},${outside.y} lies outside ${map.name}, which has ${columns}x${rows}`,
|
||||
);
|
||||
for (const tileId of new Set(edits.map((edit) => edit.tileId))) {
|
||||
if (tileId !== '' && findAsset(project, tileId)?.type !== 'tile')
|
||||
throw new CommandError(`${tileId} is not a tile asset`);
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
function gridOf(project: Project, map: TilemapAsset): { columns: number; rows: number } {
|
||||
const bags = [
|
||||
map.props,
|
||||
...objectHolders(project).flatMap((holder) =>
|
||||
holder.objects.filter((o) => o.assetId === map.id).map((o) => ({ ...map.props, ...o.overrides })),
|
||||
),
|
||||
];
|
||||
return {
|
||||
columns: Math.max(...bags.map(tilemapColumns)),
|
||||
rows: Math.max(...bags.map(tilemapRows)),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ANIMATION_COMMANDS } from './catalog/animation';
|
||||
import { ASSET_COMMANDS } from './catalog/assets';
|
||||
import { GRAPH_COMMANDS } from './catalog/graphs';
|
||||
import { OBJECT_COMMANDS } from './catalog/objects';
|
||||
import { PROJECT_COMMANDS } from './catalog/project';
|
||||
import type { CatalogCommand } from './catalog/shared';
|
||||
import { TEXTURE_COMMANDS } from './catalog/textures';
|
||||
import { TILE_COMMANDS } from './catalog/tiles';
|
||||
import { VARIABLE_COMMANDS } from './catalog/variables';
|
||||
|
||||
export type { BuildContext, Built, CatalogCommand, Frame, StepTarget } from './catalog/shared';
|
||||
@@ -33,6 +36,21 @@ export const AREAS: readonly CommandArea[] = [
|
||||
'Nodes, edges, groups, comments and ports of behaviors, state machines, shaders and the other graphs.',
|
||||
commands: GRAPH_COMMANDS,
|
||||
},
|
||||
{
|
||||
area: 'animation',
|
||||
about: 'Rows, keys and easing of Animation assets, and the points of Curve assets.',
|
||||
commands: ANIMATION_COMMANDS,
|
||||
},
|
||||
{
|
||||
area: 'textures',
|
||||
about: 'Frames cut from textures, nine-slices, sort lines and collision outlines of pictures.',
|
||||
commands: TEXTURE_COMMANDS,
|
||||
},
|
||||
{
|
||||
area: 'tiles',
|
||||
about: 'Pictures of tiles; painting tilemaps, their layers, and the edges of a placed one.',
|
||||
commands: TILE_COMMANDS,
|
||||
},
|
||||
{ area: 'project', about: 'Settings of the project as a whole.', commands: PROJECT_COMMANDS },
|
||||
];
|
||||
|
||||
|
||||
@@ -105,11 +105,13 @@ function nextFrame(app: Application): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Brings up what an edit worked on: a graph's tab, the scene or container, the asset in the inspector. */
|
||||
/** Brings up what an edit worked on: a graph's tab, the scene or container, the asset in its tab or the inspector. */
|
||||
function show(parts: EditorParts, focus: StepTarget): void {
|
||||
const { state } = parts;
|
||||
if (focus.kind === 'asset') {
|
||||
state.select({ kind: 'asset', assetId: focus.asset });
|
||||
if (focus.frame) state.select({ kind: 'frame', assetId: focus.asset, frameId: focus.frame });
|
||||
else if (focus.open) state.openAsset(focus.asset);
|
||||
else state.select({ kind: 'asset', assetId: focus.asset });
|
||||
return;
|
||||
}
|
||||
const holder = focus.kind === 'objects' ? focus.in : focus.asset;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type GraphAnnotation, type GraphNode, resolveDef } from '../../core/graph';
|
||||
import type { PortDef } from '../../core/graph/NodeRegistry';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { animationTargets, targetDefs } from '../../core/model/animation';
|
||||
import type { AnimationAsset, Asset } from '../../core/model/Asset';
|
||||
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { isTemplateContent, resolveObjects } from '../../core/model/instance';
|
||||
@@ -64,10 +65,30 @@ export function getAsset(project: Project, assetId: string): object | null {
|
||||
...(isTemplateContent(asset, object.id) ? { fromContainer: true } : {}),
|
||||
}));
|
||||
}
|
||||
if (asset.type === 'animation') plain.targets = animationTargetRows(project, asset);
|
||||
plain.properties = schemaForAsset(project, asset).map(propertyRow);
|
||||
return plain;
|
||||
}
|
||||
|
||||
/** What the rows of an animation may aim at – the root as "", the objects inside it – and what each offers. */
|
||||
function animationTargetRows(project: Project, animation: AnimationAsset): object[] {
|
||||
const root = findAsset(project, animation.root);
|
||||
if (!root) return [];
|
||||
const row = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
properties: (targetDefs(project, animation, id) ?? []).map(({ key, kind }) => ({ key, kind })),
|
||||
});
|
||||
return [
|
||||
row('', root.name),
|
||||
...animationTargets(project, root).map(({ id, object }) =>
|
||||
row(id, objectName(object, findAsset(project, object.assetId))),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const MAX_CELLS_IN_FULL = 400;
|
||||
|
||||
/** Leaves out what is too big to read and has a tool of its own, or tells only how much there is. */
|
||||
function trimmed(key: string, value: unknown): unknown {
|
||||
if (key === 'graph' && value !== null && typeof value === 'object' && 'nodes' in value) {
|
||||
@@ -75,7 +96,9 @@ function trimmed(key: string, value: unknown): unknown {
|
||||
return { nodes: graph.nodes.length, edges: graph.edges.length, read: 'get_graph' };
|
||||
}
|
||||
if ((key === 'cels' || key === 'cells') && value !== null && typeof value === 'object') {
|
||||
return { count: Object.keys(value).length };
|
||||
const count = Object.keys(value).length;
|
||||
// The cells of a small map are worth reading: which tile lies where, by "x,y".
|
||||
return key === 'cells' && count <= MAX_CELLS_IN_FULL ? value : { count };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{
|
||||
"name": "get_asset",
|
||||
"title": "Read an asset",
|
||||
"description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer). A graph is only counted here; read it with get_graph.",
|
||||
"description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap. A graph is only counted here; read it with get_graph.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "asset": { "type": "string", "description": "Id of the asset." } },
|
||||
|
||||
Reference in new issue
Block a user