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