Timeline keys can be copied and pasted at the playhead, duplicated with Ctrl+drag, and frames inserted or removed from a right-click menu (#102)
game-maker / gate (push) Successful in 1m34s
game-maker / suite (push) Successful in 5m38s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 12:53:56 +02:00
1 parent 195fc94e08
commit bf02c9e8b3
11 files changed
+666 -23

No files matched your search

+134
View File
@@ -6,13 +6,17 @@
import { describe, expect, it } from 'vitest';
import {
AddAnimationTrackCommand,
copyAnimationKeys,
MoveAnimationKeysCommand,
PasteAnimationKeysCommand,
RemoveAnimationKeysCommand,
RetargetAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
SetKeyInterpolationCommand,
SetTrackRelativeCommand,
ShiftAnimationFramesCommand,
shiftedFrame,
} from '../../src/core/commands/animationCommands';
import {
CreateAssetCommand,
@@ -386,6 +390,136 @@ describe('editing the timeline', () => {
});
});
describe('copy, paste, duplicate and frames (#102)', () => {
it('pastes relative to the frame, into the same rows, replacing a key there, and undoes in one step', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [
[2, 20],
[4, 40],
[9, 90],
]);
const rot = addRow(f, walk, 'rotation', [[3, 1]]);
f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 2 }], 'ease-in'));
const clip = copyAnimationKeys(f.project, walk, [
{ trackId: x, frame: 2 },
{ trackId: x, frame: 4 },
{ trackId: rot, frame: 3 },
])!;
expect(clip.rows.map((r) => r.keys.map((k) => k.offset))).toEqual([[0, 2], [1]]);
const paste = new PasteAnimationKeysCommand(walk.id, clip, 7);
f.stack.execute(paste);
expect(rowOf(walk, x).keys).toEqual([
{ frame: 2, value: 20, interpolation: 'ease-in' },
{ frame: 4, value: 40 },
{ frame: 7, value: 20, interpolation: 'ease-in' },
{ frame: 9, value: 40 },
]);
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [8, 1]));
expect(paste.pasted).toEqual([
{ trackId: x, frame: 7 },
{ trackId: x, frame: 9 },
{ trackId: rot, frame: 8 },
]);
f.stack.undo();
expect(rowOf(walk, x).keys).toHaveLength(3);
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1]));
});
it('pastes one row into the row picked, only of the same kind', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [[0, 5]]);
const rot = addRow(f, walk, 'rotation', []);
const color = addRow(f, walk, 'color', []);
const clip = copyAnimationKeys(f.project, walk, [{ trackId: x, frame: 0 }])!;
f.stack.execute(new PasteAnimationKeysCommand(walk.id, clip, 3, rot));
expect(rowOf(walk, rot).keys).toEqual(keys([3, 5]));
expect(rowOf(walk, x).keys).toEqual(keys([0, 5]));
expect(() => f.stack.execute(new PasteAnimationKeysCommand(walk.id, clip, 3, color))).toThrow(
CommandError,
);
expect(rowOf(walk, color).keys).toEqual([]);
});
it('finds the same property in another animation, and leaves out rows it has not', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [[1, 10]]);
const rot = addRow(f, walk, 'rotation', [[1, 2]]);
const run = addAnimation(f, f.spriteId, {}, 'Run');
const runX = addRow(f, run, 'x', []);
const clip = copyAnimationKeys(f.project, walk, [
{ trackId: x, frame: 1 },
{ trackId: rot, frame: 1 },
])!;
const paste = new PasteAnimationKeysCommand(run.id, clip, 0);
f.stack.execute(paste);
expect(rowOf(current(f, run), runX).keys).toEqual(keys([0, 10]));
expect(paste.skipped).toBe(1);
const empty = addAnimation(f, f.spriteId, {}, 'Idle');
expect(() => f.stack.execute(new PasteAnimationKeysCommand(empty.id, clip, 0))).toThrow(CommandError);
});
it('duplicates keys: the originals stay, the copies replace what they land on', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const id = addRow(f, walk, 'x', [
[0, 0],
[2, 20],
[5, 50],
]);
const picked = [
{ trackId: id, frame: 0 },
{ trackId: id, frame: 2 },
];
f.stack.execute(new MoveAnimationKeysCommand(walk.id, picked, 3, true));
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [3, 0], [5, 20]));
expect(() => f.stack.execute(new MoveAnimationKeysCommand(walk.id, picked, 0, true))).toThrow(
CommandError,
);
f.stack.undo();
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [5, 50]));
});
it('inserts frames over every row from the frame on, and removes them taking their keys along', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [
[0, 0],
[4, 40],
[9, 90],
]);
const rot = addRow(f, walk, 'rotation', [
[3, 1],
[6, 2],
]);
f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 4, 3));
expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [7, 40], [12, 90]));
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [9, 2]));
expect(frameCount(walk)).toBe(13);
f.stack.undo();
f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 3, -2));
expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [7, 90]));
expect(rowOf(walk, rot).keys).toEqual(keys([4, 2]));
f.stack.undo();
expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [4, 40], [9, 90]));
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [6, 2]));
// Past the last key nothing would change – no empty undo step.
expect(() => f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 10, 5))).toThrow(CommandError);
expect(shiftedFrame(4, 3, -2)).toBeNull();
expect(shiftedFrame(5, 3, -2)).toBe(3);
expect(shiftedFrame(2, 3, -2)).toBe(2);
});
});
describe('saving', () => {
it('keeps root, rows and keys through a round trip, and cleans what a hand-edited file brings', () => {
const f = makeSpriteScene();