<div id="root"></div>
const { useState, useRef, useCallback, useEffect, useLayoutEffect } = React;

// https://twitter.com/xapaxa/status/1103854291578703872
// https://codepen.io/halvves/pen/JwQaVN?editors=0010
const { css, keyframes, createGlobalStyle } = styled;
const MOLE_LENGTH = 9;
const MOLE_SPEED = 0.3;

const APPEARANCE_INTERVAL = 1200;

const MOLE = {
  default: {
    eyes: ["●", "●"],
    complexion: "sienna"
  },
  hurt: {
    eyes: [">", "<"],
    complexion: "#f7504a"
  }
};

const TIME_LIMIT = {
  hour: 0,
  min: 0,
  sec: 10
};

const createArray = (length, elem) => {
  return new Array(length).fill(elem);
};

const useCountDownTimer = (time) => {
  const [timeLimit, setTimeLimit] = useState(time);

  const [isStart, setIsStart] = useState(false);
  const [isStop, setIsStop] = useState(false);
  const [isReset, setIsReset] = useState(false);
  const [isTimeUp, setIsTimeUp] = useState(false);

  const intervalID = useRef(null);

  const zeroPaddingNum = useCallback((num) => {
    return String(num).padStart(2, "0");
  }, []);

  const startTime = useCallback(() => {
    intervalID.current = setInterval(() => tick(), 1000);

    setIsStart(true);
    setIsStop(false);
    setIsTimeUp(false);
    setIsReset(false);
  });

  const stopTime = useCallback(() => {
    clearInterval(intervalID.current);

    setIsStop(true);
    setIsStart(false);
  });

  const resetTime = useCallback(() => {
    clearInterval(intervalID.current);

    setTimeLimit({
      hour: zeroPaddingNum(time.hour),
      min: zeroPaddingNum(time.min),
      sec: zeroPaddingNum(time.sec)
    });

    setIsReset(true);
    setIsStart(false);
    setIsStop(false);
    setIsTimeUp(false);
  });

  const tick = useCallback(() => {
    setTimeLimit((prevTimeLimit) => {
      const newTimeLimit = Object.assign({}, prevTimeLimit);
      const { hour, min, sec } = newTimeLimit;

      if (hour <= 0 && min <= 0 && sec <= 0) {
        stopTime();

        setIsTimeUp(true);

        return newTimeLimit;
      }

      if (newTimeLimit.hour > 0 && min <= 0 && sec <= 0) {
        newTimeLimit.hour -= 1;
        newTimeLimit.min = 60;
      }

      if (newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
        newTimeLimit.min -= 1;
        newTimeLimit.sec = 60;
      }

      newTimeLimit.sec -= 1;

      return {
        hour: zeroPaddingNum(newTimeLimit.hour),
        min: zeroPaddingNum(newTimeLimit.min),
        sec: zeroPaddingNum(newTimeLimit.sec)
      };
    });
  });

  // ピッカーで選択した値をそのままタイムリミットとして反映する
  useEffect(() => {
    setTimeLimit({
      hour: zeroPaddingNum(time.hour),
      min: zeroPaddingNum(time.min),
      sec: zeroPaddingNum(time.sec)
    });
  }, [time]);

  // タイムアップした後にスタートボタンを押したときに選択したタイムからカウントダウンする
  useEffect(() => {
    if (isStart && isTimeUp) {
      setTimeLimit({
        hour: zeroPaddingNum(time.hour),
        min: zeroPaddingNum(time.min),
        sec: zeroPaddingNum(time.sec)
      });

      setIsTimeUp(false);
    }
  }, [isStart]);

  return [
    timeLimit,
    startTime,
    stopTime,
    resetTime,
    { isStart, isStop, isTimeUp, isReset }
  ];
};

const useMoles = (stage) => {
  const [moles, setMoles] = useState(createArray(MOLE_LENGTH, MOLE.default));

  const intervalID = useRef(null);

  const moveMole = useCallback((num) => {
    setMoles((prevMoles) =>
      prevMoles.map((prevMole) =>
        prevMole.num === num
          ? {
              ...prevMole,
              isMove: true
            }
          : prevMole
      )
    );
  }, []);

  const initializeMoles = useCallback(() => {
    setMoles((prevMoles) =>
      prevMoles.map((prevMole, i) => ({
        ...MOLE.default,
        isMove: false,
        isStruck: false,
        num: i + 1
      }))
    );
  }, []);

  const hitMole = useCallback((num) => {
    setMoles((prevMoles) =>
      prevMoles.map((prevMole) =>
        prevMole.num === num
          ? {
              ...prevMole,
              ...MOLE.hurt,
              isStruck: true
            }
          : prevMole
      )
    );
  }, []);

  const moveMoles = useCallback(() => {
    intervalID.current = setInterval(() => {
      initializeMoles();

      const randomMoleNum = Math.floor(Math.random() * MOLE_LENGTH) + 1;
      moveMole(randomMoleNum);
    }, APPEARANCE_INTERVAL);
  }, []);

  const stopMoles = useCallback(() => {
    clearInterval(intervalID.current);
  }, []);

  return [moles, moveMoles, initializeMoles, stopMoles, hitMole];
};

