Reactでアニメーションの開始・ステップ実行・一時停止・リセットを制御する

# これは何？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193148/a146de8a-45fd-8a25-193b-4262ef6958fc.png)

* Reactのコンポーネントで、アニメーションなどの何らかのステップごとの処理内容を、開始・ステップ実行・一時停止・リセットの4つの機能で制御するしくみと、これらの機能に対応したボタンなどで構成されたユーザインターフェイスを提供します。

* アニメーションの1フレーム分を実行する`tick`関数を、コンポーネント外部からプロパティで与えることができます。この`tick`関数は、`requestAnimationFrame`関数を通じて、周期的な呼び出しをされます。

* `tick`関数を実行する周期は、「亀（ゆっくり）」と、「ウサギ（すばやく）」のスライダーで制御できます。

* コンポーネントの状態として、経過時間、フレーム番号、FPSを管理し、表示します。

* React Material UI Componentを用いています。
* MITライセンスです。


# デモ

本記事のコンポーネントを内部的に利用して、[シェリングの分居モデルのシミュレーション](https://kubohiroya.github.io/webgpu-react-bitmap-viewport/examples/index.html#3)を開発しました。`tick`関数から、JavaScript / WebAssembly / WebGPU / WebGPU + WebAssembly の4通りの実装を実行して、それぞれの動作速度を比較することができます。

ちなみに、MacBook Pro 16インチ(M1 Max)で、1024x1024のトーラス状の空間の75%に5種類のエージェントを配置してシミュレーションを動作させた場合、 WebGPU + WebAssemblyの組み合わせで、90fps弱くらいまで性能を引き出すことができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193148/177ecd7f-79dd-ca5c-f174-0b7405450942.png)

# 機能

* このコンポーネントの状態は、INITIALIZING(初期化中), INITALIZED(初期化済み), RUNNING(実行中), STEP_RUNNING(ステップ実行中), PAUSED(一時停止中)の５つの値を取ります。ユーザの操作内容や、tick関数の返値によって状態を遷移させます。

```mermaid
stateDiagram-v2
    [*] --> INITIALIZING
    INITIALIZING --> INITIALIZED
    INITIALIZED --> STEP_RUNNING : STEPを押す
    INITIALIZED --> RUNNING : PLAYを押す
    PAUSED --> STEP_RUNNING : STEPを押す
    STEP_RUNNING --> PAUSED : tick関数実行完了
    PAUSED --> RUNNING : PLAYを押す
    RUNNING --> PAUSED : PAUSEを押す
    PAUSED --> INITIALIZING : RESETを押す
    RUNNING --> RUNNING : tick関数実行完了
    RUNNING --> PAUSED: tick関数からfalseが返された
    
```

* 開始ボタン、ステップ実行ボタン、一時停止ボタン、リセットボタンを押したときにコールバックされる関数を、コンポーネント外部からプロパティで与えることができます。
* FPSは、一時停止をしていた時間を除いて計算されるようになっています。

# 使い方
```TypeScript
import {
  PlayControllerPanel,
  PlayControllerState,
} from './PlayControllerPanel';

:
略
:
  const [playControllerState, setPlayControllerState] =
    useState<PlayControllerState>(PlayControllerState.INITIALIZING);

  const onReset = useCallback(() => { 
    // TODO: リセット処理
  }, []);
  
  const onPause = useCallback(() => {
    setPlayControllerState(PlayControllerState.PAUSED);
  }, []);
  
  const onStep = useCallback(async () => {
    switch (playControllerState) {
      case PlayControllerState.INITIALIZED:
        // TODO: 初回フレームの処理
        break;
      case PlayControllerState.STEP_RUNNING:
      case PlayControllerState.PAUSED:
        setPlayControllerState(PlayControllerState.STEP_RUNNING);
        break;
    }
  }, [playControllerState]);
  
  const onPlay = useCallback(async () => { 
    if (playControllerState == PlayControllerState.INITIALIZED) {
        // TODO: 初回フレームの処理
    }
    setPlayControllerState(PlayControllerState.RUNNING);
  }, [playControllerState]);
  
  const tick = = useCallback(async (): boolean => { 
if (playControllerState === PlayControllerState.PAUSED) {
      return false;
    }

    if (
      playControllerState === PlayControllerState.INITIALIZED ||
      playControllerState === PlayControllerState.RUNNING ||
      playControllerState === PlayControllerState.STEP_RUNNING
    ) {
        
        // TODO: フレームごとの処理

        if (
          playControllerState === PlayControllerState.RUNNING &&
          isSessionFinished()
        ) {
          setPlayControllerState(PlayControllerState.PAUSED);
        }
        return true;
     
    } else {
      return false;
    }
  }, []);

  return (
        <PlayControllerPanel
          state={playControllerState}
          speed={0.5}
          onReset={onReset}
          onPause={onPause}
          onStep={onStep}
          onPlay={onPlay}
          tick={tick}
        />
    );
```

