Part (a) of the animation rework: model, commands, saving, runtime and nodes. - AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one property of the root or of an object inside it, keys sit on whole frames. - The last key decides the length (its frame counts as a frame of its own). The playhead runs between frames, so numbers and colors blend every tick even at 10 fps; everything else holds. - Playback gains `once-reset`, which puts back what was there before the start. A row can be relative, adding to where its target stood. - Play / Pause / Stop animation act on an object; each instance plays on its own, the later start wins on a shared property, and a behavior writing the same property still writes last. - A sprite no longer wears an animation through `texture`. "Create animation from texture" builds a sprite plus a texture row keyed frame by frame instead. - The Animation tab shows a placeholder until the timeline editor (part b) lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.9 KiB
TypeScript
136 lines
5.9 KiB
TypeScript
/**
|
||
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
||
* value – and a color or a texture says so, or it would be a plain number or text.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { type PropertyValue, tagged, untag } from '../../src/core/model/Properties';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { itemKindOf, makeItem, structItemFields } from '../../src/core/properties/listItems';
|
||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
/** A behavior with a list variable `stops`, placed once with an override of its own. */
|
||
function withList(value: unknown = [1, 2]) {
|
||
const f = makeSpriteScene();
|
||
const behavior = makeBehavior(f, 'B');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'stops', 'list'));
|
||
const asset = findAsset(f.project, behavior.id)!;
|
||
asset.props.stops = value as never;
|
||
const object = place(f, behavior.id, { x: 0, y: 0 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'stops', [3]));
|
||
return { f, behaviorId: behavior.id, object };
|
||
}
|
||
|
||
describe('the kind of one list item', () => {
|
||
it('is read off the item itself, a color through its tag', () => {
|
||
expect([7, 'a', true, tagged('color', 0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
|
||
'number',
|
||
'text',
|
||
'boolean',
|
||
'color',
|
||
'struct',
|
||
]);
|
||
});
|
||
|
||
it('lets one list hold all of them at once, through a write and a read back', () => {
|
||
const mixed = [7, 'tee', true, tagged('color', 0xff0000), { x: 1, y: 'two' }];
|
||
const { f, behaviorId } = withList(mixed);
|
||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(mixed);
|
||
});
|
||
|
||
/** The one kind JSON cannot tell apart from a number, which is why it is tagged at all. */
|
||
it('keeps a color apart from the number beside it', () => {
|
||
const { f, behaviorId } = withList([tagged('color', 0xff0000), 0xff0000]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map(itemKindOf)).toEqual(['color', 'number']);
|
||
expect(back.map(untag)).toEqual([0xff0000, 0xff0000]);
|
||
});
|
||
|
||
/**
|
||
* The second kind JSON cannot tell apart on its own: an asset id is a text like any other. What a
|
||
* sprite wears is one id whether it names a texture or one of its frames.
|
||
*/
|
||
it('keeps a texture apart from the text beside it, and reads back as its id', () => {
|
||
const { f, behaviorId } = withList([tagged('texture', 'tex-walk'), 'tex-walk']);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map(itemKindOf)).toEqual(['texture', 'text']);
|
||
expect(back.map(untag)).toEqual(['tex-walk', 'tex-walk']);
|
||
});
|
||
|
||
it('turns a text into a texture item and back without losing the id', () => {
|
||
expect(makeItem('texture', 'frame-26')).toEqual(tagged('texture', 'frame-26'));
|
||
expect(makeItem('text', tagged('texture', 'frame-26'))).toBe('frame-26');
|
||
});
|
||
|
||
/** A struct item's field is undeclared too, so a texture there keeps its tag, like a color does. */
|
||
it('stays a texture inside a struct item', () => {
|
||
const { f, behaviorId } = withList([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back).toEqual([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
|
||
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
|
||
'pic:texture',
|
||
'n:number',
|
||
]);
|
||
});
|
||
|
||
it('changes for one item without touching its neighbours', () => {
|
||
const { f, behaviorId } = withList([1, 2]);
|
||
const asset = findAsset(f.project, behaviorId)!;
|
||
const list = asset.props.stops as PropertyValue[];
|
||
asset.props.stops = [makeItem('text', list[0]), list[1]] as never;
|
||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(['1', 2]);
|
||
});
|
||
|
||
it('gives a struct item a shape of its own, each field with its own kind', () => {
|
||
const { f, behaviorId } = withList([{ x: 1, label: 'a' }, { x: 2 }]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map((item) => structItemFields(item).map((field) => `${field.key}:${field.kind}`))).toEqual([
|
||
['x:number', 'label:text'],
|
||
['x:number'],
|
||
]);
|
||
});
|
||
|
||
it('drops a list inside a struct item, the one level too deep', () => {
|
||
const { f, behaviorId } = withList([{ x: 1, deep: [1, 2] }]);
|
||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([{ x: 1 }]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A node parameter meets no schema on the way in – `asStoredValue` is the only rule it passes. It is
|
||
* therefore the one place where the store itself has to know the tags.
|
||
*/
|
||
describe('a list in a node parameter, where nothing is declared', () => {
|
||
const loadParam = (value: unknown) =>
|
||
deserializeProject({
|
||
id: 'p',
|
||
assets: [
|
||
{
|
||
id: 'b',
|
||
type: 'behavior',
|
||
name: 'B',
|
||
graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value } }], edges: [] },
|
||
},
|
||
],
|
||
});
|
||
const paramOf = (value: unknown) => {
|
||
const asset = findAsset(loadParam(value), 'b');
|
||
return asset?.type === 'behavior' ? asset.graph.nodes[0]?.params.value : undefined;
|
||
};
|
||
|
||
it('keeps a tagged texture an item of its own', () => {
|
||
expect(paramOf([tagged('texture', 'frame-26'), 'frame-26'])).toEqual([
|
||
tagged('texture', 'frame-26'),
|
||
'frame-26',
|
||
]);
|
||
});
|
||
|
||
it('and one inside a struct item as well', () => {
|
||
expect(paramOf([{ pic: tagged('texture', 'frame-26'), n: 1 }])).toEqual([
|
||
{ pic: tagged('texture', 'frame-26'), n: 1 },
|
||
]);
|
||
});
|
||
});
|