Files
feuerwerk/src/core/model/animationPreview.ts
T
adminandClaude Opus 5.5 393b8ec7f9
game-maker / gate (push) Successful in 3m44s
game-maker / suite (push) Successful in 10m25s
Build your own easing curve and use it on keys and Lerp (#247)
The built-in eases cover the usual shapes, but a game often wants its
own: a coin that bounces twice, a door that overshoots and settles.
A new asset type holds such a curve, and keys and the Lerp node can
pick it instead of a built-in ease.

- New asset type "curve": points like in Vector art, x is t and y is
  the value. A new curve is the straight line from (0, 0) to (1, 1).
- One pure evaluation in src/core/model/curve.ts serves the timeline
  preview, the running game and the Lerp node.
- The curve always has one value per t. The end points stay at t 0
  and 1, points cannot pass each other, and a handle that reaches past
  its neighbour is shortened along its direction. Loading a hand-edited
  file applies the same rules.
- Curve tab: click on the line to insert a point that keeps the shape,
  click on free space to put one there, drag points and handles,
  double-click or Delete removes, Alt-click toggles round and sharp.
  Every step is one undo step.
- Presets: all 30 built-in eases plus Linear, fitted with adaptive
  subdivision. Each stays within 0.4 % of the built-in ease.
- Preview: a dot runs along the curve once a second, a second dot
  shows only the value. The Preview button stops it.
- Animation keys get "Curve" under Easing with a curve picker. Lerp
  gets "Curve" under Ease, and its result may overshoot b.
- A missing or unpicked curve runs linear. The inspector says so, the
  node shows it as a problem, and deleting a used curve warns with the
  number of keys and Lerp nodes.

Tests:
- ./check.sh: 180 files, 2612 tests, CHECK EXIT 0
- GM_STEPS=1-6,25,53: ALL PASSED, 309 checks
- New e2e module 53 with 13 checks, all with the real mouse. Expected
  values come from its own Bezier evaluation.
- Counter-checks bite: without Bezier evaluation 4 unit tests fail,
  without handle clamping 2. In the browser, a key that cannot find its
  curve, a click that sets no point and a handle drag that moves the
  point all turn checks red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:58:05 +02:00

112 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What the Animation tab shows above the timeline (#99): the root asset with every row's value at
* the playhead written over it. Nothing here touches the document – the holder returned is built
* fresh on every call from copies, and the preview draws it the way the Scene tab draws a scene.
*/
import {
animatableDefs,
animatedDefOf,
frameCount,
isPositionRow,
PLACED_ROOT_ID,
placedRoot,
playbackOf,
trackWriteAt,
} from './animation';
import type { AnimationAsset, AnimationTrack, Asset } from './Asset';
import { type CurveLookup, curveLookup } from './curve';
import { instanceChildId, objectRef } from './instance';
import { findAsset, type ObjectHolder, type Project } from './Project';
import { resolveSceneObjectProps } from '../properties/resolve';
/**
* The holder the preview draws at `frame`: a copy of the scene for a scene root, otherwise a holder
* of one object placing the root at the origin, its content resolved from the template like any
* placed container's. A row reaches an object stored in the holder directly and one resolved from a
* container through its instance's edits – the same route an edit in the Scene tab takes, so a row
* aimed into a placed container needs nothing of its own (#109). `undefined` without a root. Past
* the end of a `once-reset` pass nothing is written: the player has put the starting values back.
* `reverse` is ping-pong's way back, as `playheadAt` reports it.
*/
export function previewHolder(
project: Project,
animation: AnimationAsset,
frame: number,
reverse = false,
): ObjectHolder | undefined {
const root = findAsset(project, animation.root);
if (!root) return undefined;
const reset = playbackOf(animation) === 'once-reset' && frame >= frameCount(animation);
const holder = root.type === 'scene' ? sceneCopy(root) : placedHolder(root);
const at: ReadAt = { frame, reverse, curves: curveLookup(project) };
for (const track of reset ? [] : animation.tracks) {
if (root.type === 'scene' && track.target === '') writeScene(holder, track, at);
else writeObject(project, holder, previewObjectId(root, track.target), track, at);
}
return holder;
}
/** The id the preview draws a row's target under – for a check that wants to look at it. */
export function previewObjectId(root: Asset | undefined, target: string): string {
if (root?.type === 'scene') return target;
return target === '' ? PLACED_ROOT_ID : instanceChildId(PLACED_ROOT_ID, target);
}
/** Deep enough that nothing a row writes – a position, an override, an instance edit – is the document's. */
function sceneCopy(scene: ObjectHolder): ObjectHolder {
return {
...scene,
props: { ...scene.props },
objects: scene.objects.map((o) => ({
...o,
position: { ...o.position },
overrides: { ...o.overrides },
instanceEdits: structuredClone(o.instanceEdits),
})),
};
}
function placedHolder(root: Asset): ObjectHolder {
return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
}
/** Where on the timeline a row is read, and the Curve assets its keys may blend along. */
interface ReadAt {
frame: number;
reverse: boolean;
curves: CurveLookup;
}
/** A row on the scene itself: its value goes into the copy's own properties. */
function writeScene(holder: ObjectHolder, track: AnimationTrack, at: ReadAt): void {
const def = animatableDefs('scene').find((d) => d.key === track.property);
if (!def) return;
const base = holder.props[track.property] ?? def.default;
const value = trackWriteAt(track, def, at.frame, base, at.reverse, at.curves);
if (value !== undefined) holder.props[track.property] = value;
}
function writeObject(
project: Project,
holder: ObjectHolder,
id: string,
track: AnimationTrack,
at: ReadAt,
): void {
const ref = objectRef(project, holder, id);
const asset = ref && findAsset(project, ref.object.assetId);
if (!ref || !asset) return;
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
const props = resolveSceneObjectProps(project, asset, ref.object);
const def = animatedDefOf(project, asset, props, track.property);
if (!def) return;
const key = track.property;
if (isPositionRow(asset.type, key)) {
const value = trackWriteAt(track, def, at.frame, ref.object.position[key], at.reverse, at.curves);
if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) });
return;
}
const value = trackWriteAt(track, def, at.frame, props[key], at.reverse, at.curves);
if (value !== undefined) ref.setOverride(key, value);
}