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>
112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|