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

+30 -10
View File
@@ -8,10 +8,11 @@ import {
animatedKind,
animationPlayheadAt,
frameCount,
interpolationOf,
targetAssetOf,
trackLabel,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { drawBand, pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
@@ -214,16 +215,17 @@ export class TimelineCanvas extends Widget {
return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + RULER_HEIGHT / 2 };
}
/** The keys as drawn, with whether each is picked. */
keysDrawn(): { trackId: string; frame: number; selected: boolean }[] {
/** The keys as drawn, with whether each is picked and the easing its shape shows. */
keysDrawn(): { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] {
const picked = this.state.selectedKeys;
const out: { trackId: string; frame: number; selected: boolean }[] = [];
const out: { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] = [];
for (const track of this.asset()?.tracks ?? [])
for (const key of track.keys)
out.push({
trackId: track.id,
frame: key.frame,
selected: hasKey(picked, { trackId: track.id, frame: key.frame }),
interpolation: interpolationOf(key),
});
return out;
}
@@ -491,12 +493,7 @@ export class TimelineCanvas extends Widget {
const shifted = drag && drag.keys.some((k) => sameKey(k, ref)) ? drag.delta : 0;
const x = HEADER_WIDTH + frameCentreX(this.view, key.frame + shifted);
if (x < HEADER_WIDTH - half || x > this.w + half) continue;
this.overlay
.moveTo(x, y - half)
.lineTo(x + half, y)
.lineTo(x, y + half)
.lineTo(x - half, y)
.closePath()
drawKeyShape(this.overlay, interpolationOf(key), x, y, half)
.fill(selected ? c.accent : c.text)
.stroke({ width: 1, color: selected ? c.accentText : c.viewport });
}
@@ -556,3 +553,26 @@ export class TimelineCanvas extends Widget {
this.hint.position.set(HEADER_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
}
}
/**
* A key's outline tells its easing: the right half is the start of the way to the next key, the left
* half its end, and a round half is a slow one. Linear is the diamond, ease-in-out the circle, a step
* – which holds – the square.
*/
function drawKeyShape(
g: Graphics,
interpolation: KeyInterpolation,
x: number,
y: number,
half: number,
): Graphics {
if (interpolation === 'step') return g.rect(x - half * 0.75, y - half * 0.75, half * 1.5, half * 1.5);
const slowStart = interpolation === 'ease-in' || interpolation === 'ease-in-out';
const slowEnd = interpolation === 'ease-out' || interpolation === 'ease-in-out';
g.moveTo(x, y - half);
if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2);
else g.lineTo(x + half, y).lineTo(x, y + half);
if (slowEnd) g.arc(x, y, half, Math.PI / 2, (3 * Math.PI) / 2);
else g.lineTo(x - half, y).lineTo(x, y - half);
return g.closePath();
}
+60 -2
View File
@@ -1,11 +1,20 @@
import {
type KeyRef,
RemoveAnimationKeysCommand,
RemoveAnimationTracksCommand,
SetAnimationKeyCommand,
SetKeyInterpolationCommand,
SetTrackRelativeCommand,
} from '../../core/commands/animationCommands';
import { animatableDefs, targetAssetOf, trackLabel } from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import {
animatableDefs,
blends,
interpolationOf,
KEY_INTERPOLATIONS,
targetAssetOf,
trackLabel,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
import { findAssetOf, type Project } from '../../core/model/Project';
import { Button } from '../../ui';
import { openRowDialog, RowDialog } from '../animation/RowDialog';
@@ -87,6 +96,7 @@ export function buildTrackSection(
else if (keys.length > 1) {
addGap(host);
addTitle(host, `${keys.length} KEYS`);
buildEasing(host, asset, keys);
host.body.add(
new Button('Delete keys', {
variant: 'danger',
@@ -120,6 +130,51 @@ function buildKey(host: InspectorHost, asset: AnimationAsset, ref: { trackId: st
host.fields.set('value', row);
host.body.add(row.row);
if (track.relative) addNote(host, 'An offset, since the row is relative.', 'small');
buildEasing(host, asset, [ref]);
}
/**
* How the picked keys blend into their next key – for all of them at once. It shows the easing of the
* key picked last among those that can blend; a row that cannot blend offers none, it always jumps.
*/
function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly KeyRef[]): void {
const shown = easingKey(host.state.project, asset, keys);
if (!shown) return;
const row = createFieldRow(
host,
{
key: 'easing',
label: 'Easing',
kind: 'enum',
default: 'linear',
options: KEY_INTERPOLATIONS.map((i) => ({ ...i })),
},
interpolationOf(shown),
{
onInput: () => {},
onChange: (value) =>
host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value as KeyInterpolation)),
},
);
host.fields.set('easing', row);
host.body.add(row.row);
addNote(host, 'Easing: the way from this key to the next one.', 'small');
}
/** The last picked key on a row that blends; undefined when none of them can ease. */
function easingKey(
project: Project,
asset: AnimationAsset,
keys: readonly KeyRef[],
): AnimationKey | undefined {
for (const ref of [...keys].reverse()) {
const track = asset.tracks.find((t) => t.id === ref.trackId);
const kind = track && kindOf(project, asset, track);
if (!kind || !blends(kind)) continue;
const key = track.keys.find((k) => k.frame === ref.frame);
if (key) return key;
}
return undefined;
}
/** Pushes the current values into the rows. False when the row is gone (rebuild). */
@@ -139,6 +194,9 @@ export function refreshTrackSection(
if (!key) return false;
host.fields.get('value')?.setValue(key.value);
}
const eased = easingKey(host.state.project, asset, keys);
if (Boolean(eased) !== host.fields.has('easing')) return false;
if (eased) host.fields.get('easing')?.setValue(interpolationOf(eased));
return true;
}