Ping-pong turns on the last key, and stepped rows hold the key they last passed (#112)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f68941fa3a
commit
cc14988338
7 files changed
+72
-22
No files matched your search
@@ -27,6 +27,8 @@ export interface PlayheadAt {
|
||||
frame: number;
|
||||
/** Only the two `once` modes ever finish. */
|
||||
finished: boolean;
|
||||
/** On ping-pong's way back: a value that jumps holds the key the playhead passed last – the one above. */
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
/** Slack for `time * fps`: six ticks of 1/60 s sum to 0.09999999999999999, which at 10 fps is frame 1. */
|
||||
@@ -67,18 +69,26 @@ export function animationDuration(asset: AnimationAsset): number {
|
||||
|
||||
/** Where the playhead stands `time` seconds after the start, for a pass of `count` frames. */
|
||||
export function playheadAt(count: number, fps: number, playback: Playback, time: number): PlayheadAt {
|
||||
if (count <= 0) return { frame: 0, finished: playback === 'once' || playback === 'once-reset' };
|
||||
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false };
|
||||
if (count <= 0)
|
||||
return { frame: 0, finished: playback === 'once' || playback === 'once-reset', reverse: false };
|
||||
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false, reverse: false };
|
||||
const at = time * fps + STEP_EPSILON;
|
||||
switch (playback) {
|
||||
case 'loop':
|
||||
return { frame: at % count, finished: false };
|
||||
return { frame: at % count, finished: false, reverse: false };
|
||||
case 'once':
|
||||
case 'once-reset':
|
||||
return at >= count ? { frame: count, finished: true } : { frame: at, finished: false };
|
||||
return at >= count
|
||||
? { frame: count, finished: true, reverse: false }
|
||||
: { frame: at, finished: false, reverse: false };
|
||||
case 'pingpong': {
|
||||
const phase = at % (2 * count);
|
||||
return { frame: phase < count ? phase : 2 * count - phase, finished: false };
|
||||
// Turns on the last key, so neither end is shown twice: keys on 0…9 play 0…9…1 in 18 frames (#112).
|
||||
const last = count - 1;
|
||||
if (last <= 0) return { frame: 0, finished: false, reverse: false };
|
||||
const phase = at % (2 * last);
|
||||
return phase < last
|
||||
? { frame: phase, finished: false, reverse: false }
|
||||
: { frame: 2 * last - phase, finished: false, reverse: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,12 +124,14 @@ 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. `undefined` only for a row without keys.
|
||||
* 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.
|
||||
*/
|
||||
export function trackValueAt(
|
||||
keys: readonly AnimationKey[],
|
||||
kind: string,
|
||||
frame: number,
|
||||
reverse = false,
|
||||
): PropertyValue | undefined {
|
||||
const first = keys[0];
|
||||
if (!first) return undefined;
|
||||
@@ -128,7 +140,7 @@ export function trackValueAt(
|
||||
const next = keys[i]!;
|
||||
if (frame >= next.frame) continue;
|
||||
const prev = keys[i - 1]!;
|
||||
if (!blends(kind)) return prev.value;
|
||||
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));
|
||||
}
|
||||
return keys[keys.length - 1]!.value;
|
||||
@@ -144,8 +156,9 @@ export function trackWriteAt(
|
||||
kind: string,
|
||||
frame: number,
|
||||
base: PropertyValue | undefined,
|
||||
reverse = false,
|
||||
): PropertyValue | undefined {
|
||||
const value = trackValueAt(track.keys, kind, frame);
|
||||
const value = trackValueAt(track.keys, kind, frame, reverse);
|
||||
if (value === undefined) return undefined;
|
||||
const offset = track.relative && kind === 'number' && typeof base === 'number';
|
||||
return offset ? base + Number(value) : value;
|
||||
|
||||
@@ -30,19 +30,21 @@ export const PREVIEW_ROOT_ID = PLACED_ROOT_ID;
|
||||
* 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);
|
||||
for (const track of reset ? [] : animation.tracks) {
|
||||
if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame);
|
||||
else writeObject(project, holder, previewObjectId(root, track.target), track, frame);
|
||||
if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame, reverse);
|
||||
else writeObject(project, holder, previewObjectId(root, track.target), track, frame, reverse);
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
@@ -72,10 +74,10 @@ function placedHolder(root: Asset): ObjectHolder {
|
||||
}
|
||||
|
||||
/** A row on the scene itself: its value goes into the copy's own properties. */
|
||||
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number): void {
|
||||
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void {
|
||||
const def = animatableDefs('scene').find((d) => d.key === track.property);
|
||||
if (!def) return;
|
||||
const value = trackWriteAt(track, def.kind, frame, holder.props[track.property] ?? def.default);
|
||||
const value = trackWriteAt(track, def.kind, frame, holder.props[track.property] ?? def.default, reverse);
|
||||
if (value !== undefined) holder.props[track.property] = value;
|
||||
}
|
||||
|
||||
@@ -85,12 +87,13 @@ function writeObject(
|
||||
id: string,
|
||||
track: AnimationTrack,
|
||||
frame: number,
|
||||
reverse: boolean,
|
||||
): void {
|
||||
const ref = objectRef(project, holder, id);
|
||||
const asset = ref && findAsset(project, ref.object.assetId);
|
||||
const kind = asset && animatedKind(asset.type, track.property);
|
||||
if (!ref || !asset || !kind) return;
|
||||
const value = trackWriteAt(track, kind, frame, baseOf(project, asset, ref.object, track.property));
|
||||
const value = trackWriteAt(track, kind, frame, baseOf(project, asset, ref.object, track.property), reverse);
|
||||
if (value === undefined) return;
|
||||
if (isPositionKey(track.property))
|
||||
ref.setPosition({ ...ref.object.position, [track.property]: Number(value) });
|
||||
|
||||
@@ -33,7 +33,10 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() });
|
||||
this.centreButton = new Button('Centre', { onClick: () => this.preview.fit() });
|
||||
const timeline = new TimelineCanvas(state);
|
||||
this.preview = new PreviewCanvas(state, () => timeline.playhead);
|
||||
this.preview = new PreviewCanvas(state, () => ({
|
||||
frame: timeline.playhead,
|
||||
reverse: timeline.playheadReverse,
|
||||
}));
|
||||
timeline.onPlayhead = () => this.preview.redraw();
|
||||
this.mount(timeline, [this.rootPicker, this.addRowButton, this.playButton, this.centreButton]);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ export class PreviewCanvas extends WorldViewport {
|
||||
|
||||
constructor(
|
||||
state: EditorState,
|
||||
/** Where the timeline's playhead stands, in frames. */
|
||||
private readonly playhead: () => number,
|
||||
/** Where the timeline's playhead stands, in frames, and whether it runs ping-pong's way back. */
|
||||
private readonly playhead: () => { frame: number; reverse: boolean },
|
||||
) {
|
||||
super(state);
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
@@ -95,7 +95,8 @@ export class PreviewCanvas extends WorldViewport {
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
const asset = this.assetId ? findAssetOf(this.state.project, this.assetId, 'animation') : undefined;
|
||||
const holder = asset && previewHolder(this.state.project, asset, this.playhead());
|
||||
const head = this.playhead();
|
||||
const holder = asset && previewHolder(this.state.project, asset, head.frame, head.reverse);
|
||||
return collectHolderItems(this.state.project, holder, Theme.colors.viewport);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ export class TimelineCanvas extends Widget {
|
||||
private assetId = '';
|
||||
/** Where the playhead stands, in frames – fractional while playing. */
|
||||
playhead = 0;
|
||||
/** Whether the playhead runs ping-pong's way back – a jumping value then holds the key above. */
|
||||
playheadReverse = false;
|
||||
/** Seconds since play began; the playhead is a function of it (see `animationPlayheadAt`). */
|
||||
private time = 0;
|
||||
private playingNow = false;
|
||||
@@ -160,6 +162,7 @@ export class TimelineCanvas extends Widget {
|
||||
stop(): void {
|
||||
this.playingNow = false;
|
||||
this.playhead = 0;
|
||||
this.playheadReverse = false;
|
||||
this.time = 0;
|
||||
this.onPlayhead();
|
||||
}
|
||||
@@ -171,6 +174,7 @@ export class TimelineCanvas extends Widget {
|
||||
this.time += dt;
|
||||
const at = animationPlayheadAt(asset, this.time);
|
||||
this.playhead = at.frame;
|
||||
this.playheadReverse = at.reverse;
|
||||
if (at.finished) this.playingNow = false;
|
||||
this.redraw();
|
||||
this.onPlayhead();
|
||||
@@ -179,6 +183,7 @@ export class TimelineCanvas extends Widget {
|
||||
/** Puts the playhead on a frame – what the ruler does, and what a check does without a pointer. */
|
||||
scrub(frame: number): void {
|
||||
this.playhead = Math.max(0, frame);
|
||||
this.playheadReverse = false;
|
||||
const asset = this.asset();
|
||||
if (asset) this.time = this.timeOf(asset, this.playhead);
|
||||
this.redraw();
|
||||
|
||||
@@ -59,7 +59,7 @@ export class AnimationSystem implements System {
|
||||
} else if (player.playing && isHandleEnabled(entity)) player.time += dt;
|
||||
const head = animationPlayheadAt(asset, player.time);
|
||||
for (const b of bindings) {
|
||||
const value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base]);
|
||||
const value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base], head.reverse);
|
||||
if (value !== undefined) b.write(value);
|
||||
}
|
||||
if (!head.finished) continue;
|
||||
|
||||
@@ -88,12 +88,37 @@ describe('where the playhead stands', () => {
|
||||
expect(playheadAt(10, 10, 'loop', 1.05).frame).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('finishes only in the two once modes, and ping-pong turns at the end', () => {
|
||||
expect(playheadAt(10, 10, 'once', 1.2)).toEqual({ frame: 10, finished: true });
|
||||
it('finishes only in the two once modes, and ping-pong turns on the last key', () => {
|
||||
expect(playheadAt(10, 10, 'once', 1.2)).toEqual({ frame: 10, finished: true, reverse: false });
|
||||
expect(playheadAt(10, 10, 'once-reset', 0.5).finished).toBe(false);
|
||||
expect(playheadAt(10, 10, 'pingpong', 1.5).frame).toBeCloseTo(5);
|
||||
expect(playheadAt(10, 10, 'pingpong', 0.95)).toMatchObject({ reverse: true });
|
||||
expect(playheadAt(10, 10, 'pingpong', 0.95).frame).toBeCloseTo(8.5);
|
||||
expect(playheadAt(10, 10, 'pingpong', 1.5).frame).toBeCloseTo(3);
|
||||
expect(playheadAt(10, 10, 'loop', 30).finished).toBe(false);
|
||||
});
|
||||
|
||||
it('shows each ping-pong frame once per bounce, the two ends included (#112)', () => {
|
||||
const row = keys(...Array.from({ length: 10 }, (_, i): [number, string] => [i, `f${i}`]));
|
||||
const shown: string[] = [];
|
||||
for (let tick = 0; tick < 18 * 6; tick++) {
|
||||
const head = playheadAt(10, 10, 'pingpong', tick / 60);
|
||||
if (tick % 6 === 0) shown.push(trackValueAt(row, 'texture', head.frame, head.reverse) as string);
|
||||
}
|
||||
expect(shown.join(' ')).toBe('f0 f1 f2 f3 f4 f5 f6 f7 f8 f9 f8 f7 f6 f5 f4 f3 f2 f1');
|
||||
expect(trackValueAt(row, 'texture', playheadAt(10, 10, 'pingpong', 1.8).frame)).toBe('f0');
|
||||
});
|
||||
|
||||
it('turns a blended ping-pong value on the last key without standing still', () => {
|
||||
const row = keys([0, 0], [9, 90]);
|
||||
const at = (time: number) => {
|
||||
const head = playheadAt(10, 10, 'pingpong', time);
|
||||
return Number(trackValueAt(row, 'number', head.frame, head.reverse));
|
||||
};
|
||||
expect(at(0.85)).toBeCloseTo(85);
|
||||
expect(at(0.9)).toBeCloseTo(90);
|
||||
expect(at(0.95)).toBeCloseTo(85);
|
||||
expect(at(1.8)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the value of a row', () => {
|
||||
|
||||
Reference in new issue
Block a user