Skip to content

Commit

Permalink
feat(2d): add save and restore methods to nodes
Browse files Browse the repository at this point in the history
  • Loading branch information
ksassnowski committed Feb 23, 2023
1 parent 4280af3 commit 87f3d95
Show file tree
Hide file tree
Showing 9 changed files with 217 additions and 8 deletions.
126 changes: 118 additions & 8 deletions packages/2d/src/components/Node.ts
Expand Up @@ -29,7 +29,12 @@ import {
import type {ComponentChild, ComponentChildren, NodeConstructor} from './types';
import {Promisable} from '@motion-canvas/core/lib/threading';
import {useScene2D} from '../scenes/useScene2D';
import {TimingFunction} from '@motion-canvas/core/lib/tweening';
import {
deepLerp,
easeInOutCubic,
TimingFunction,
tween,
} from '@motion-canvas/core/lib/tweening';
import {threadable} from '@motion-canvas/core/lib/decorators';
import {drawLine} from '../utils';
import type {View2D} from './View2D';
Expand All @@ -43,6 +48,8 @@ import {
isReactive,
} from '@motion-canvas/core/lib/signals';

export type NodeState = NodeProps & Record<string, any>;

export interface NodeProps {
ref?: ReferenceReceiver<any>;
children?: ComponentChildren;
Expand Down Expand Up @@ -392,6 +399,7 @@ export class Node implements Promisable<Node> {
}

protected view2D: View2D;
private stateStack: NodeState[] = [];
private realChildren: Node[] = [];
public readonly parent = createSignal<Node | null>(null);
public readonly properties = getPropertiesOf(this);
Expand Down Expand Up @@ -927,16 +935,15 @@ export class Node implements Promisable<Node> {
* @param customProps - Properties to override.
*/
public snapshotClone(customProps: NodeProps = {}): this {
const props: NodeProps & Record<string, any> = {...customProps};
const props: NodeProps & Record<string, any> = {
...this.getState(),
...customProps,
};

if (this.children().length > 0) {
props.children ??= this.children().map(child => child.snapshotClone());
}

for (const {key, meta, signal} of this) {
if (!meta.cloneable || key in props) continue;
props[key] = signal();
}

return this.instantiate(props);
}

Expand Down Expand Up @@ -1254,13 +1261,116 @@ export class Node implements Promisable<Node> {
return this;
}

/**
* Return a snapshot of the node's current signal values.
*
* @remarks
* This method will calculate the values of any reactive properties of the
* node at the time the method is called.
*/
public getState(): NodeState {
const state: NodeState = {};
for (const {key, meta, signal} of this) {
if (!meta.cloneable || key in state) continue;
state[key] = signal();
}
return state;
}

/**
* Apply the given state to the node, setting all matching signal values to
* the provided values.
*
* @param state - The state to apply to the node.
*/
public applyState(state: NodeState) {
for (const key in state) {
const signal = this.signalByKey(key);
if (signal) {
signal(state[key]);
}
}
}

/**
* Push a snapshot of the node's current state onto the node's state stack.
*
* @remarks
* This method can be used together with the {@link restore} method to save a
* node's current state and later restore it. It is possible to store more
* than one state by calling `save` method multiple times.
*/
public save(): void {
this.stateStack.push(this.getState());
}

/**
* Restore the node to its last saved state.
*
* @remarks
* This method can be used together with the {@link save} method to restore a
* node to a previously saved state. Restoring a node to a previous state
* removes that state from the state stack.
*
* @example
* ```tsx
* const node = <Circle width={100} height={100} fill={"lightseagreen"} />
*
* view.add(node);
*
* // Save the node's current state
* node.save();
*
* // Modify some of the node's properties
* yield* node.scale(2, 1);
* yield* node.fill('hotpink', 1);
*
* // Restore the node to its saved state over 1 second
* yield* node.restore(1);
* ```
*
* @param duration - The duration of the transition
* @param timing - The timing function to use for the transition
*/
public restore(duration: number, timing: TimingFunction = easeInOutCubic) {
const state = this.stateStack.pop();

if (state === undefined) {
return;
}

const currentState = this.getState();
for (const key in state) {
// Filter out any properties that haven't changed between the current and
// previous states so we don't perform unnecessary tweens.
if (currentState[key] === state[key]) {
delete state[key];
}
}

return tween(duration, value => {
const t = timing(value);

const nextState = Object.keys(state).reduce((newState, key) => {
newState[key] = deepLerp(currentState[key], state[key], t);
return newState;
}, {} as NodeState);

this.applyState(nextState);
});
}

public *[Symbol.iterator]() {
for (const key in this.properties) {
const meta = this.properties[key];
const signal = (<Record<string, SimpleSignal<any>>>(<unknown>this))[key];
const signal = this.signalByKey(key);
yield {meta, signal, key};
}
}

private signalByKey(key: string): SimpleSignal<any> {
return (<Record<string, SimpleSignal<any>>>(<unknown>this))[key];
}
}

/*@__PURE__*/
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/tweening/interpolationFunctions.test.ts
Expand Up @@ -89,4 +89,17 @@ describe('deepLerp', () => {
expect(deepLerp(...args)).toEqual(Vector2.lerp(...args));
expect(spy).toHaveBeenCalledTimes(2);
});

test('returns the from boolean until a value of 0.5', () => {
expect(deepLerp(true, false, 0)).toBe(true);
expect(deepLerp(true, false, 0.25)).toBe(true);
expect(deepLerp(true, false, 0.499999)).toBe(true);
});

test('returns the to boolean after a value of 0.5 or greater', () => {
expect(deepLerp(true, false, 0.5)).toBe(false);
expect(deepLerp(true, false, 0.75)).toBe(false);
expect(deepLerp(true, false, 0.99999)).toBe(false);
expect(deepLerp(true, false, 1)).toBe(false);
});
});
4 changes: 4 additions & 0 deletions packages/core/src/tweening/interpolationFunctions.ts
Expand Up @@ -103,6 +103,10 @@ export function deepLerp(
return textLerp(from, to, value);
}