const GlobalStyle = createGlobalStyle`
  @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

  :root {
    --brand-color: rebeccapurple;
    --accent-color: darkorange;
    --text-color: rgb(40, 40, 40);
    --bg-color: #444;
  }
  * {
    font-family: 'Press Start 2P', cursive;
  }
  body {
    background-color: var(--bg-color);
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100vh;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    margin: 0;
    overflow:hidden;
    width: 100%;
  } 
`;

const StyledTimeDisplay = styled.div`
  color: ${({ textColor }) => (textColor ? textColor : `#333`)};
  font-family: "Share Tech Mono", monospace;
  font-weight: bold;
  font-size: ${({ fontSize }) => (fontSize ? fontSize : "1em")};
`;

const TimeDisplay = ({ className, time, delimiter, fontSize, textColor }) => {
  const newTime = Array.isArray(time) ? time : Object.values(time);

  return (
    <StyledTimeDisplay
      className={className}
      fontSize={fontSize}
      textColor={textColor}
    >
      {newTime.map((n, i, array) => (
        <>
          <span>{String(n).padStart(2, "0")}</span>
          {i !== array.length - 1 && delimiter}
        </>
      ))}
    </StyledTimeDisplay>
  );
};

const StyledGameStatus = styled.div`
  font-size: 1em;
`;

const GameStatus = ({ className, title, text }) => (
  <StyledGameStatus
    className={className}
  >{`${title}: ${text}`}</StyledGameStatus>
);

const Button = styled.button`
  background-color: ${({ colors }) => (colors && colors.bg) || "lightgray"};
  border: none;
  border-radius: 0.6vmin;
  cursor: ${({ isDisabled }) => (isDisabled ? "cursor" : "pointer")};
  color: ${({ colors }) => (colors && colors.text) || "#333333"};
  font-size: ${({ sizes }) => (sizes && sizes.font) || "1em"};
  height: ${({ sizes }) => (sizes && sizes.height) || "auto"};
  outline: none;
  opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)};
  padding: ${({ sizes }) => (sizes && sizes.padding) || "1em 2em"};
  pointer-events: ${({ isDisabled }) => (isDisabled ? "none" : "auto")};
  user-select: none;
  width: ${({ sizes }) => (sizes && sizes.width) || "auto"};
`;

const StyledModalWindow = styled.div`
  z-index: 1000;

  & .content-wrapper {
  }
`;

const Overlay = styled.div`
  background-color: rgba(0, 0, 0, 0.6);
  position: fixed;
  top: -100vh;
  left: -100vw;
  bottom: -100vh;
  right: -100vw;
  z-index: 1;
`;

const ContentWrapper = styled.div`
  background-color: white;
  box-sizing: border-box;
  border-radius: 2vmin;
  color: #333;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 60vmin;
  text-align: center;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  min-width: 80vmin;
  z-index: 2;
  > button {
    position: absolute;
    top: 4vmin;
    right: 4vmin;
  }
`;

const Content = styled.div`
  max-height: 80vmin;
  padding: 4vmin;
`;

const Title = styled.h1`
  font-size: 4vmin;
  margin: 0 auto 4vmin;
`;

const Text = styled.p`
  color: #777;
  font-size: 2vmin;
  margin: 0 0 4vmin;
`;

const Buttons = styled.div`
  display: flex;
  justify-content: center;

  > button {
    font-size: 2vmin;
    margin: 0 0.6vmin;
  }
