Add cluster animations. (#232)

pull/233/head
Atul Varma 2021-12-31 08:47:39 -05:00 zatwierdzone przez GitHub
rodzic b1265990a9
commit 19208970cd
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
4 zmienionych plików z 239 dodań i 30 usunięć

Wyświetl plik

@ -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<HTMLElement>;
/**
* 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 (
<svg

Wyświetl plik

@ -0,0 +1,97 @@
import { getBoundingBoxCenter } from "./bounding-box";
import { SvgSymbolData } from "./svg-symbol";
import {
svgRotate,
SvgTransform,
svgTransformOrigin,
svgTranslate,
} from "./svg-transform";
/**
* A type of function that tells us how to transform a creature based
* on how far through an animation we are.
*/
type CreatureAnimate = (
animPct: number,
symbol: SvgSymbolData
) => 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,
},
};

Wyświetl plik

@ -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<CreatureSymbolProps> = (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<CreatureSymbolProps> = (props) => {
// appear behind our symbol, while anything nested within our symbol
// should be after our symbol so they appear in front of it.
return (
<>
<SvgTransform transform={svgTransforms}>
{attachments.length && (
<CreatureContext.Provider value={attachmentCtx}>
{attachments.map((a, i) => (
<AttachedCreatureSymbol key={i} {...a} parent={data} />
<AttachedCreatureSymbol
key={i}
{...a}
parent={data}
animPct={animPct}
animator={childAnimator}
/>
))}
</CreatureContext.Provider>
)}
@ -235,10 +256,16 @@ export const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
{nests.length && (
<CreatureContext.Provider value={nestedCtx}>
{nests.map((n, i) => (
<NestedCreatureSymbol key={i} {...n} parent={data} />
<NestedCreatureSymbol
key={i}
{...n}
parent={data}
animPct={animPct}
animator={childAnimator}
/>
))}
</CreatureContext.Provider>
)}
</>
</SvgTransform>
);
};

Wyświetl plik

@ -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<AnimationWidgetProps> = (props) => {
const id = "animationName";
return (
<div className="flex-widget thingy">
<label htmlFor={id}>Animation (experimental):</label>
<select
id={id}
onChange={(e) => props.onChange(e.target.value as CreatureAnimatorName)}
value={props.value}
>
{CREATURE_ANIMATOR_NAMES.map((choice) => (
<option key={choice} value={choice}>
{choice}
</option>
))}
</select>
</div>
);
};
export const CreaturePageWithDefaults: React.FC<
ComponentWithShareableStateProps<CreatureDesign>
> = ({ defaults, onChange }) => {
const svgRef = useRef<SVGSVGElement>(null);
const [animatorName, setAnimatorName] =
useRememberedState<CreatureAnimatorName>(
"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 (
<Page title="Cluster!">
<div className="sidebar">
<CompositionContextWidget ctx={compCtx} onChange={setCompCtx} />
<CreatureEditorWidget creature={creature} onChange={setCreature} />
<AnimationWidget value={animatorName} onChange={setAnimatorName} />
<RandomizerWidget
onColorsChange={(colors) => setCompCtx({ ...compCtx, ...colors })}
onSymbolsChange={newRandomCreature}
@ -356,42 +400,65 @@ export const CreaturePageWithDefaults: React.FC<
<ExportWidget
basename={getDownloadBasename(creature.data.name)}
svgRef={svgRef}
animate={isAnimated && { duration: ANIMATION_PERIOD_MS, render }}
/>
</div>
</div>
<CreatureCanvas
compCtx={compCtx}
ctx={ctx}
creature={creature}
render={render}
ref={svgRef}
isAnimated={isAnimated}
/>
</Page>
);
};
function createCreatureAnimationRenderer(
creature: CreatureSymbol,
ctx: CreatureContextType,
scale = 0.5,
animatorName: CreatureAnimatorName = "none"
): AnimationRenderer {
return (animPct) => {
return (
<SvgTransform transform={svgScale(scale)}>
<CreatureContext.Provider value={ctx}>
<CreatureSymbol
{...creature}
animPct={animPct}
animator={CreatureAnimators[animatorName]}
/>
</CreatureContext.Provider>
</SvgTransform>
);
};
}
type CreatureCanvasProps = {
isAnimated: boolean;
compCtx: SvgCompositionContext;
ctx: CreatureContextType;
creature: CreatureSymbol;
render: AnimationRenderer;
};
const ANIMATION_PERIOD_MS = 5000;
const CreatureCanvas = React.forwardRef<SVGSVGElement, CreatureCanvasProps>(
({ compCtx, ctx, creature }, svgRef) => {
({ isAnimated, compCtx, render }, svgRef) => {
const animPct = useAnimationPct(isAnimated ? ANIMATION_PERIOD_MS : 0);
return (
<div className="canvas" style={{ backgroundColor: compCtx.background }}>
<CreatureContext.Provider value={ctx}>
<HoverDebugHelper>
<AutoSizingSvg
padding={20}
ref={svgRef}
bgColor={compCtx.background}
>
<SvgTransform transform={svgScale(0.5)}>
<CreatureSymbol {...creature} />
</SvgTransform>
</AutoSizingSvg>
</HoverDebugHelper>
</CreatureContext.Provider>
<HoverDebugHelper>
<AutoSizingSvg
padding={100}
ref={svgRef}
resizeKey={render}
bgColor={compCtx.background}
>
{render(animPct)}
</AutoSizingSvg>
</HoverDebugHelper>
</div>
);
}