The scene view and the animation preview share one camera, the three form dialogs one base, and keys are found through one set of helpers (#85)
game-maker / gate (push) Successful in 1m36s
game-maker / suite (push) Successful in 5m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 14:19:53 +02:00
1 parent bf02c9e8b3
commit 2a390e52ff
22 files changed
+418 -452

No files matched your search

+36 -30
View File
@@ -2,7 +2,10 @@ import { newId } from '../ids';
import {
ANIMATION_ROOT_TYPES,
blends,
findTrack,
hasKey,
isKeyInterpolation,
type KeyRef,
normalizeKeys,
targetAssetOf,
targetDefs,
@@ -16,12 +19,6 @@ import { coerceValue } from '../properties/PropertySchema';
import { type Command, CommandError } from './Command';
import { requireAsset } from './require';
/** A key on one row, as the timeline selects it. */
export interface KeyRef {
trackId: string;
frame: number;
}
function requireAnimation(project: Project, assetId: string): AnimationAsset {
const asset = requireAsset(project, assetId);
if (asset.type !== 'animation') throw new CommandError(`${asset.name} is not an animation`);
@@ -29,11 +26,27 @@ function requireAnimation(project: Project, assetId: string): AnimationAsset {
}
function requireTrack(asset: AnimationAsset, trackId: string): AnimationTrack {
const track = asset.tracks.find((t) => t.id === trackId);
const track = findTrack(asset, trackId);
if (!track) throw new CommandError(`${asset.name} has no such row`);
return track;
}
function requireKey(asset: AnimationAsset, ref: KeyRef): { track: AnimationTrack; key: AnimationKey } {
const track = requireTrack(asset, ref.trackId);
const key = track.keys.find((k) => k.frame === ref.frame);
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
return { track, key };
}
function requireFrame(frame: number, message: string): void {
if (!Number.isInteger(frame) || frame < 0) throw new CommandError(message);
}
/** `Remove key`, `Remove 3 keys` – how a key command names itself. */
function keysLabel(verb: string, count: number): string {
return count === 1 ? `${verb} key` : `${verb} ${count} keys`;
}
/** The kind of `property` on the object `target` names, refusing what cannot be animated there. */
function requireAnimatable(
project: Project,
@@ -217,8 +230,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
protected apply(project: Project, asset: AnimationAsset): void {
const track = requireTrack(asset, this.trackId);
if (!Number.isInteger(this.frame) || this.frame < 0)
throw new CommandError('A key sits on a whole frame from 0');
requireFrame(this.frame, 'A key sits on a whole frame from 0');
const def = trackDef(project, asset, track);
if (!def) throw new CommandError('The row aims at something that is gone');
const current = trackValueAt(track.keys, def.kind, this.frame);
@@ -250,9 +262,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
if (!isKeyInterpolation(this.interpolation)) throw new CommandError('No such easing');
let changed = 0;
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
const key = track.keys.find((k) => k.frame === ref.frame);
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
const { track, key } = requireKey(asset, ref);
const kind = trackDef(project, asset, track)?.kind;
if (kind !== undefined && !blends(kind)) continue;
if (this.interpolation === 'linear') delete key.interpolation;
@@ -271,16 +281,14 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
private readonly keys: readonly KeyRef[],
) {
super(assetId);
this.label = keys.length === 1 ? 'Remove key' : `Remove ${keys.length} keys`;
this.label = keysLabel('Remove', keys.length);
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (this.keys.length === 0) throw new CommandError('No keys to remove');
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
const at = track.keys.findIndex((k) => k.frame === ref.frame);
if (at < 0) throw new CommandError(`No key on frame ${ref.frame}`);
track.keys.splice(at, 1);
const { track, key } = requireKey(asset, ref);
track.keys.splice(track.keys.indexOf(key), 1);
}
}
}
@@ -299,8 +307,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
private readonly copy = false,
) {
super(assetId);
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
this.label = keysLabel(copy ? 'Duplicate' : 'Move', keys.length);
}
protected apply(_project: Project, asset: AnimationAsset): void {
@@ -308,9 +315,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
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);
if (!track.keys.some((k) => k.frame === ref.frame))
throw new CommandError(`No key on frame ${ref.frame}`);
requireKey(asset, ref);
if (ref.frame + this.delta < 0) throw new CommandError('A key cannot move before frame 0');
byTrack.set(ref.trackId, (byTrack.get(ref.trackId) ?? new Set()).add(ref.frame));
}
@@ -342,6 +347,10 @@ export interface KeyClipboard {
rows: ClipboardRow[];
}
export function clipboardKeyCount(clipboard: KeyClipboard): number {
return clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
}
/** The picked keys as a clipboard, or null when none of them exists. */
export function copyAnimationKeys(
project: Project,
@@ -351,7 +360,7 @@ export function copyAnimationKeys(
const picked = asset.tracks
.map((track) => ({
track,
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
keys: track.keys.filter((k) => hasKey(refs, { trackId: track.id, frame: k.frame })),
}))
.filter((p) => p.keys.length > 0);
if (picked.length === 0) return null;
@@ -391,13 +400,11 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
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`;
this.label = keysLabel('Paste', clipboardKeyCount(clipboard));
}
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');
requireFrame(this.frame, 'Keys paste onto a whole frame from 0');
this.pasted = [];
this.skipped = 0;
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
@@ -434,7 +441,7 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
return asset.id === this.clipboard.assetId
? asset.tracks.find((t) => t.id === row.trackId)
? findTrack(asset, row.trackId)
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
}
}
@@ -467,8 +474,7 @@ export class ShiftAnimationFramesCommand extends TimelineCommand {
}
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');
requireFrame(this.at, '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;