diff --git a/packages/game-client/package.json b/packages/game-client/package.json index 9270d66..6533211 100644 --- a/packages/game-client/package.json +++ b/packages/game-client/package.json @@ -24,6 +24,7 @@ "webpack-dev-server": "^4.15.1" }, "dependencies": { + "antd": "^5.11.0", "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/game-client/src/App.jsx b/packages/game-client/src/App.jsx index d982121..de0c0f8 100644 --- a/packages/game-client/src/App.jsx +++ b/packages/game-client/src/App.jsx @@ -1,74 +1,54 @@ -import React, { useState } from 'react'; -import { defaultDirections } from './constants'; -import { initialSnakesState, initialFoodState } from './computed'; -import { useDirection, useFood, useTicks, useSnakes, useInput, useSocket } from './hooks'; -import Grid from './Grid'; -import styles from './app.module.css'; - -// function App() { -// const [mounted, setMounted] = useState(true); -// return ( -//
-//
-// -//
-//
{mounted && }
-//
-// ); -// } +import React, { Fragment, useState } from 'react'; +import Game from './Game'; +import { Button } from 'antd'; +import { Divider, Space, Checkbox, Switch } from 'antd'; +import { stringToBoolean } from './utils'; function App() { - const [snakeId, setSnakeId] = useState(1); - - // Keep the direction of the snakes inside useRef since we don't - // want to force rerender of the component when the user changes - // the direction. - const { getDirection, onLeft, onRight, onUp, onDown, setDirection } = useDirection(defaultDirections, 1); - - useInput({ snakes, onUp, onDown, onLeft, onRight, snakeId }); - - const getSnakeCells = () => allSnakeCells(); - - const { food, getFood, removeFood, spawnFood, isFood, setFood } = useFood({ initialFoodState, getSnakeCells }); - - const getTracks = () => { - return { addSnakeToTrack, removeSnakeFromTracks, resetSnakeTrack }; - }; - - // Don't keep direction of the snakes inside of useState()... - const { - snakes, - moveForward, - getSnakeCells: allSnakeCells, - getAllSnakeIds, - } = useSnakes({ - initialSnakesState, - getDirection, - getFood, - removeFood, - setDirection, - isFood, - setFood, - getTracks, - }); - - const { addSnakeToTrack, removeSnakeFromTracks, resetSnakeTrack } = useTicks({ - moveForward, - spawnFood, - getAllSnakeIds, - }); + const [recordInLocalStorage, setRecordInLocalStorage] = useState( + stringToBoolean(localStorage.getItem('recordInLocalStorage')) ?? false, + ); + const [isGamePaused, setIsGamePaused] = useState(stringToBoolean(localStorage.getItem('isGamePaused') ?? true)); + const [showCellId, setShowCellId] = useState(stringToBoolean(localStorage.getItem('showCellId')) ?? false); return ( -
- {/* */} - -
+ + + { + const val = e.target.checked; + localStorage.setItem('isGamePaused', val); + setIsGamePaused(val); + }} + > + Is Game paused? + + { + const val = Boolean(e.target.checked); + localStorage.setItem('recordInLocalStorage', val); + setRecordInLocalStorage(val); + }} + > + Record game in localStorage? + + { + const val = Boolean(e.target.checked); + localStorage.setItem('showCellId', val); + setShowCellId(val); + }} + > + Show cell ID? + + + + + + ); } diff --git a/packages/game-client/src/Game.jsx b/packages/game-client/src/Game.jsx new file mode 100644 index 0000000..b902b92 --- /dev/null +++ b/packages/game-client/src/Game.jsx @@ -0,0 +1,77 @@ +import React, { Fragment, useState } from 'react'; +import { defaultDirections } from './constants'; +import { initialSnakesState, initialFoodState } from './computed'; +import { useDirection, useFood, useTicks, useSnakes, useInput, useSocket } from './hooks'; +import Grid from './Grid'; +import styles from './app.module.css'; + +// function App() { +// const [mounted, setMounted] = useState(true); +// return ( +//
+//
+// +//
+//
{mounted && }
+//
+// ); +// } + +function Game(props) { + const { showCellId, isGamePaused } = props; + const [snakeId, setSnakeId] = useState(1); + + // Keep the direction of the snakes inside useRef since we don't + // want to force rerender of the component when the user changes + // the direction. + const { getDirection, onLeft, onRight, onUp, onDown, setDirection } = useDirection(defaultDirections, 1); + + useInput({ snakes, onUp, onDown, onLeft, onRight, snakeId }); + + const getSnakeCells = () => allSnakeCells(); + + const { food, getFood, removeFood, spawnFood, isFood, setFood } = useFood({ initialFoodState, getSnakeCells }); + + const getTracks = () => { + return { addSnakeToTrack, removeSnakeFromTracks, resetSnakeTrack }; + }; + + // Don't keep direction of the snakes inside of useState()... + const { + snakes, + moveForward, + getSnakeCells: allSnakeCells, + getAllSnakeIds, + } = useSnakes({ + initialSnakesState, + getDirection, + getFood, + removeFood, + setDirection, + isFood, + setFood, + getTracks, + }); + + const { addSnakeToTrack, removeSnakeFromTracks, resetSnakeTrack } = useTicks({ + moveForward, + spawnFood, + getAllSnakeIds, + isGamePaused, + }); + + return ( +
+ {/* */} + +
+ ); +} + +export default Game; diff --git a/packages/game-client/src/Grid.jsx b/packages/game-client/src/Grid.jsx index 54493fd..751a1a7 100644 --- a/packages/game-client/src/Grid.jsx +++ b/packages/game-client/src/Grid.jsx @@ -1,11 +1,12 @@ import React from 'react'; import styles from './grid.module.css'; -import { CELL_DIMENSION, GRID_HEIGHT, GRID_WIDTH } from './constants'; +import { CELL_DIMENSION, GRID_HEIGHT, GRID_WIDTH, FOOD_TYPES } from './constants'; +import animation from './animations.module.css'; -function Cell({ x, y, color }) { +function Cell({ x, y, color, showCellId, animationClass }) { return (
- ); -} - -function Food({ x, y, type }) { - return ( -
+ > + {showCellId && `${x}-${y}`} + ); } -function Grid({ snakes, food }) { +function Grid({ snakes, food, showCellId }) { return (
{Object.values(snakes).map((snake) => { @@ -39,16 +28,24 @@ function Grid({ snakes, food }) { const [headKey] = snake.list; return Object.entries(hash).map(([key, value]) => { const { x, y } = value; - if (key == headKey) { - return ; - } else { - return ; - } + return ( + + ); }); })} {Object.entries(food).map(([key, value]) => { - const { x, y, type } = value; - return ; + const { x, y, type: foodType } = value; + const { color, animationClass } = FOOD_TYPES[foodType]; + return ( + + ); })}
); diff --git a/packages/game-client/src/animations.module.css b/packages/game-client/src/animations.module.css new file mode 100644 index 0000000..a859b13 --- /dev/null +++ b/packages/game-client/src/animations.module.css @@ -0,0 +1,121 @@ +.frog { + /* Enter */ + -webkit-animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + + /* Pulsate */ + -webkit-animation: pulsate-fwd 0.5s ease-in-out infinite both; + animation: pulsate-fwd 0.5s ease-in-out infinite both; +} + +.red-bull { + -webkit-animation: vibrate 0.3s linear infinite both; + animation: vibrate 0.3s linear infinite both; +} + +@-webkit-keyframes scale-in-center { + 0% { + -webkit-transform: scale(0); + transform: scale(0); + opacity: 1; + } + 100% { + -webkit-transform: scale(1); + transform: scale(1); + opacity: 1; + } +} +@keyframes scale-in-center { + 0% { + -webkit-transform: scale(0); + transform: scale(0); + opacity: 1; + } + 100% { + -webkit-transform: scale(1); + transform: scale(1); + opacity: 1; + } +} + +@-webkit-keyframes pulsate-fwd { + 0% { + -webkit-transform: scale(1); + transform: scale(1); + } + 50% { + -webkit-transform: scale(1.1); + transform: scale(1.1); + } + 100% { + -webkit-transform: scale(1); + transform: scale(1); + } +} +@keyframes pulsate-fwd { + 0% { + -webkit-transform: scale(1); + transform: scale(1); + } + 50% { + -webkit-transform: scale(1.1); + transform: scale(1.1); + } + 100% { + -webkit-transform: scale(1); + transform: scale(1); + } +} + +@-webkit-keyframes vibrate { + 0% { + -webkit-transform: translate(0); + transform: translate(0); + } + 20% { + -webkit-transform: translate(-1px, 1px); + transform: translate(-1px, 1px); + } + 40% { + -webkit-transform: translate(-1px, -1px); + transform: translate(-1px, -1px); + } + 60% { + -webkit-transform: translate(1px, 1px); + transform: translate(1px, 1px); + } + 80% { + -webkit-transform: translate(1px, -1px); + transform: translate(1px, -1px); + } + 100% { + -webkit-transform: translate(0); + transform: translate(0); + } +} +@keyframes vibrate { + 0% { + -webkit-transform: translate(0); + transform: translate(0); + } + 20% { + -webkit-transform: translate(-2px, 2px); + transform: translate(-2px, 2px); + } + 40% { + -webkit-transform: translate(-2px, -2px); + transform: translate(-2px, -2px); + } + 60% { + -webkit-transform: translate(2px, 2px); + transform: translate(2px, 2px); + } + 80% { + -webkit-transform: translate(2px, -2px); + transform: translate(2px, -2px); + } + 100% { + -webkit-transform: translate(0); + transform: translate(0); + } +} diff --git a/packages/game-client/src/app.module.css b/packages/game-client/src/app.module.css index f5ab743..a23a64a 100644 --- a/packages/game-client/src/app.module.css +++ b/packages/game-client/src/app.module.css @@ -4,7 +4,3 @@ justify-content: center; height: 100vh; } - -:global(body) { - margin: 0px; -} diff --git a/packages/game-client/src/computed.js b/packages/game-client/src/computed.js index 9f37e88..88df464 100644 --- a/packages/game-client/src/computed.js +++ b/packages/game-client/src/computed.js @@ -1,5 +1,5 @@ import { NUMBER_OF_COLUMNS, NUMBER_OF_ROWS } from './constants'; -import { generateKey, generateValue } from './helpers'; +import { generateKey, generateValue, isCellValid } from './helpers'; const generateGridMap = () => { const hash = {}; @@ -30,13 +30,12 @@ const initialSnakesState = { }, 2: { headColor: 'blue', - bodyColor: 'yellow', + bodyColor: 'purple', hash: { '0-11': { x: 0, y: 11 }, '0-12': { x: 0, y: 12 }, - '0-13': { x: 0, y: 13 }, }, - list: ['0-11', '0-12', '0-13'], + list: ['0-11', '0-12'], }, 3: { headColor: 'yellow', @@ -67,6 +66,21 @@ const initialSnakesState = { }, }; +// TODO: Move this logic into a unit test later. + +Object.values(initialSnakesState).forEach((snake) => { + const { hash, list } = snake; + for (const cell of list) { + if (!(cell in hash)) { + throw new Error('Snake initial data is corrupt! list and hash are not in sync.'); + } + const { x, y } = hash[cell]; + if (!isCellValid(x, y)) { + throw new Error(`A cell is outside of the grid! x - ${x}, y - ${y}`); + } + } +}); + const initialFoodState = {}; export { GRID_MAP, initialSnakesState, initialFoodState }; diff --git a/packages/game-client/src/constants.js b/packages/game-client/src/constants.js index 0810668..0b212d2 100644 --- a/packages/game-client/src/constants.js +++ b/packages/game-client/src/constants.js @@ -47,7 +47,7 @@ const FOOD_TICKS = { // if they are unique. areValuesUnique(SNAKE_TICKS); -const DEFAULT_TRACK = SNAKE_TICKS.QUARTER.TYPE; +const DEFAULT_TRACK = SNAKE_TICKS.ONE.TYPE; const FOOD_EFFECTS = { GROW: 'grow', @@ -69,13 +69,23 @@ const FOOD_TYPES = { TYPE: 'FROG', chance: 95, effects: { ...grow(1) }, + color: 'green', + animationClass: 'frog', }, RED_BULL: { TYPE: 'RED_BULL', chance: 5, effects: { ...speed(SNAKE_TICKS.ONE_TENTH.TYPE, 30) }, // Lasts for 30 ticks. + color: 'silver', + animationClass: 'red-bull', + }, + FILLET: { + TYPE: 'FILLET', + chance: 0, + effects: { ...grow(3) }, + color: 'red', + animationClass: 'fillet', }, - FILLET: { TYPE: 'FILLET', chance: 0, effects: { ...grow(3) } }, }; const defaultDirections = { diff --git a/packages/game-client/src/grid.module.css b/packages/game-client/src/grid.module.css index aac0e28..d91f850 100644 --- a/packages/game-client/src/grid.module.css +++ b/packages/game-client/src/grid.module.css @@ -5,142 +5,9 @@ .cell { position: absolute; -} - -:global(*) { - outline: 1px solid black !important; -} - -.food { - box-sizing: border-box; - border: 1px solid black; -} - -.food.frog { - background-color: green; - - /* Enter */ - -webkit-animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; - animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; - - /* Pulsate */ - -webkit-animation: pulsate-fwd 0.5s ease-in-out infinite both; - animation: pulsate-fwd 0.5s ease-in-out infinite both; -} - -.food.fillet { - background-color: #d62828; -} - -.food.red_bull { - -webkit-animation: vibrate 0.3s linear infinite both; - animation: vibrate 0.3s linear infinite both; - background-color: blue; -} - -@-webkit-keyframes scale-in-center { - 0% { - -webkit-transform: scale(0); - transform: scale(0); - opacity: 1; - } - 100% { - -webkit-transform: scale(1); - transform: scale(1); - opacity: 1; - } -} -@keyframes scale-in-center { - 0% { - -webkit-transform: scale(0); - transform: scale(0); - opacity: 1; - } - 100% { - -webkit-transform: scale(1); - transform: scale(1); - opacity: 1; - } -} - -@-webkit-keyframes pulsate-fwd { - 0% { - -webkit-transform: scale(1); - transform: scale(1); - } - 50% { - -webkit-transform: scale(1.1); - transform: scale(1.1); - } - 100% { - -webkit-transform: scale(1); - transform: scale(1); - } -} -@keyframes pulsate-fwd { - 0% { - -webkit-transform: scale(1); - transform: scale(1); - } - 50% { - -webkit-transform: scale(1.1); - transform: scale(1.1); - } - 100% { - -webkit-transform: scale(1); - transform: scale(1); - } -} - -@-webkit-keyframes vibrate { - 0% { - -webkit-transform: translate(0); - transform: translate(0); - } - 20% { - -webkit-transform: translate(-1px, 1px); - transform: translate(-1px, 1px); - } - 40% { - -webkit-transform: translate(-1px, -1px); - transform: translate(-1px, -1px); - } - 60% { - -webkit-transform: translate(1px, 1px); - transform: translate(1px, 1px); - } - 80% { - -webkit-transform: translate(1px, -1px); - transform: translate(1px, -1px); - } - 100% { - -webkit-transform: translate(0); - transform: translate(0); - } -} -@keyframes vibrate { - 0% { - -webkit-transform: translate(0); - transform: translate(0); - } - 20% { - -webkit-transform: translate(-2px, 2px); - transform: translate(-2px, 2px); - } - 40% { - -webkit-transform: translate(-2px, -2px); - transform: translate(-2px, -2px); - } - 60% { - -webkit-transform: translate(2px, 2px); - transform: translate(2px, 2px); - } - 80% { - -webkit-transform: translate(2px, -2px); - transform: translate(2px, -2px); - } - 100% { - -webkit-transform: translate(0); - transform: translate(0); - } + display: flex; + justify-content: center; + align-items: center; + color: white; + font-size: 10px; } diff --git a/packages/game-client/src/hooks/useTicks.js b/packages/game-client/src/hooks/useTicks.js index 2dafa1c..6b218b3 100644 --- a/packages/game-client/src/hooks/useTicks.js +++ b/packages/game-client/src/hooks/useTicks.js @@ -1,7 +1,8 @@ import React, { useEffect, useRef } from 'react'; import { SNAKE_TICKS, FOOD_TICKS, DEFAULT_TRACK } from '../constants'; -const useTicks = ({ moveForward, spawnFood, getSnakeCells, getAllSnakeIds }) => { +const useTicks = ({ moveForward, spawnFood, getSnakeCells, getAllSnakeIds, isGamePaused }) => { + const isGamePausedRef = useRef(isGamePaused); const timersRef = useRef([]); const trackRef = useRef( Object.keys(SNAKE_TICKS).reduce((tracks, tick) => { @@ -48,29 +49,27 @@ const useTicks = ({ moveForward, spawnFood, getSnakeCells, getAllSnakeIds }) => } }; - // For food. + useEffect(() => { + isGamePausedRef.current = isGamePaused; + }, [isGamePaused]); + useEffect(() => { for (const { DURATION: duration } of Object.values(FOOD_TICKS)) { const timer = setInterval(() => { - spawnFood(getSnakeCells); + if (!isGamePausedRef.current) { + spawnFood(getSnakeCells); + } }, duration); timersRef.current.push(timer); } - return () => { - for (const timer of timersRef.current) { - clearInterval(timer); - } - }; - }, []); - - // For snakes. - useEffect(() => { for (const [key, value] of Object.entries(SNAKE_TICKS)) { const { DURATION: duration } = value; const timer = setInterval(() => { - onTick(key); + if (!isGamePausedRef.current) { + onTick(key); + } }, duration); timersRef.current.push(timer); diff --git a/packages/game-client/src/index.css b/packages/game-client/src/index.css index e69de29..c957491 100644 --- a/packages/game-client/src/index.css +++ b/packages/game-client/src/index.css @@ -0,0 +1,7 @@ +body { + margin: 0px; +} + +* { + outline: 1px dashed black !important; +} diff --git a/packages/game-client/src/utils.js b/packages/game-client/src/utils.js index 599e525..6b41da9 100644 --- a/packages/game-client/src/utils.js +++ b/packages/game-client/src/utils.js @@ -23,4 +23,16 @@ const findKeyByValue = (object, value) => { throw new Error("The key you supplied doesn't exist in the hash."); }; -export { generateRandomNumber, findKeyByValue, areValuesUnique }; +const stringToBoolean = (val) => { + if (val === 'true' || val === true) { + return true; + } else if (val === 'false' || val === false) { + return false; + } else if (val === null || val === undefined) { + return null; + } else { + throw new Error(`Invalid string passed! ${val}`); + } +}; + +export { generateRandomNumber, findKeyByValue, areValuesUnique, stringToBoolean };