src/pages/attack-animation-simulator.tsx
// attack-animation-simulator.tsx
import React, {
memo,
useState,
useEffect,
ReactElement,
useCallback,
} from "react"
import { useSelector } from "react-redux"
import { graphql, PageProps } from "gatsby"
import styled, { css } from "styled-components"
import {
animated,
useTransition,
useSpring,
config,
AnimatedValue,
} from "react-spring"
import { CSSTransition, TransitionGroup } from "react-transition-group"
import random from "lodash/random"
import debounce from "lodash/debounce"
import throttle from "lodash/throttle"
import uuid from "uuid"
import { LayoutManager } from "components/layoutManager"
import SEO from "components/seo"
import { rhythm } from "utils/typography"
import { Button } from "components/Button"
const AnimatedPre = styled(animated.pre)`
transition: color 200ms ease-in-out;
`
const AnimatedBar = styled(animated.div)`
max-width: 100%;
`
const StyledDescription = styled.div`
border: 1px solid grey;
border-radius: 5px;
display: block;
padding: ${rhythm(0.5)};
margin: ${rhythm(0.5)};
`
/**
* # AttackCounter
* A `styled-component` that that updates style based on props
*
* @param {...*} props
* @param {string|number} props.value some attack.damage value
*/
const AttackCounter = styled(animated.div)`
position: fixed;
top: 50%;
left: 50%;
transform(-50%, -50%);
${(props) =>
props.value &&
css`
font-size: ${36 + props.value}px;
color: rgb(
${5 * props.value},
${250 / (props.value / 10)},
${250 / props.value}
);
`}
`
/**
* # AnimatedAttackCounter
* An `animated`, `styled-component`
*/
const AnimatedAttackCounter = memo(AttackCounter)
/**
* # <Damage />
* A container for using animated values to animate
* total damage as a number and a visual bar.
* @param {...*} props
* @param {AnimatedValue} props.totalDamage
*
* @returns {ReactElement} ReactElement
*/
const Damage = memo(
({
totalDamage,
}: {
totalDamage: AnimatedValue<{ number: number }>
}): ReactElement => {
return (
<>
<StyledDescription>
<p>Damage</p>
<AnimatedPre>
{totalDamage.number.interpolate((x) => x.toFixed(0))}
</AnimatedPre>
<AnimatedBar
style={{
display: "inline-block",
minHeight: `30px`,
minWidth: totalDamage.number,
maxWidth: `100%`,
backgroundImage: totalDamage.number.interpolate({
range: [0, 500, 1000],
output: [
`linear-gradient(130deg, #003a00, #009900)`,
`linear-gradient(130deg, #00008a, #0000ff)`,
`linear-gradient(130deg, #7a0000, #ff0000)`,
],
}),
}}
/>
</StyledDescription>
</>
)
}
)
/**
* # <Stamina />
* A container for using animated values to animate
* total stamina as a number and a visual bar.
* @param {...*} props
* @param {AnimatedValue} props.totalStamina
*
* @returns {ReactElement} ReactElement
*/
const Stamina = memo(
({
totalStamina,
}: {
totalStamina: AnimatedValue<{ number: number }>
}): ReactElement => {
return (
<>
<StyledDescription>
<p>Stamina</p>
<AnimatedPre>
{/* interpolate on the animated value to return a string */}
{totalStamina.number.interpolate((x) => x.toFixed(0))}
</AnimatedPre>
<AnimatedBar
style={{
display: "inline-block",
minHeight: `30px`,
minWidth: totalStamina.number,
backgroundImage: totalStamina.number.interpolate({
range: [0, 500, 1000],
output: [
`linear-gradient(130deg, #7a0000, #ff0000)`,
`linear-gradient(130deg, #7a7a00, #ffff00)`,
`linear-gradient(130deg, #003a00, #009900)`,
],
}),
}}
/>
</StyledDescription>
</>
)
}
)
/**
* # AttackAnimationSimulator
* Cool thing
*/
function AttackAnimationSimulator(props: PageProps) {
const { data } = props
const siteTitle = data.site.siteMetadata.title
const isDarkMode = useSelector((state) => state.isDarkMode)
// list of attacks
const [items, setItems] = useState([])
// https://www.react-spring.io/docs/hooks/use-spring
/**
* # Either:
* ## overwrite values to change the animation
* - If you re-render the component with changed props, the animation will update.
* @see #1
*
* # Or:
* ## pass a function that returns values, and update using "set"
* - You will get an updater function back.
* - It will not cause the component to render like an overwrite would (still the animation executes of course).
* - Handling updates like this is useful for fast-occurring updates, but you should generally prefer it.
* - Optionally there's also a stop function as a third argument.
* @see #2
*/
// #1
// const totalDamage = useSpring({
// number: d,
// })
// #2
const [totalDamage, setTotalDamage] = useSpring(() => ({
number: 0,
}))
// stateful value, with unused updater
const [maxStamina, setMaxStamina] = useState(1000)
// Animated value for <Stamina />
const [totalStamina, setTotalStamina, stop] = useSpring(() => ({
number: maxStamina,
// config: config.gentle,
}))
// const totalStamina = useSpring({
// // from: { number: 0 },
// /**
// * script
// */
// // to: async next => {
// // // cancelMap.set(item, cancel)
// // await next({ number: d - r })
// // await next({ number: 0 })
// // },
// /**
// * chain
// */
// // to: [{ number: r }],
// })
/**
* # `attack`
* ### aka 'handleAttack'
* Note: attaching this to the window, **once**, in `useEffect`, will cause
* the same instance of `attack` to be called every time for that event listener.
*
* However, for the case of a button's `onClick` event, if the
* component rerenders, onClick will be calling a new instance
* of `attack`.
*
* This can be fixed with `useCallback`, which returns a memoized
* version of the function.
*/
const attack = useCallback(() => {
const dmg = random(5, 50)
// Add attack to array of attacks
setItems((items) => [...items, { id: uuid(), text: dmg }])
// CSSTransition.onEntered() will remove the attack when finisehd animating
setTotalDamage({ number: totalDamage.number.getValue() + dmg })
setTotalStamina({ number: totalStamina.number.getValue() - dmg })
debouncedResetStamina()
}, [])
/**
* # `reset`
* ### aka 'handleReset'
*/
const reset = () => {
debouncedResetStamina.flush()
setItems([]) // This causes a rerender
setTotalDamage({ number: 0 })
// setTotalStamina({ number: maxStamina })
}
/**
* # `debouncedResetStamina`
* For this to be `.cancel`-able, it must be memoized with
* `useCallback` so that a new instance doesn't get created
* when the component rerenders.
* - this gets called inside `attack()`
* - wait time is 2000 ms
* - this gets canceled inside `reset()`
*/
const debouncedResetStamina = useCallback(
debounce(() => {
setTotalStamina({ number: maxStamina })
}, 2000),
[]
)
/**
* # Transitions
* Applies transitions to the `items` array.
* ```
* type Item: {
* text: number|string
* id: string // uuid()
* }>
* ```
* `config:` can be a callback, with the specific `{item}` and `state`
* as 1st and 2nd args, respectively.
*/
const transitions = useTransition(items, (item) => item.id, {
from: ({ text }) => {
return { opacity: 0, transform: `translate3d(0%,0%,0)` }
},
enter: ({ text }) => ({
opacity: 1,
transform: `translate3d(${random(-50, 50)}%,-${100 + text}%,0)`,
}),
leave: {
opacity: 0,
transform: `translate3d(0%,-30%,0)`,
},
// onDestroyed: e => {
// debouncedResetStamina()
// },
config: ({ text }, state) => {
return text <= 20
? config.slow
: text <= 40
? config.gentle
: state === "leave"
? { ...config.molasses, duration: 2000 }
: config.stiff
},
/**
* same thing as CSSTransition.onEntered
*/
// onRest: item => {
// setItems(items => items.filter(e => e.id !== item.id))
// },
})
/**
* Attach event listeners
*/
useEffect(() => {
const handleKeyPress = throttle((e) => {
e.key === "a" ? attack() : e.key === "r" ? reset() : null
}, 100)
typeof window !== "undefined" &&
window.addEventListener("keypress", handleKeyPress)
return () => {
window.removeEventListener("keypress", handleKeyPress)
}
}, [])
return (
<LayoutManager location={props.location} title={siteTitle}>
<SEO title="Attack Animation Simulator" />
<h1>Attack Animation Simulator</h1>
<div className="container" style={{ height: `100vh` }}>
<Button onClick={attack}>
<label>(Press A)</label>
<span>Attack</span>
</Button>
<Button onClick={reset}>
<label>(Press R)</label>
<span>Reset</span>
</Button>
<Damage totalDamage={totalDamage} />
<Stamina totalStamina={totalStamina} />
{/* <AnimatedPre className="damage-dealt">{d}</AnimatedPre> */}
{/* <pre>{JSON.stringify(totalDamage, null, 2)}</pre> */}
<TransitionGroup className="attacks">
{transitions.map(({ item, props, key }, i) => (
<CSSTransition
key={key}
timeout={600 + item.text * 10}
classNames="item"
onEntered={() =>
setItems((items) => items.filter((e) => e.id !== item.id))
}
>
<AnimatedAttackCounter
value={item.text}
style={{
...props,
}}
>
{item.text}
</AnimatedAttackCounter>
</CSSTransition>
))}
</TransitionGroup>
</div>
</LayoutManager>
)
}
export default AttackAnimationSimulator
export const pageQuery = graphql`
query {
site {
siteMetadata {
title
}
}
}
`