diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index c21f35c0..14f7ce14 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -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; diff --git a/src/core/model/animationPreview.ts b/src/core/model/animationPreview.ts index 1164daaf..36ef26df 100644 --- a/src/core/model/animationPreview.ts +++ b/src/core/model/animationPreview.ts @@ -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) }); diff --git a/src/editor/animation/AnimationEditor.ts b/src/editor/animation/AnimationEditor.ts index 3e0f66b1..addcc218 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -33,7 +33,10 @@ export class AnimationEditor extends AssetTabEditor 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]); } diff --git a/src/editor/animation/PreviewCanvas.ts b/src/editor/animation/PreviewCanvas.ts index 191ff701..5c139091 100644 --- a/src/editor/animation/PreviewCanvas.ts +++ b/src/editor/animation/PreviewCanvas.ts @@ -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); } diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 360a9f11..8ad6d69e 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -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(); diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 62b0d6a3..1e72218c 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -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; diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 3a3098a2..c97f5715 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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', () => {