From 19208970cd3692ffddd2093d99eec303d12b40fa Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Fri, 31 Dec 2021 08:47:39 -0500 Subject: [PATCH] Add cluster animations. (#232) --- lib/auto-sizing-svg.tsx | 28 +++++++-- lib/creature-animator.tsx | 97 ++++++++++++++++++++++++++++ lib/creature-symbol.tsx | 39 ++++++++++-- lib/pages/creature-page/core.tsx | 105 +++++++++++++++++++++++++------ 4 files changed, 239 insertions(+), 30 deletions(-) create mode 100644 lib/creature-animator.tsx diff --git a/lib/auto-sizing-svg.tsx b/lib/auto-sizing-svg.tsx index 455197a..4c447b6 100644 --- a/lib/auto-sizing-svg.tsx +++ b/lib/auto-sizing-svg.tsx @@ -3,7 +3,21 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; type AutoSizingSvgProps = { padding?: number; bgColor?: string; + + /** + * A ref to an element that we resize the SVG dimensions + * to match. If not provided, we'll just use the bounding + * box of the SVG itself. + */ sizeToElement?: React.RefObject; + + /** + * Whenever this key changes, we'll resize the SVG. If + * it's undefined, we will use `props.children` as + * the key. + */ + resizeKey?: any; + children: JSX.Element | JSX.Element[]; }; @@ -45,10 +59,14 @@ export const AutoSizingSvg = React.forwardRef( useResizeHandler(resizeToElement); - // Note that we're passing `props.children` in as a dependency; it's not - // used anywhere in the effect, but since any change to the - // children may result in a dimension change in the SVG element, we - // want it to trigger the effect. + // This is an "artificial" effect dependency that isn't used anywhere in + // our effect, but is used to determine when our effect is triggered. + // By default, since any change to `props.children` may result in a + // dimension change in the SVG element, we want it to trigger the effect, + // but alternatively our caller can tell us when to resize via + // `props.resizeKey` too. + const resizeDependency = props.resizeKey ?? props.children; + useEffect(() => { if (!resizeToElement()) { const svgEl = gRef.current; @@ -61,7 +79,7 @@ export const AutoSizingSvg = React.forwardRef( setHeight(bbox.height + padding * 2); } } - }, [props.padding, resizeToElement, props.children]); + }, [props.padding, resizeToElement, resizeDependency]); return ( SvgTransform[]; + +/** + * A strategy for animating a creature. + */ +export interface CreatureAnimator { + /** How to animate the main body of the creature. */ + animate: CreatureAnimate; + + /** How to animate the children (attachments & nests) of the creature. */ + getChildAnimator(): CreatureAnimator; +} + +/** + * Any function that takes a number in the range [0, 1] and + * transforms it to a number in the same range, for the + * purposes of animation easing. + */ +type EasingFunction = (t: number) => number; + +// https://gist.github.com/gre/1650294 +const easeInOutQuad: EasingFunction = (t) => + t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + +/** + * Ease from 0, get to 1 by the time t=0.5, and then + * ease back to 0. + */ +const easeInOutQuadPingPong: EasingFunction = (t) => { + if (t < 0.5) { + return easeInOutQuad(t * 2); + } + return 1 - easeInOutQuad((t - 0.5) * 2); +}; + +/** + * Convert a percentage (number in the range [0, 1]) to + * a number in the range [-1, 1]. + */ +function pctToNegativeOneToOne(pct: number) { + return (pct - 0.5) * 2; +} + +const Y_HOVER_AMPLITUDE = 25.0; + +const animateHover: CreatureAnimate = (animPct) => { + const yHover = + pctToNegativeOneToOne(easeInOutQuadPingPong(animPct)) * Y_HOVER_AMPLITUDE; + return [svgTranslate({ x: 0, y: yHover })]; +}; + +const animateSpin: CreatureAnimate = (animPct, symbol) => { + const origin = getBoundingBoxCenter(symbol.bbox); + return [svgTransformOrigin(origin, [svgRotate(animPct * 360)])]; +}; + +const spinAnimator: CreatureAnimator = { + animate: animateSpin, + getChildAnimator: () => spinAnimator, +}; + +export const CREATURE_ANIMATOR_NAMES = ["none", "breathe", "spin"] as const; + +export type CreatureAnimatorName = typeof CREATURE_ANIMATOR_NAMES[number]; + +export const CreatureAnimators: { + [k in CreatureAnimatorName]: CreatureAnimator; +} = { + none: { + animate: () => [], + getChildAnimator: () => CreatureAnimators.none, + }, + breathe: { + animate: animateHover, + getChildAnimator: () => CreatureAnimators.breathe, + }, + spin: { + animate: animateHover, + getChildAnimator: () => spinAnimator, + }, +}; diff --git a/lib/creature-symbol.tsx b/lib/creature-symbol.tsx index 63cd6fa..8934645 100644 --- a/lib/creature-symbol.tsx +++ b/lib/creature-symbol.tsx @@ -1,7 +1,8 @@ -import React, { useContext } from "react"; +import React, { useContext, useMemo } from "react"; import { BBox, Point } from "../vendor/bezier-js"; import { getAttachmentTransforms } from "./attach"; import { getBoundingBoxCenter, uniformlyScaleToFit } from "./bounding-box"; +import { CreatureAnimator, CreatureAnimators } from "./creature-animator"; import { scalePointXY, subtractPoints } from "./point"; import { AttachmentPointType } from "./specs"; import { @@ -49,14 +50,21 @@ export type CreatureSymbol = { nests: NestedCreatureSymbol[]; }; -export type CreatureSymbolProps = CreatureSymbol; +export type CreatureSymbolProps = CreatureSymbol & { + animator?: CreatureAnimator; + animPct?: number; +}; type NestedCreatureSymbolProps = NestedCreatureSymbol & { parent: SvgSymbolData; + animator: CreatureAnimator; + animPct: number; }; type AttachedCreatureSymbolProps = AttachedCreatureSymbol & { parent: SvgSymbolData; + animator: CreatureAnimator; + animPct: number; }; function getNestingTransforms(parent: BBox, child: BBox) { @@ -210,6 +218,13 @@ export const CreatureSymbol: React.FC = (props) => { let ctx = useContext(CreatureContext); const { data, attachments, nests } = props; const attachmentCtx: CreatureContextType = { ...ctx, parent: data }; + const animator = props.animator ?? CreatureAnimators.none; + const animPct = props.animPct ?? 0; + const svgTransforms = useMemo( + () => animator.animate(animPct, data), + [animator, animPct, data] + ); + const childAnimator = useMemo(() => animator.getChildAnimator(), [animator]); if (props.invertColors) { ctx = swapColors(ctx); @@ -223,11 +238,17 @@ export const CreatureSymbol: React.FC = (props) => { // appear behind our symbol, while anything nested within our symbol // should be after our symbol so they appear in front of it. return ( - <> + {attachments.length && ( {attachments.map((a, i) => ( - + ))} )} @@ -235,10 +256,16 @@ export const CreatureSymbol: React.FC = (props) => { {nests.length && ( {nests.map((n, i) => ( - + ))} )} - + ); }; diff --git a/lib/pages/creature-page/core.tsx b/lib/pages/creature-page/core.tsx index 7d51d14..7cbce9c 100644 --- a/lib/pages/creature-page/core.tsx +++ b/lib/pages/creature-page/core.tsx @@ -20,7 +20,7 @@ import { Random } from "../../random"; import { range } from "../../util"; import { AutoSizingSvg } from "../../auto-sizing-svg"; -import { ExportWidget } from "../../export-svg"; +import { AnimationRenderer, ExportWidget } from "../../export-svg"; import { CreatureContext, CreatureContextType, @@ -46,6 +46,12 @@ import { useRememberedState } from "../../use-remembered-state"; import { GalleryWidget } from "../../gallery-widget"; import { serializeCreatureDesign } from "./serialization"; import { CreatureEditorWidget } from "./creature-editor"; +import { useAnimationPct } from "../../animation"; +import { + CreatureAnimatorName, + CreatureAnimators, + CREATURE_ANIMATOR_NAMES, +} from "../../creature-animator"; /** * The minimum number of attachment points that any symbol used as the main body @@ -264,10 +270,41 @@ export const CREATURE_DESIGN_DEFAULTS: CreatureDesign = { }, }; +type AnimationWidgetProps = { + value: CreatureAnimatorName; + onChange: (name: CreatureAnimatorName) => void; +}; + +const AnimationWidget: React.FC = (props) => { + const id = "animationName"; + return ( +
+ + +
+ ); +}; + export const CreaturePageWithDefaults: React.FC< ComponentWithShareableStateProps > = ({ defaults, onChange }) => { const svgRef = useRef(null); + const [animatorName, setAnimatorName] = + useRememberedState( + "creature-page:animatorName", + "none" + ); + const isAnimated = animatorName !== "none"; const [randomlyInvert, setRandomlyInvert] = useRememberedState( "creature-page:randomlyInvert", true @@ -313,11 +350,18 @@ export const CreaturePageWithDefaults: React.FC< useCallback(() => onChange(design), [onChange, design]) ); + const render = useMemo( + () => + createCreatureAnimationRenderer(creature, ctx, undefined, animatorName), + [creature, ctx, animatorName] + ); + return (
+ setCompCtx({ ...compCtx, ...colors })} onSymbolsChange={newRandomCreature} @@ -356,42 +400,65 @@ export const CreaturePageWithDefaults: React.FC<
); }; +function createCreatureAnimationRenderer( + creature: CreatureSymbol, + ctx: CreatureContextType, + scale = 0.5, + animatorName: CreatureAnimatorName = "none" +): AnimationRenderer { + return (animPct) => { + return ( + + + + + + ); + }; +} + type CreatureCanvasProps = { + isAnimated: boolean; compCtx: SvgCompositionContext; - ctx: CreatureContextType; - creature: CreatureSymbol; + render: AnimationRenderer; }; +const ANIMATION_PERIOD_MS = 5000; + const CreatureCanvas = React.forwardRef( - ({ compCtx, ctx, creature }, svgRef) => { + ({ isAnimated, compCtx, render }, svgRef) => { + const animPct = useAnimationPct(isAnimated ? ANIMATION_PERIOD_MS : 0); + return (
- - - - - - - - - + + + {render(animPct)} + +
); }