Keys carry an easing for the span to the next key: linear, step, ease in, ease out, ease in-out (#101)
game-maker / gate (push) Successful in 1m32s
game-maker / suite (push) Successful in 5m36s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 07:27:44 +02:00
1 parent 4c92a9478f
commit b278605c53
7 files changed
+331 -27

No files matched your search

+42 -4
View File
@@ -3,11 +3,13 @@ import {
ANIMATION_ROOT_TYPES,
animatableDefs,
animatedKind,
blends,
isKeyInterpolation,
normalizeKeys,
targetAssetOf,
trackValueAt,
} from '../model/animation';
import type { AnimationAsset, AnimationKey, AnimationTrack } from '../model/Asset';
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset';
import { findAsset, type Project } from '../model/Project';
import type { PropertyValue } from '../model/Properties';
import { coerceValue } from '../properties/PropertySchema';
@@ -195,9 +197,9 @@ export class SetTrackRelativeCommand extends TimelineCommand {
}
/**
* Sets the key at `frame`, replacing one already there. Without a value it takes the value the row
* has at that frame – what a double click in the timeline means – and on an empty row the property's
* default, which for a relative row is no offset at all.
* Sets the key at `frame`, replacing the value of one already there but keeping its interpolation.
* Without a value it takes the value the row has at that frame – what a double click in the timeline
* means – and on an empty row the property's default, which for a relative row is no offset at all.
*/
export class SetAnimationKeyCommand extends TimelineCommand {
readonly label = 'Set key';
@@ -221,10 +223,46 @@ export class SetAnimationKeyCommand extends TimelineCommand {
const current = trackValueAt(track.keys, def.kind, this.frame);
const raw = this.value ?? current ?? (track.relative ? 0 : def.default);
const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) };
const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation;
if (interpolation) key.interpolation = interpolation;
track.keys = normalizeKeys([...track.keys, key]);
}
}
/**
* Sets how each key blends into the next one. `linear` is stored as no field at all. A key on a row
* that cannot blend is left as it is – it jumps whatever it says – and only such keys is refused.
*/
export class SetKeyInterpolationCommand extends TimelineCommand {
readonly label: string;
constructor(
assetId: string,
private readonly keys: readonly KeyRef[],
private readonly interpolation: KeyInterpolation,
) {
super(assetId);
this.label = keys.length === 1 ? 'Set easing' : `Set easing of ${keys.length} keys`;
}
protected apply(project: Project, asset: AnimationAsset): void {
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 aimed = targetAssetOf(project, asset, track.target);
const kind = aimed ? animatedKind(aimed.type, track.property) : undefined;
if (kind !== undefined && !blends(kind)) continue;
if (this.interpolation === 'linear') delete key.interpolation;
else key.interpolation = this.interpolation;
changed += 1;
}
if (changed === 0) throw new CommandError('Only a number or colour key can ease');
}
}
export class RemoveAnimationKeysCommand extends TimelineCommand {
readonly label: string;
+6 -3
View File
@@ -101,10 +101,13 @@ export interface TextureAsset extends AssetBase {
frames: TextureFrame[];
}
/** How a key blends into the next one; absent is `linear`, the only one there is so far (#97). */
export type KeyInterpolation = 'linear';
/** How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. */
export type KeyInterpolation = 'linear' | 'step' | 'ease-in' | 'ease-out' | 'ease-in-out';
/** A value on a whole frame. A value that cannot blend (a texture, a switch) holds until the next key. */
/**
* A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot
* blend (a texture, a switch) holds until the next key whatever it says.
*/
export interface AnimationKey {
frame: number;
value: PropertyValue;
+45 -5
View File
@@ -1,6 +1,6 @@
import { isKindWith } from '../properties/kinds';
import { type PropertyDef, schemaFor } from '../properties/PropertySchema';
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset } from './Asset';
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset, KeyInterpolation } from './Asset';
import type { AssetType } from './AssetTypes';
import { treeOrder } from './hierarchy';
import { instanceChildId, resolveObjects } from './instance';
@@ -111,6 +111,42 @@ function lerpColor(a: number, b: number, t: number): number {
return channel(16) | channel(8) | channel(0);
}
/** In the order the inspector offers them; `linear` first, since it is what an absent field means. */
export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [
{ value: 'linear', label: 'Linear' },
{ value: 'step', label: 'Step' },
{ value: 'ease-in', label: 'Ease in' },
{ value: 'ease-out', label: 'Ease out' },
{ value: 'ease-in-out', label: 'Ease in-out' },
];
export function isKeyInterpolation(value: unknown): value is KeyInterpolation {
return KEY_INTERPOLATIONS.some((i) => i.value === value);
}
export function interpolationOf(key: AnimationKey): KeyInterpolation {
return key.interpolation ?? 'linear';
}
/**
* How far along the way to the next key a value is, `t` the share of the time passed. The eases are
* quadratic: ease-in reaches 0.25 at half the time, ease-out 0.75, ease-in-out 0.5 but 0.125 at a quarter.
*/
export function easedShare(interpolation: KeyInterpolation, t: number): number {
switch (interpolation) {
case 'linear':
return t;
case 'step':
return 0;
case 'ease-in':
return t * t;
case 'ease-out':
return 1 - (1 - t) * (1 - t);
case 'ease-in-out':
return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
}
}
/** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */
export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue {
if (kind === 'number') {
@@ -124,8 +160,9 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t:
/**
* A row's value at a (fractional) frame: the first value before the first key, the last after the
* last one. A value that jumps holds until the next key in the direction of play: played in
* `reverse`, that is the key above. `undefined` only for a row without keys.
* last one. Between two keys the earlier one's interpolation decides the way. A value that jumps –
* one that cannot blend, or a `step` key – holds until the next key in the direction of play: played
* in `reverse`, that is the key above. `undefined` only for a row without keys.
*/
export function trackValueAt(
keys: readonly AnimationKey[],
@@ -140,8 +177,11 @@ export function trackValueAt(
const next = keys[i]!;
if (frame >= next.frame) continue;
const prev = keys[i - 1]!;
if (!blends(kind)) return reverse && frame > prev.frame ? next.value : prev.value;
return blendValue(kind, prev.value, next.value, (frame - prev.frame) / (next.frame - prev.frame));
const interpolation = interpolationOf(prev);
if (!blends(kind) || interpolation === 'step')
return reverse && frame > prev.frame ? next.value : prev.value;
const t = (frame - prev.frame) / (next.frame - prev.frame);
return blendValue(kind, prev.value, next.value, easedShare(interpolation, t));
}
return keys[keys.length - 1]!.value;
}
+6 -2
View File
@@ -18,7 +18,7 @@ import {
} from '../graph';
import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes';
import { normalizeKeys } from '../model/animation';
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type { AnimationKey, AnimationTrack, Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
@@ -473,7 +473,11 @@ function normalizeTracks(raw: unknown): AnimationTrack[] {
const key = asRecord(k);
const frame = Number(key.frame);
if (!Number.isFinite(frame) || !isPrimitive(key.value)) continue;
keys.push({ frame, value: key.value });
const kept: AnimationKey = { frame, value: key.value };
// `linear` is what no field means, so it is not kept; an unknown one reads as linear, too.
if (isKeyInterpolation(key.interpolation) && key.interpolation !== 'linear')
kept.interpolation = key.interpolation;
keys.push(kept);
}
const track: AnimationTrack = { id: t.id, target, property: t.property, keys: normalizeKeys(keys) };
if (t.relative === true) track.relative = true;