<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
This Pen doesn't use any external CSS resources.