Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(2d): add save and restore methods to nodes #406

Merged
merged 2 commits into from Feb 24, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
ksassnowski marked this conversation as resolved.
Show resolved Hide resolved

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