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

+168 -3
View File
@@ -285,7 +285,10 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
}
}
/** Shifts the keys by `delta` frames together; one landing on a key that stays behind replaces it. */
/**
* Shifts the keys by `delta` frames together; one landing on a key that stays behind replaces it.
* With `copy` the originals stay where they were and the copies travel – Ctrl+drag (#102).
*/
export class MoveAnimationKeysCommand extends TimelineCommand {
readonly label: string;
@@ -293,13 +296,16 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
assetId: string,
private readonly keys: readonly KeyRef[],
private readonly delta: number,
private readonly copy = false,
) {
super(assetId);
this.label = keys.length === 1 ? 'Move key' : `Move ${keys.length} keys`;
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.delta)) throw new CommandError('Keys move by whole frames');
if (this.copy && this.delta === 0) throw new CommandError('A duplicate has to land somewhere else');
const byTrack = new Map<string, Set<number>>();
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
@@ -310,7 +316,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
}
for (const [trackId, frames] of byTrack) {
const track = requireTrack(asset, trackId);
const staying = track.keys.filter((k) => !frames.has(k.frame));
const staying = this.copy ? track.keys : track.keys.filter((k) => !frames.has(k.frame));
const moved = track.keys
.filter((k) => frames.has(k.frame))
.map((k) => ({ ...k, frame: k.frame + this.delta }));
@@ -319,3 +325,162 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
}
}
}
/** One row of copied keys: where it came from, its kind, and the keys counted from the first copied frame. */
export interface ClipboardRow {
trackId: string;
target: string;
property: string;
/** Unknown when the row aimed at something gone – such keys only go back onto that very row. */
kind: string | null;
keys: { offset: number; value: PropertyValue; interpolation?: KeyInterpolation }[];
}
/** What Ctrl+C on picked keys holds (#102). Frames are relative: the earliest copied key is offset 0. */
export interface KeyClipboard {
assetId: string;
rows: ClipboardRow[];
}
/** The picked keys as a clipboard, or null when none of them exists. */
export function copyAnimationKeys(
project: Project,
asset: AnimationAsset,
refs: readonly KeyRef[],
): KeyClipboard | null {
const picked = asset.tracks
.map((track) => ({
track,
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
}))
.filter((p) => p.keys.length > 0);
if (picked.length === 0) return null;
const first = Math.min(...picked.flatMap((p) => p.keys.map((k) => k.frame)));
return {
assetId: asset.id,
rows: picked.map(({ track, keys }) => ({
trackId: track.id,
target: track.target,
property: track.property,
kind: trackDef(project, asset, track)?.kind ?? null,
keys: keys.map((k) => ({
offset: k.frame - first,
value: structuredClone(k.value),
...(k.interpolation ? { interpolation: k.interpolation } : {}),
})),
})),
};
}
/**
* Pastes a clipboard with its first key on `frame`. Keys of one row go onto `intoTrackId` when given
* – the row picked in the timeline – as long as it has the same kind. Otherwise every row finds its
* own: the same row in the animation it came from, the row animating the same property elsewhere.
* Rows that find none are left out and counted in `skipped`; a pasted key replaces one already there.
*/
export class PasteAnimationKeysCommand extends TimelineCommand {
readonly label: string;
/** The keys as they landed, for the timeline to pick. */
pasted: KeyRef[] = [];
skipped = 0;
constructor(
assetId: string,
private readonly clipboard: KeyClipboard,
private readonly frame: number,
private readonly intoTrackId = '',
) {
super(assetId);
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
this.label = count === 1 ? 'Paste key' : `Paste ${count} keys`;
}
protected apply(project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.frame) || this.frame < 0)
throw new CommandError('Keys paste onto a whole frame from 0');
this.pasted = [];
this.skipped = 0;
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
for (const row of this.clipboard.rows) {
const track = single ? requireTrack(asset, this.intoTrackId) : this.matchingTrack(asset, row);
if (!track) {
this.skipped += 1;
continue;
}
const def = trackDef(project, asset, track);
if (row.kind === null ? track.id !== row.trackId : def?.kind !== row.kind) {
if (single)
throw new CommandError(
def
? `${row.kind ?? 'These'} keys cannot go on a ${def.kind} row`
: 'The row aims at something that is gone',
);
this.skipped += 1;
continue;
}
const keys = row.keys.map((k) => {
const key: AnimationKey = {
frame: this.frame + k.offset,
value: def ? coerceValue(def, k.value) : structuredClone(k.value),
};
if (k.interpolation && (!def || blends(def.kind))) key.interpolation = k.interpolation;
return key;
});
track.keys = normalizeKeys([...track.keys, ...keys]);
this.pasted.push(...keys.map((k) => ({ trackId: track.id, frame: k.frame })));
}
if (this.pasted.length === 0) throw new CommandError('None of the copied rows is in this animation');
}
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
return asset.id === this.clipboard.assetId
? asset.tracks.find((t) => t.id === row.trackId)
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
}
}
/**
* Where a key on `frame` ends up when `count` frames are inserted at `at` (count > 0) or removed from
* it (count < 0): keys from `at` on move, and removing drops the keys in the removed span (null).
*/
export function shiftedFrame(frame: number, at: number, count: number): number | null {
if (frame < at) return frame;
if (count < 0 && frame < at - count) return null;
return frame + count;
}
/**
* Inserts (`count` > 0) or removes (`count` < 0) frames at `at`, over every row – the way to make an
* animation longer or shorter in the middle (#102). A step that would change nothing is refused.
*/
export class ShiftAnimationFramesCommand extends TimelineCommand {
readonly label: string;
constructor(
assetId: string,
private readonly at: number,
private readonly count: number,
) {
super(assetId);
const frames = Math.abs(count) === 1 ? 'frame' : `${Math.abs(count)} frames`;
this.label = count > 0 ? `Insert ${frames}` : `Remove ${frames}`;
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.at) || this.at < 0)
throw new CommandError('Frames are inserted at a whole frame from 0');
if (!Number.isInteger(this.count) || this.count === 0)
throw new CommandError('Give a whole number of frames');
let touched = 0;
for (const track of asset.tracks) {
const keys: AnimationKey[] = [];
for (const key of track.keys) {
const frame = shiftedFrame(key.frame, this.at, this.count);
if (frame !== key.frame) touched += 1;
if (frame !== null) keys.push({ ...key, frame });
}
track.keys = keys;
}
if (touched === 0) throw new CommandError(`No keys from frame ${this.at} on`);
}
}