`;

const ModalWindow = ({
  className,
  title,
  text,
  isShow,
  isShowCloseButton,
  buttons
}) => {
  const [isClose, setIsClose] = useState(false);
  const closeButtonSizes = {
    padding: "0"
  };
  return (
    <>
      {isShow ? (
        <StyledModalWindow className={className}>
          <Overlay />
          <ContentWrapper>
            {isShowCloseButton && (
              <Button
                colors={{
                  bg: "transparent"
                }}
                sizes={closeButtonSizes}
                onClick={() => setIsClose(true)}
              >
                ✖
              </Button>
            )}
            <Content>
              <Title>{title}</Title>
              <Text>{text}</Text>
              <Buttons>
                {buttons.map((b, i) => (
                  <Button
                    className="btn"
                    onClick={b.callback}
                    colors={b.colors}
                    sizes={b.sizes}
                  >
                    {b.label}
                  </Button>
                ))}
              </Buttons>
            </Content>
          </ContentWrapper>
        </StyledModalWindow>
      ) : (
        <></>
      )}
    </>
  );
};

const StyledMole = styled.div`
  background-color: ${({ mole }) => mole.complexion};
  border-radius: 50% 50% 20% 20%;
  cursor: pointer;
  height: 5.5em;
  position: absolute;
  bottom: -5.5em;
  width: 65%;

  &::after {
    border-radius: 50% 50% 20% 20%;
    content: "";
    display: block;
    height: 100%;
    opacity: 0;
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
  }

  & .mole-eyes {
    position: absolute;
    top: 1.4em;
    left: 0;
    right: 0;
  }

  & .mole-eye {
    border-radius: 50%;
    color: #333;
    font-family: none;
    font-size: 1em;
    line-height: 0;
    position: absolute;
    top: 0;

    &:nth-child(1) {
      left: 1.2em;
    }

    &:nth-child(2) {
      right: 1.2em;
    }
  }

  & .mole-mouth {
    background-color: tan;
    border-radius: 50%;
    display: block;
    height: 2em;
    position: absolute;
    top: 1.8em;
    left: calc(50% - (2.4em / 2));
    width: 2.4em;
  }

  & .mole-nose {
    background-color: #333;
    border-radius: 100%;
    position: absolute;
    top: calc(50% - 0.3em);
    left: calc(50% - 0.3em);
    height: 0.6em;
    width: 0.6em;
  }

  & .mole-whiskers {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 100%;

    &-left {
      left: -80%;

      &::before {
        transform: rotate(15deg);
      }

      &::after {
        transform: rotate(-15deg);
      }
    }

    &-right {
      right: -80%;

      &::before {
        transform: rotate(-15deg);
      }

      &::after {
        transform: rotate(15deg);
      }
    }

    &-whisker,
    &::before,
    &::after {
      background-color: #fff;
      display: block;
      position: absolute;
      height: 0.01em;
      left: 0;
      width: 100%;
    }

    &::before,
    &::after {
      content: "";
    }

    &-whisker {
      top: calc(50% - 0.005em);
    }

    &::before {
      top: 0.4em;
    }

    &::after {
      bottom: 0.4em;
    }
  }
`;

const useMole = (mole) => {
  const moleElementRef = useRef(null);
  /*const [runSinkMoleAnimation, runUpDownMoleAnimation] = useMoleAnimation(
    moleElementRef
  );*/
  const runSinkMoleAnimation = useCallback(() => {
    TweenMax.to(moleElementRef.current, 0.1, { y: 0 });
  });

  const runUpDownMoleAnimation = useCallback(() => {
    const moleHeight = moleElementRef.current.getBoundingClientRect().height;
    const tween = TweenMax.to(moleElementRef.current, 0.5, { y: -moleHeight });
    tween.repeat(1);
    tween.yoyo(true);
  });

  useEffect(() => {
    if (mole.isMove) {
      runUpDownMoleAnimation();
    }

    if (mole.isStruck) {
      runSinkMoleAnimation();
    }
  }, [mole.isMove, mole.isStruck]);

  return moleElementRef;
};

const Mole = ({ className, mole, ...props }) => {
  const moleElementRef = useMole(mole);

  return (
    <StyledMole
      className={`${className} ${className}-${mole.num}`}
      ref={moleElementRef}
      mole={mole}
      {...props}
    >
      <div className="mole-eyes">
        {mole.eyes.map((eye, index) => (
          <div className="mole-eye" key={index}>
            {eye}
          </div>
        ))}
      </div>
      <div className="mole-mouth">
        <div className="mole-nose"></div>
        <div className="mole-whiskers mole-whiskers-left">
          <span className="mole-whiskers-whisker"></span>
        </div>
        <div className="mole-whiskers mole-whiskers-right">
          <span className="mole-whiskers-whisker"></span>
        </div>
      </div>
    </StyledMole>
  );
};

const emergeAnimation = keyframes`
	0% {
        transform: scale(0.2);
        opacity: 1;
        visibility: visible;
    }
    70% {
        transform: scale(1);
    }
    100% {
        opacity: 0;
        visibility: hidden;
    }
