Add cluster animations. (#232)
rodzic
b1265990a9
commit
19208970cd
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue