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 = { type AutoSizingSvgProps = {
padding?: number; padding?: number;
bgColor?: string; 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>; 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[]; children: JSX.Element | JSX.Element[];
}; };
@ -45,10 +59,14 @@ export const AutoSizingSvg = React.forwardRef(
useResizeHandler(resizeToElement); useResizeHandler(resizeToElement);
// Note that we're passing `props.children` in as a dependency; it's not // This is an "artificial" effect dependency that isn't used anywhere in
// used anywhere in the effect, but since any change to the // our effect, but is used to determine when our effect is triggered.
// children may result in a dimension change in the SVG element, we // By default, since any change to `props.children` may result in a
// want it to trigger the effect. // 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(() => { useEffect(() => {
if (!resizeToElement()) { if (!resizeToElement()) {
const svgEl = gRef.current; const svgEl = gRef.current;
@ -61,7 +79,7 @@ export const AutoSizingSvg = React.forwardRef(
setHeight(bbox.height + padding * 2); setHeight(bbox.height + padding * 2);
} }
} }
}, [props.padding, resizeToElement, props.children]); }, [props.padding, resizeToElement, resizeDependency]);
return ( return (
<svg <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 { BBox, Point } from "../vendor/bezier-js";
import { getAttachmentTransforms } from "./attach"; import { getAttachmentTransforms } from "./attach";
import { getBoundingBoxCenter, uniformlyScaleToFit } from "./bounding-box"; import { getBoundingBoxCenter, uniformlyScaleToFit } from "./bounding-box";
import { CreatureAnimator, CreatureAnimators } from "./creature-animator";
import { scalePointXY, subtractPoints } from "./point"; import { scalePointXY, subtractPoints } from "./point";
import { AttachmentPointType } from "./specs"; import { AttachmentPointType } from "./specs";
import { import {
@ -49,14 +50,21 @@ export type CreatureSymbol = {
nests: NestedCreatureSymbol[]; nests: NestedCreatureSymbol[];
}; };
export type CreatureSymbolProps = CreatureSymbol; export type CreatureSymbolProps = CreatureSymbol & {
animator?: CreatureAnimator;
animPct?: number;
};
type NestedCreatureSymbolProps = NestedCreatureSymbol & { type NestedCreatureSymbolProps = NestedCreatureSymbol & {
parent: SvgSymbolData; parent: SvgSymbolData;
animator: CreatureAnimator;
animPct: number;
}; };
type AttachedCreatureSymbolProps = AttachedCreatureSymbol & { type AttachedCreatureSymbolProps = AttachedCreatureSymbol & {
parent: SvgSymbolData; parent: SvgSymbolData;
animator: CreatureAnimator;
animPct: number;
}; };
function getNestingTransforms(parent: BBox, child: BBox) { function getNestingTransforms(parent: BBox, child: BBox) {
@ -210,6 +218,13 @@ export const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
let ctx = useContext(CreatureContext); let ctx = useContext(CreatureContext);
const { data, attachments, nests } = props; const { data, attachments, nests } = props;
const attachmentCtx: CreatureContextType = { ...ctx, parent: data }; 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) { if (props.invertColors) {
ctx = swapColors(ctx); ctx = swapColors(ctx);
@ -223,11 +238,17 @@ export const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
// appear behind our symbol, while anything nested within our symbol // appear behind our symbol, while anything nested within our symbol
// should be after our symbol so they appear in front of it. // should be after our symbol so they appear in front of it.
return ( return (
<> <SvgTransform transform={svgTransforms}>
{attachments.length && ( {attachments.length && (
<CreatureContext.Provider value={attachmentCtx}> <CreatureContext.Provider value={attachmentCtx}>
{attachments.map((a, i) => ( {attachments.map((a, i) => (
<AttachedCreatureSymbol key={i} {...a} parent={data} /> <AttachedCreatureSymbol
key={i}
{...a}
parent={data}
animPct={animPct}
animator={childAnimator}
/>
))} ))}
</CreatureContext.Provider> </CreatureContext.Provider>
)} )}
@ -235,10 +256,16 @@ export const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
{nests.length && ( {nests.length && (
<CreatureContext.Provider value={nestedCtx}> <CreatureContext.Provider value={nestedCtx}>
{nests.map((n, i) => ( {nests.map((n, i) => (
<NestedCreatureSymbol key={i} {...n} parent={data} /> <NestedCreatureSymbol
key={i}
{...n}
parent={data}
animPct={animPct}
animator={childAnimator}
/>
))} ))}
</CreatureContext.Provider> </CreatureContext.Provider>
)} )}
</> </SvgTransform>
); );
}; };

Wyświetl plik

@ -20,7 +20,7 @@ import { Random } from "../../random";
import { range } from "../../util"; import { range } from "../../util";
import { AutoSizingSvg } from "../../auto-sizing-svg"; import { AutoSizingSvg } from "../../auto-sizing-svg";
import { ExportWidget } from "../../export-svg"; import { AnimationRenderer, ExportWidget } from "../../export-svg";
import { import {
CreatureContext, CreatureContext,
CreatureContextType, CreatureContextType,
@ -46,6 +46,12 @@ import { useRememberedState } from "../../use-remembered-state";
import { GalleryWidget } from "../../gallery-widget"; import { GalleryWidget } from "../../gallery-widget";
import { serializeCreatureDesign } from "./serialization"; import { serializeCreatureDesign } from "./serialization";
import { CreatureEditorWidget } from "./creature-editor"; 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 * 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< export const CreaturePageWithDefaults: React.FC<
ComponentWithShareableStateProps<CreatureDesign> ComponentWithShareableStateProps<CreatureDesign>
> = ({ defaults, onChange }) => { > = ({ defaults, onChange }) => {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const [animatorName, setAnimatorName] =
useRememberedState<CreatureAnimatorName>(
"creature-page:animatorName",
"none"
);
const isAnimated = animatorName !== "none";
const [randomlyInvert, setRandomlyInvert] = useRememberedState( const [randomlyInvert, setRandomlyInvert] = useRememberedState(
"creature-page:randomlyInvert", "creature-page:randomlyInvert",
true true
@ -313,11 +350,18 @@ export const CreaturePageWithDefaults: React.FC<
useCallback(() => onChange(design), [onChange, design]) useCallback(() => onChange(design), [onChange, design])
); );
const render = useMemo(
() =>
createCreatureAnimationRenderer(creature, ctx, undefined, animatorName),
[creature, ctx, animatorName]
);
return ( return (
<Page title="Cluster!"> <Page title="Cluster!">
<div className="sidebar"> <div className="sidebar">
<CompositionContextWidget ctx={compCtx} onChange={setCompCtx} /> <CompositionContextWidget ctx={compCtx} onChange={setCompCtx} />
<CreatureEditorWidget creature={creature} onChange={setCreature} /> <CreatureEditorWidget creature={creature} onChange={setCreature} />
<AnimationWidget value={animatorName} onChange={setAnimatorName} />
<RandomizerWidget <RandomizerWidget
onColorsChange={(colors) => setCompCtx({ ...compCtx, ...colors })} onColorsChange={(colors) => setCompCtx({ ...compCtx, ...colors })}
onSymbolsChange={newRandomCreature} onSymbolsChange={newRandomCreature}
@ -356,42 +400,65 @@ export const CreaturePageWithDefaults: React.FC<
<ExportWidget <ExportWidget
basename={getDownloadBasename(creature.data.name)} basename={getDownloadBasename(creature.data.name)}
svgRef={svgRef} svgRef={svgRef}
animate={isAnimated && { duration: ANIMATION_PERIOD_MS, render }}
/> />
</div> </div>
</div> </div>
<CreatureCanvas <CreatureCanvas
compCtx={compCtx} compCtx={compCtx}
ctx={ctx} render={render}
creature={creature}
ref={svgRef} ref={svgRef}
isAnimated={isAnimated}
/> />
</Page> </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 = { type CreatureCanvasProps = {
isAnimated: boolean;
compCtx: SvgCompositionContext; compCtx: SvgCompositionContext;
ctx: CreatureContextType; render: AnimationRenderer;
creature: CreatureSymbol;
}; };
const ANIMATION_PERIOD_MS = 5000;
const CreatureCanvas = React.forwardRef<SVGSVGElement, CreatureCanvasProps>( const CreatureCanvas = React.forwardRef<SVGSVGElement, CreatureCanvasProps>(
({ compCtx, ctx, creature }, svgRef) => { ({ isAnimated, compCtx, render }, svgRef) => {
const animPct = useAnimationPct(isAnimated ? ANIMATION_PERIOD_MS : 0);
return ( return (
<div className="canvas" style={{ backgroundColor: compCtx.background }}> <div className="canvas" style={{ backgroundColor: compCtx.background }}>
<CreatureContext.Provider value={ctx}> <HoverDebugHelper>
<HoverDebugHelper> <AutoSizingSvg
<AutoSizingSvg padding={100}
padding={20} ref={svgRef}
ref={svgRef} resizeKey={render}
bgColor={compCtx.background} bgColor={compCtx.background}
> >
<SvgTransform transform={svgScale(0.5)}> {render(animPct)}
<CreatureSymbol {...creature} /> </AutoSizingSvg>
</SvgTransform> </HoverDebugHelper>
</AutoSizingSvg>
</HoverDebugHelper>
</CreatureContext.Provider>
</div> </div>
); );
} }