`;

const StyledTextEmergeEffect = styled.p`
  animation: ${css`
    ${emergeAnimation} 1s 1
  `};
  color: white;
  font-size: 1em;
  opacity: 0;
  visibility: hidden;
  text-align: center;
`;

const TextEmergeEffect = ({ className, text }) => (
  <StyledTextEmergeEffect className={className}>{text}</StyledTextEmergeEffect>
);

const StyledApp = styled.div`
  padding: 1em;
  font-size: 4vmin;

  & .navigation {
    color: white;
    display: flex;
    font-size: 0.4em;
    justify-content: space-between;
    margin-bottom: 1.6em;
    width: 100%;
  }

  & .stage {
    background-color: #96d65e;
    display: flex;
    flex-wrap: wrap;
    padding: 0 1em 1em;
    position: relative;
    width: 20em;

    & .cell {
      display: flex;
      align-items: end;
      justify-content: center;
      width: calc(100% / 3);
    }
    & .hole {
      background-color: #431f07;
      border-radius: 100%;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      height: 4.5em;
      width: 88%;

      &-mask {
        border-radius: 50%;
        display: flex;
        align-items: end;
        justify-content: center;
        overflow: hidden;
        padding-top: 1em;
        position: relative;
        width: 100%;
      }
    }
    & .hit-effect {
      font-size: 1em;
    }
  }
`;

const App = () => {
  const [isStart, setIsStart] = useState(false);
  const [isStop, setIsStop] = useState(false);
  const [hitCount, setHitCount] = useState(0);

  const [moles, moveMoles, initializeMoles, stopMoles, hitMole] = useMoles();
  const [
    timeLimit,
    startTime,
    stopTime,
    resetTime,
    countDownStatus
  ] = useCountDownTimer(TIME_LIMIT);

  const startGame = useCallback(() => {
    setIsStart(true);
    startTime();
    moveMoles();
  }, []);

  const stopGame = useCallback(() => {
    setIsStop(true);
    stopMoles();
  }, []);

  const initializeGame = useCallback(() => {
    setIsStart(false);
    setIsStop(false);
    setHitCount(0);
    initializeMoles();
    resetTime();
  }, []);

  const handleHit = useCallback((e) => {
    const moleElement = e.target;

    const num = parseFloat(moleElement.className.replace(/[^0-9]/g, ""));

    hitMole(num);
    setHitCount((prevHitCount) => prevHitCount + 1);
  }, []);

  useEffect(() => {
    initializeGame();
  }, []);

  useEffect(() => {
    if (isStart && countDownStatus.isTimeUp) {
      stopGame();
    }
  }, [countDownStatus.isTimeUp]);

  const startBtn = {
    label: "start",
    colors: {
      bg: `var(--accent-color)`,
      text: "white"
    },
    callback: startGame
  };

  const endBtn = {
    label: "end",
    colors: {
      bg: "#333",
      text: "white"
    },
    callback: initializeGame
  };

  const replayBtn = {
    label: "replay",
    colors: {
      bg: "green",
      text: "white"
    },
    callback: () => {
      initializeGame();
      startGame();
    }
  };

  return (
    <>
      <GlobalStyle />
      <StyledApp>
        <ModalWindow
          className="finish-modal"
          title="Time's up"
          text={`Your score is ${hitCount}!`}
          isShow={isStop}
          isShowCloseButton={false}
          buttons={[endBtn, replayBtn]}
        ></ModalWindow>
        <ModalWindow
          className="start-modal"
          title="Whack a Mole"
          text="Click the button to start!"
          isShow={!isStart}
          isShowCloseButton={false}
          buttons={[startBtn]}
        ></ModalWindow>
        <div className="navigation">
          <GameStatus
            className="time-limit"
            title="Time limit"
            text={`${timeLimit.min}:${timeLimit.sec}`}
          />
          <GameStatus className="score" title="Score" text={hitCount} />
        </div>
        <div className="stage">
          {moles &&
            moles.map((mole, i) => (
              <div className="cell" key={i}>
                <div className="hole-mask">
                  <div className="hole">
                    {mole.isStruck && (
                      <TextEmergeEffect className="hit-effect" text="hit!" />
                    )}
                    <Mole className="mole" mole={mole} onClick={handleHit} />
                  </div>
                </div>
              </div>
            ))}
        </div>
      </StyledApp>
    </>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js
  3. https://unpkg.com/styled-components@4.1.2/dist/styled-components.min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/gsap/2.0.2/TweenMax.min.js