if (typeof from === 'boolean' && typeof to === 'boolean') {
return value < 0.5 ? from : to;
}

if ('lerp' in from) {
return from.lerp(to, value);
}
Expand Down
36 changes: 36 additions & 0 deletions packages/docs/docs/getting-started/tweening.mdx
Expand Up @@ -6,6 +6,7 @@ slug: /tweening
import AnimationPlayer from '@site/src/components/AnimationPlayer';
import CodeBlock from '@theme/CodeBlock';
import linearSource from '!!raw-loader!@motion-canvas/examples/src/scenes/tweening-linear';
import saveRestoreSource from '!!raw-loader!@motion-canvas/examples/src/scenes/tweening-save-restore';
import springSource from '!!raw-loader!@motion-canvas/examples/src/scenes/tweening-spring';

# Tweening
Expand Down Expand Up @@ -199,6 +200,41 @@ yield *
);
```

## Saving and restoring states

All nodes provide a [`save`](/api/2d/components/Node#save) method which allows
us to save a snapshot of the node's current state. We can then use the
[`restore`](/api/2d/components/Node#restore) method at a later point in our
animation to restore the node to the previously saved state.

```ts
// highlight-next-line
circle().save();
yield * circle().position(new Vector2(300, -200), 2);
// highlight-next-line
yield * circle().restore(1);
```

It is also possible to provide a custom [timing function](/api/core/tweening) to
the [`restore`](/api/2d/components/Node#restore) method.

```ts
yield * circle().restore(1, linear);
```

Node states get stored on a stack. This makes it possible to save more than one
state by invoking the [`save`](/api/2d/components/Node#save) method multiple
times. When calling [`restore`](/api/2d/components/Node#restore), the node will
be restored to the most recently saved state by popping the top entry in the
state stack. If there is no saved state, this method does nothing.

The example below shows a more complete example of how we can store and restore
multiple states across an animation.

<CodeBlock language="tsx">{saveRestoreSource}</CodeBlock>

<AnimationPlayer small name="tweening-save-restore" />

## `spring` function

The [`spring`](/api/core/tweening#spring) function allows us to interpolate
Expand Down
4 changes: 4 additions & 0 deletions packages/examples/src/scenes/tweening-save-restore.meta
@@ -0,0 +1,4 @@
{
"version": 1,
"seed": 1271865379
}
31 changes: 31 additions & 0 deletions packages/examples/src/scenes/tweening-save-restore.tsx
@@ -0,0 +1,31 @@
import {makeScene2D} from '@motion-canvas/2d';
import {Circle} from '@motion-canvas/2d/lib/components';
import {createRef} from '@motion-canvas/core/lib/utils';
import {all} from '@motion-canvas/core/lib/flow';

export default makeScene2D(function* (view) {
const circle = createRef<Circle>();

view.add(
<Circle
// highlight-start
ref={circle}
size={150}
position={[-300, -300]}
fill={'#e13238'}
/>,
);

circle().save();
yield* all(circle().position.x(0, 1), circle().scale(1.5, 1));

circle().save();
yield* all(circle().position.y(0, 1), circle().scale(0.5, 1));

circle().save();
yield* all(circle().position.x(300, 1), circle().scale(1, 1));

yield* circle().restore(1);
yield* circle().restore(1);
yield* circle().restore(1);
});
3 changes: 3 additions & 0 deletions packages/examples/src/tweening-save-restore.meta
@@ -0,0 +1,3 @@
{
"version": 0
}
7 changes: 7 additions & 0 deletions packages/examples/src/tweening-save-restore.ts
@@ -0,0 +1,7 @@
import {makeProject} from '@motion-canvas/core';

import scene from './scenes/tweening-save-restore?scene';

export default makeProject({
scenes: [scene],
});
1 change: 1 addition & 0 deletions packages/examples/vite.config.ts
Expand Up @@ -24,6 +24,7 @@ export default defineConfig({
'./src/logging.ts',
'./src/transitions.ts',
'./src/tweening-spring.ts',
'./src/tweening-save-restore.ts',
],
}),
],
Expand Down

0 comments on commit 87f3d95

Please sign in to comment.