# コード

```TypeScript:PlayControllerPanel.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, ButtonGroup, Slider, Typography } from '@mui/material';
import { Pause, PlayCircle, RestartAlt, SkipNext } from '@mui/icons-material';
import styled from '@emotion/styled';

export enum PlayControllerState {
  INITIALIZING,
  INITIALIZED,
  RUNNING,
  STEP_RUNNING,
  PAUSED,
}

export type PlayControllerProps = {
  state: PlayControllerState;
  speed: number;
  onPlay: () => void;
  onPause: () => void;
  onStep: () => void;
  onReset: () => void;
  tick: () => Promise<boolean>;
};
const StyledButtonGroup = styled(ButtonGroup)`
  margin: 4px;
  padding-left: 4px;
  padding-right: 4px;
  border-collapse: separate;
`;
const PlayButton = styled(Button)`
  border-radius: 30px;
  border-spacing: 1px;
  border-style: solid;
  border-color: lightgrey;
  border-width: 1px;
`;
const PaddedPlayButton = styled(PlayButton)`
  padding: 2px 8px 2px 4px;
`;
const MainPlayButton = styled(PlayButton)``;
export const PlayControllerPanel = (props: PlayControllerProps) => {
  const [speed, setSpeed] = useState<number>(props.speed);
  const [elapsed, setElapsed] = useState<number | null>(null);
  const [fps, setFps] = useState<string | null>(null);

  const stepFrameCount = useRef<number>(0);

  const frameCountRef = useRef<number>(0);
  const [frameCount, setFrameCount] = useState<number>(0);

  const handleRef = useRef<number | null>(null);
  const sessionStartedAt = useRef<number>(0);
  const timeTotal = useRef<number>(0);

  const requestStop = useRef<boolean>(false);

  const onSpeedChange = useCallback(
    (_: Event, value: number[] | number) => {
      setSpeed(value as number);
    },
    [],
  );

  const start = useCallback(() => {
    handleRef.current !== null && cancelAnimationFrame(handleRef.current);
    handleRef.current = null;
    requestStop.current = false;

    sessionStartedAt.current = Date.now();
    const delay = Math.pow(1.0 - speed, 2) * 1000 + 1;
    let loopStartedAt = Date.now();

    const loop = async () => {
      const delta = Date.now() - loopStartedAt;
      if (delta >= delay) {
        const result = await props.tick();
        loopStartedAt = Date.now();
        if (result) {
          frameCountRef.current++;
          setFrameCount(frameCountRef.current);
          updateFPS();
          handleRef.current = requestAnimationFrame(loop);
        } else {
          handleRef.current !== null && cancelAnimationFrame(handleRef.current);
          handleRef.current = null;
          requestStop.current = true;
          return;
        }
      } else {
        !requestStop.current &&
          (handleRef.current = requestAnimationFrame(loop));
      }
    };
    handleRef.current = requestAnimationFrame(loop);
  }, [speed, props.tick]);

  const pause = useCallback(() => {
    handleRef.current !== null && cancelAnimationFrame(handleRef.current);
    handleRef.current = null;
    requestStop.current = true;
    if (sessionStartedAt.current > 0) {
      timeTotal.current += Date.now() - sessionStartedAt.current;
      sessionStartedAt.current = 0;
    }
  }, []);

  const reset = useCallback(() => {
    sessionStartedAt.current = 0;
    frameCountRef.current = 0;
    stepFrameCount.current = 0;
    timeTotal.current = 0;
    setFrameCount(0);
    setElapsed(null);
    setFps(null);
  }, []);

  const doStep = useCallback(() => {
    frameCountRef.current++;
    setFrameCount(frameCountRef.current);
    stepFrameCount.current++;
    props.onStep();
  }, [props.onStep]);

  const step = useCallback(() => {
    props.tick();
  }, [props.tick]);

  const updateFPS = useCallback(() => {
    if (sessionStartedAt.current && sessionStartedAt.current > 0) {
      const elapsedMsec =
        Date.now() - sessionStartedAt.current + timeTotal.current;
      if (frameCountRef.current >= 10) {
        setFps(
          (
            (frameCountRef.current - stepFrameCount.current) /
            (elapsedMsec / 1000)
          ).toFixed(1),
        );
      }
      setElapsed(Math.round(elapsedMsec / 1000));
    }
  }, []);

  useEffect(() => {
    switch (props.state) {
      case PlayControllerState.INITIALIZING:
      case PlayControllerState.INITIALIZED:
        pause();
        reset();
        break;
      case PlayControllerState.RUNNING:
        start();
        break;
      case PlayControllerState.STEP_RUNNING:
        step();
        break;
      case PlayControllerState.PAUSED:
        pause();
        break;
      default:
        break;
    }
  }, [props.state, props.tick]);

  useEffect(() => {
    return () => {
      pause();
    };
  }, []);

  useEffect(() => {
    if (props.speed !== speed) {
      pause();
      start();
    }
  }, [speed, start]);

  const color =
    props.state === PlayControllerState.RUNNING ? 'warning' : 'primary';

  return (
    <>
      <StyledButtonGroup color={color}>
        <PaddedPlayButton
          title="Restart"
          variant="contained"
          size="small"
          onClick={props.onReset}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.INITIALIZED ||
            props.state === PlayControllerState.RUNNING ||
            props.state === PlayControllerState.STEP_RUNNING
          }
        >
          <RestartAlt />
          Reset
        </PaddedPlayButton>
        <PaddedPlayButton
          title="Pause"
          variant="contained"
          size="small"
          onClick={props.onPause}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.INITIALIZED ||
            props.state === PlayControllerState.STEP_RUNNING ||
            props.state === PlayControllerState.PAUSED
          }
        >
          <Pause />
          Pause
        </PaddedPlayButton>
        <PaddedPlayButton
          title="Step"
          variant="contained"
          size="small"
          onClick={doStep}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.RUNNING ||
            props.state === PlayControllerState.STEP_RUNNING
          }
        >
          <SkipNext />
          Step
        </PaddedPlayButton>
        <MainPlayButton
          title="Play"
          variant="contained"
          onClick={props.onPlay}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.RUNNING ||
            props.state === PlayControllerState.STEP_RUNNING
          }
        >
          <PlayCircle />
          Play
        </MainPlayButton>
      </StyledButtonGroup>

      <Box
        style={{
          display: 'flex',
          margin: '8px 8px 8px',
          columnGap: '8px',
          justifySelf: 'center',
          width: '100%',
        }}
      >
        <Box
          style={{
            display: 'flex',
            columnGap: '18px',
            padding: '3px 8px 3px 8px',
            borderRadius: '30px',
            borderStyle: 'solid',
            borderWidth: '1px',
            borderColor: '#b3b3b3',
            width: '100%',
          }}
        >
          <Typography style={{ alignSelf: 'center' }}>🐢</Typography>
          <Slider
            value={speed}
            aria-label="custom thumb label"
            valueLabelDisplay="auto"
            step={0.01}
            min={0}
            max={1}
            color={color}
            onChange={onSpeedChange}
          />
          <Typography style={{ alignSelf: 'center' }}>🐇</Typography>
        </Box>
        <Box
          style={{
            display: 'flex',
            columnGap: '12px',
            padding: '2px 8px 2px 8px',
            borderRadius: '30px',
            borderStyle: 'solid',
            borderWidth: '1px',
            borderColor: '#b3b3b3',
            width: '45%',
          }}
        >
          <Typography
            fontSize={11}
            color={color}
            style={{
              width: '100%',
              alignSelf: 'center',
              textAlign: 'end',
              justifyContent: 'center',
            }}
          >
            {frameCount}
            {elapsed !== null ? '  / ' + elapsed + ' sec' : ''}
            {fps !== null ? ' (' + fps + ' fps)' : ''}
          </Typography>
        </Box>
      </Box>
    </>
  );
};

```

# NPMパッケージ

(のちほど、GitHubにこのコンポーネントについての独立したリポジトリをつくり、NPMパッケージとしても登録しておきます)

# まとめ

この記事では、ブラウザ上で動作するアプリの何らかの動作内容について、開始・ステップ実行・一時停止・リセットを制御するしくみをReactコンポーネントとしてつくったものについて、紹介しました。

こういう内容のコンポーネントで、オープンソースなものが、どこかに公開されているだろうと思って、あちこち探しましたが、うまく見つけられませんでした。みんなそれぞれが、車輪の再発明をして、自分のコードに組み込んでいるのだろうと思います。

というわけで、今回、自分でも作ってみました。誰かの役に立つといいなと思い、ここに記事化して公開しておくことにします。