├── .gitignore
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.css
├── App.test.tsx
├── App.tsx
├── components
│ ├── Food.tsx
│ ├── Ghost.tsx
│ ├── Header.tsx
│ ├── Pacman.tsx
│ └── Scene.tsx
├── context
│ └── GameContext.tsx
├── hooks
│ └── useInterval.ts
├── index.css
├── index.tsx
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
├── styles
│ └── Colors.ts
└── types
│ ├── character.ts
│ ├── color.ts
│ ├── difficulty.ts
│ ├── direction.ts
│ ├── gameStatus.ts
│ └── position.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # pacman-react
2 | Is a game created as a showcase to understand how to use `react + typescript + hooks + react context`.
3 |
4 | The project was boilerplated with create-react-app.
5 |
6 | Play the game [here](https://mbfassnacht.github.io/pacman-react/)!
7 |
8 | [](https://github.com/mbfassnacht/assets/raw/master/images/pacman-react/react-pacman.png)
9 |
10 |
11 | ## INSTALLATION DEPENDENCES
12 | ```javascript
13 | yarn install
14 | ```
15 | ### USAGE
16 | The following commands are available in your project:
17 | ```bash
18 | # Start for development
19 | yarn start
20 |
21 | # Just build the dist version and copy static files
22 | yarn build
23 |
24 | # Run unit tests
25 | yarn test
26 | ```
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pacman-react",
3 | "version": "3.0.0",
4 | "description": "Pacman game created with react",
5 | "author": "mbfassnacht",
6 | "homepage": "https://mbfassnacht.de/pacman-react/",
7 | "dependencies": {
8 | "@testing-library/jest-dom": "^5.16.5",
9 | "@testing-library/react": "^13.4.0",
10 | "@testing-library/user-event": "^13.5.0",
11 | "@types/jest": "^27.5.2",
12 | "@types/node": "^16.18.11",
13 | "@types/react": "^18.0.26",
14 | "@types/react-dom": "^18.0.10",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0",
17 | "react-scripts": "5.0.1",
18 | "react-svg-inline": "^2.1.1",
19 | "styled-components": "^5.3.6",
20 | "typescript": "^4.9.4",
21 | "web-vitals": "^2.1.4"
22 | },
23 | "scripts": {
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": [
31 | "react-app",
32 | "react-app/jest"
33 | ]
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | },
47 | "devDependencies": {
48 | "@types/react-svg-inline": "^2.1.3",
49 | "@types/styled-components": "^5.1.26"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AllTwinkleStars/react_pacman/eca817f9b7741f26dfde4280f26a2c3c04a07077/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
25 | pacman-react
26 |
27 |
28 |
29 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AllTwinkleStars/react_pacman/eca817f9b7741f26dfde4280f26a2c3c04a07077/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AllTwinkleStars/react_pacman/eca817f9b7741f26dfde4280f26a2c3c04a07077/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | render();
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./App.css";
3 | import Header from "./components/Header";
4 | import Scene from "./components/Scene";
5 | import { GameProvider } from "./context/GameContext";
6 |
7 | function App() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | export default App;
19 |
--------------------------------------------------------------------------------
/src/components/Food.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { Position } from "../types/position";
4 | import colors from "../styles/Colors";
5 | import { useGameContext } from "../context/GameContext";
6 | import { GAME_STATUS } from "../types/gameStatus";
7 |
8 | interface StyledFoodProps {
9 | position: Position;
10 | hidden: boolean;
11 | }
12 |
13 | const eatPrecision = 18;
14 |
15 | export type FoodProps = {
16 | name: string;
17 | position: Position;
18 | hidden: boolean;
19 | pacmanSize: number;
20 | };
21 |
22 | const Food = (props: FoodProps) => {
23 | const position = props.position;
24 | const [isHidden, setIsHidden] = React.useState(false);
25 | const { pacmanPosition, setPoints, points, foodAmount, setGameStatus } =
26 | useGameContext();
27 |
28 | function eaten() {
29 | setIsHidden(true);
30 | }
31 |
32 | React.useEffect(() => {
33 | function gameRestarted() {
34 | setIsHidden(false);
35 | }
36 |
37 | document.addEventListener("restart-game", gameRestarted);
38 | return () => document.removeEventListener("restart-game", gameRestarted);
39 | }, []);
40 |
41 | React.useEffect(() => {
42 | if (
43 | !isHidden &&
44 | pacmanPosition.left + (props.pacmanSize - eatPrecision) / 2 >=
45 | position.left &&
46 | pacmanPosition.left - (props.pacmanSize - eatPrecision) / 2 <
47 | position.left &&
48 | pacmanPosition.top + (props.pacmanSize - eatPrecision) / 2 >=
49 | position.top &&
50 | pacmanPosition.top - (props.pacmanSize - eatPrecision) / 2 < position.top
51 | ) {
52 | eaten();
53 | if (foodAmount === points + 1) {
54 | setGameStatus(GAME_STATUS.WON);
55 | }
56 | setPoints(points + 1);
57 | }
58 | }, [pacmanPosition, position]);
59 |
60 | return (
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | const StyledFood = styled.div`
68 | width: 60px;
69 | height: 60px;
70 | position: absolute;
71 | display: ${(props) => (props.hidden ? "none" : "block")};
72 | top: ${(props) => props.position.top}px;
73 | left: ${(props) => props.position.left}px;
74 |
75 | .effective-food {
76 | border-radius: 50px;
77 | width: 10px;
78 | height: 10px;
79 | background-color: ${colors.color2};
80 | margin: 20px;
81 | }
82 | `;
83 |
84 | export default Food;
85 |
--------------------------------------------------------------------------------
/src/components/Ghost.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { Position, ghostStartPosition } from "../types/position";
4 | import { DIRECTION, Direction } from "../types/direction";
5 | import { Character } from "../types/character";
6 | import { useGameContext } from "../context/GameContext";
7 | import { useInterval } from "../hooks/useInterval";
8 | import { COLOR } from "../types/color";
9 | import { GAME_STATUS } from "../types/gameStatus";
10 |
11 | interface StyledGhostProps {
12 | position: Position;
13 | color: string;
14 | }
15 |
16 | const GhostIcon = () => (
17 |
39 | );
40 |
41 | const Ghost = (props: Character) => {
42 | const { pacmanPosition, setGameStatus, gameStatus } = useGameContext();
43 | const [position, setPosition] = React.useState(ghostStartPosition);
44 | const [direction, setDirection] = React.useState(DIRECTION.LEFT);
45 | const [color, setColor] = React.useState(props.color!);
46 | const [changeDirectionWaitingTime, setChangeDirectionWaitingTime] =
47 | React.useState(0);
48 | useInterval(move, 100);
49 |
50 | React.useEffect(() => {
51 | document.addEventListener("restart-game", gameRestarted);
52 | return () => document.removeEventListener("restart-game", gameRestarted);
53 | }, []);
54 |
55 | function gameRestarted() {
56 | setColor(props.color);
57 | setPosition(ghostStartPosition);
58 | }
59 |
60 | function move() {
61 | if (gameStatus === GAME_STATUS.IN_PROGRESS) {
62 | if (changeDirectionWaitingTime > 4) {
63 | const movement = Math.floor(Math.random() * 4) + 0;
64 | const arrayOfMovement: Direction[] = [
65 | DIRECTION.LEFT,
66 | DIRECTION.UP,
67 | DIRECTION.DOWN,
68 | DIRECTION.RIGHT,
69 | ];
70 | setDirection(arrayOfMovement[movement]);
71 | setChangeDirectionWaitingTime(0);
72 | } else {
73 | setChangeDirectionWaitingTime((oldChangeDirectionWaitingTime) => {
74 | return oldChangeDirectionWaitingTime + 1;
75 | });
76 | }
77 |
78 | setPosition((oldPosition: Position) => {
79 | const currentLeft = position.left;
80 | const currentTop = position.top;
81 | let newPosition: Position = { top: 0, left: 0 };
82 |
83 | switch (direction) {
84 | case DIRECTION.LEFT:
85 | newPosition = {
86 | top: currentTop,
87 | left: Math.max(currentLeft - props.velocity, 0),
88 | };
89 | break;
90 | case DIRECTION.UP:
91 | newPosition = {
92 | top: Math.max(currentTop - props.velocity, 0),
93 | left: currentLeft,
94 | };
95 | break;
96 | case DIRECTION.RIGHT:
97 | newPosition = {
98 | top: currentTop,
99 | left: Math.min(
100 | currentLeft + props.velocity,
101 | window.innerWidth - props.border - props.size
102 | ),
103 | };
104 | break;
105 |
106 | default:
107 | newPosition = {
108 | top: Math.min(
109 | currentTop + props.velocity,
110 | window.innerHeight -
111 | props.size -
112 | props.border -
113 | props.topScoreBoard
114 | ),
115 | left: currentLeft,
116 | };
117 | }
118 | if (
119 | pacmanPosition.left > newPosition.left - props.size &&
120 | pacmanPosition.left < newPosition.left + props.size &&
121 | pacmanPosition.top > newPosition.top - props.size &&
122 | pacmanPosition.top < newPosition.top + props.size
123 | ) {
124 | setGameStatus(GAME_STATUS.LOST);
125 | }
126 |
127 | return newPosition;
128 | });
129 | }
130 | if (
131 | gameStatus !== GAME_STATUS.PAUSED &&
132 | gameStatus !== GAME_STATUS.IN_PROGRESS
133 | ) {
134 | setColor(COLOR.GHOST_DEAD);
135 | }
136 | }
137 |
138 | return (
139 |
140 |
141 |
142 | );
143 | };
144 |
145 | const StyledGhost = styled.div`
146 | width: 60px;
147 | height: 63px;
148 | position: absolute;
149 | top: ${(props) => props.position.top}px;
150 | left: ${(props) => props.position.left}px;
151 |
152 | svg {
153 | fill: ${(props) => {
154 | switch (props.color) {
155 | case COLOR.RED:
156 | return COLOR.RED;
157 | case COLOR.BLUE:
158 | return COLOR.BLUE;
159 | case COLOR.ORANGE:
160 | return COLOR.ORANGE;
161 | case COLOR.GREEN:
162 | return COLOR.GREEN;
163 | default:
164 | return COLOR.GHOST_DEAD;
165 | }
166 | }};
167 | }
168 | `;
169 |
170 | export default Ghost;
171 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import colors from "../styles/Colors";
3 | import styled from "styled-components";
4 | import { useGameContext } from "../context/GameContext";
5 | import { useInterval } from "../hooks/useInterval";
6 | import { GAME_STATUS } from "../types/gameStatus";
7 |
8 | const Header = () => {
9 | const { points, foodAmount, gameStatus } = useGameContext();
10 | const [timeElapsed, setTimeElapsed] = React.useState(0);
11 |
12 | React.useEffect(() => {
13 | document.addEventListener("restart-game", gameRestarted);
14 | return () => document.removeEventListener("restart-game", gameRestarted);
15 | }, []);
16 |
17 | function gameRestarted() {
18 | setTimeElapsed(0);
19 | }
20 |
21 | useInterval(() => {
22 | if (gameStatus === GAME_STATUS.IN_PROGRESS) {
23 | setTimeElapsed((previuosTime) => {
24 | return previuosTime + 1;
25 | });
26 | }
27 | }, 1000);
28 |
29 | return (
30 |
31 | PACMAN
32 |
33 |
34 | Score:
35 |
36 | {points} / {foodAmount}
37 |
38 |
39 |
40 | Time elapsed:
41 | {timeElapsed}
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | const StyledHeader = styled.div`
49 | height: 100px;
50 | background-color: ${colors.color3};
51 | color: ${colors.color2};
52 | display: flex;
53 | padding-left: 10px;
54 | padding-right: 10px;
55 | justify-content: space-between;
56 |
57 | .title {
58 | font-size: 80px;
59 | text-align: left;
60 | margin-top: 10px;
61 | }
62 |
63 | .score {
64 | font-size: 34px;
65 | text-align: right;
66 | margin-top: 10px;
67 | }
68 | `;
69 |
70 | export default Header;
71 |
--------------------------------------------------------------------------------
/src/components/Pacman.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled, { keyframes } from "styled-components";
3 | import { Position } from "../types/position";
4 | import { ARROW, DIRECTION, Direction } from "../types/direction";
5 | import { Character } from "../types/character";
6 | import { useGameContext } from "../context/GameContext";
7 | import { useInterval } from "../hooks/useInterval";
8 | import { COLOR } from "../types/color";
9 | import { GAME_STATUS } from "../types/gameStatus";
10 | import colors from "../styles/Colors";
11 |
12 | interface StyledPacmanProps {
13 | direction: Direction;
14 | position: Position;
15 | isAlive: boolean;
16 | }
17 |
18 | type PacmanMouthProps = {
19 | moving: boolean;
20 | };
21 |
22 | const Pacman = (props: Character) => {
23 | const {
24 | pacmanPosition: position,
25 | setPacmanPosition,
26 | gameStatus,
27 | } = useGameContext();
28 | const [direction, setDirection] = React.useState(DIRECTION.RIGHT);
29 | const [color, setColor] = React.useState(props.color);
30 | useInterval(move, 100);
31 |
32 | React.useEffect(() => {
33 | function rotate(keypressed: number) {
34 | switch (keypressed) {
35 | case ARROW.LEFT:
36 | setDirection(DIRECTION.LEFT);
37 | break;
38 | case ARROW.UP:
39 | setDirection(DIRECTION.UP);
40 | break;
41 | case ARROW.RIGHT:
42 | setDirection(DIRECTION.RIGHT);
43 | break;
44 | default:
45 | setDirection(DIRECTION.DOWN);
46 | }
47 | }
48 |
49 | function handleKeyDown(e: any) {
50 | const arrows = [ARROW.LEFT, ARROW.UP, ARROW.RIGHT, ARROW.DOWN];
51 |
52 | if (arrows.indexOf(e.keyCode) >= 0) {
53 | rotate(e.keyCode);
54 | }
55 | }
56 |
57 | document.addEventListener("keydown", handleKeyDown, false);
58 | document.addEventListener("restart-game", gameRestarted);
59 |
60 | return () => {
61 | document.removeEventListener("restart-game", gameRestarted);
62 | document.removeEventListener("keydown", handleKeyDown);
63 | };
64 | }, []);
65 |
66 | function gameRestarted() {
67 | setColor(props.color);
68 | }
69 |
70 | function move() {
71 | if (gameStatus === GAME_STATUS.IN_PROGRESS) {
72 | const currentLeft = position.left;
73 | const currentTop = position.top;
74 | let newPosition: Position = { top: 0, left: 0 };
75 | switch (direction) {
76 | case DIRECTION.LEFT:
77 | newPosition = {
78 | top: currentTop,
79 | left: Math.max(currentLeft - props.velocity, 0),
80 | };
81 | break;
82 | case DIRECTION.UP:
83 | newPosition = {
84 | top: Math.max(currentTop - props.velocity, 0),
85 | left: currentLeft,
86 | };
87 | break;
88 | case DIRECTION.RIGHT:
89 | newPosition = {
90 | top: currentTop,
91 | left: Math.min(
92 | currentLeft + props.velocity,
93 | window.innerWidth - props.border - props.size
94 | ),
95 | };
96 | break;
97 |
98 | default:
99 | newPosition = {
100 | top: Math.min(
101 | currentTop + props.velocity,
102 | window.innerHeight -
103 | props.size -
104 | props.border -
105 | props.topScoreBoard
106 | ),
107 | left: currentLeft,
108 | };
109 | }
110 | setPacmanPosition(newPosition);
111 | }
112 | if (gameStatus === GAME_STATUS.LOST) {
113 | setColor(COLOR.PACMAN_DEAD);
114 | }
115 | }
116 |
117 | return (
118 |
125 |
126 |
127 |
128 | );
129 | };
130 |
131 | const eat = keyframes`
132 | 0% {
133 | clip-path: polygon(100% 74%, 44% 48%, 100% 21%);
134 | }
135 | 25% {
136 | clip-path: polygon(100% 60%, 44% 48%, 100% 35%);
137 | }
138 | 50% {
139 | clip-path: polygon(100% 50%, 44% 48%, 100% 60%);
140 | }
141 | 75% {
142 | clip-path: polygon(100% 59%, 44% 48%, 100% 35%);
143 | }
144 | 100% {
145 | clip-path: polygon(100% 74%, 44% 48%, 100% 21%);
146 | }
147 | `;
148 |
149 | const StyledPacman = styled.div`
150 | width: 60px;
151 | height: 63px;
152 | position: absolute;
153 | top: ${(props) => props.position.top}px;
154 | left: ${(props) => props.position.left}px;
155 | transform: ${(props) => {
156 | switch (props.direction) {
157 | case DIRECTION.LEFT:
158 | return "rotateY(180deg)";
159 | case DIRECTION.UP:
160 | return "rotate(-90deg)";
161 | case DIRECTION.DOWN:
162 | return "rotate(90deg)";
163 | default:
164 | return "rotate(0deg)";
165 | }
166 | }};
167 | width: 60px;
168 | height: 60px;
169 | border-radius: 50%;
170 | background: ${(props) => (props.isAlive ? colors.color2 : "white")};
171 | position: relative;
172 | `;
173 |
174 | const PacmanEye = styled.div`
175 | position: absolute;
176 | width: 8px;
177 | height: 8px;
178 | border-radius: 50%;
179 | top: 10px;
180 | right: 26px;
181 | background: ${colors.color1};
182 | `;
183 |
184 | const PacmanMouth = styled.div`
185 | animation-name: ${eat};
186 | animation-duration: 0.7s;
187 | animation-iteration-count: ${(props) =>
188 | props.moving ? "infinite" : "initial"};
189 | background: ${colors.color1};
190 | position: absolute;
191 | width: 100%;
192 | height: 100%;
193 | clip-path: polygon(100% 74%, 44% 48%, 100% 21%);
194 | `;
195 |
196 | export default Pacman;
197 |
--------------------------------------------------------------------------------
/src/components/Scene.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Pacman from "./Pacman";
3 | import Ghost from "./Ghost";
4 | import Food from "./Food";
5 | import styled from "styled-components";
6 | import colors from "../styles/Colors";
7 | import { useGameContext } from "../context/GameContext";
8 | import { GAME_STATUS } from "../types/gameStatus";
9 | import { COLOR } from "../types/color";
10 | import { DIFFICULTY, Difficulty } from "../types/difficulty";
11 |
12 | type SceneProps = {
13 | foodSize: number;
14 | border: number;
15 | topScoreBoard: number;
16 | };
17 |
18 | const pacmanSize = 60;
19 | const pacmanVelocity = 30;
20 | const ghostSize = 60;
21 | const topScoreBoardHeight = 100;
22 |
23 | const generateFoodMatrix = (props: SceneProps, amountOfFood: number) => {
24 | let currentTop = 0;
25 | let currentLeft = 0;
26 | const foods = [];
27 |
28 | for (let i = 0; i <= amountOfFood; i++) {
29 | if (currentLeft + props.foodSize >= window.innerWidth - props.border) {
30 | currentTop += props.foodSize;
31 | currentLeft = 0;
32 | }
33 | if (
34 | currentTop + props.foodSize >=
35 | window.innerHeight - props.border - props.topScoreBoard
36 | ) {
37 | break;
38 | }
39 | const position = { left: currentLeft, top: currentTop };
40 | currentLeft = currentLeft + props.foodSize;
41 | foods.push(
42 |
49 | );
50 | }
51 | return foods;
52 | };
53 |
54 | const Scene = (props: SceneProps) => {
55 | const {
56 | setFoodAmount,
57 | restartGame,
58 | setDifficulty,
59 | setGameStatus,
60 | foodAmount,
61 | gameStatus,
62 | difficulty,
63 | } = useGameContext();
64 |
65 | const [ghostVelocity, setGhostVelocity] = React.useState(20);
66 |
67 | React.useEffect(() => {
68 | if (difficulty === DIFFICULTY.EASY) {
69 | setGhostVelocity(15);
70 | }
71 | if (difficulty === DIFFICULTY.MEDIUM) {
72 | setGhostVelocity(20);
73 | }
74 | if (difficulty === DIFFICULTY.ADVANCED) {
75 | setGhostVelocity(30);
76 | }
77 | }, [difficulty]);
78 |
79 | React.useEffect(() => {
80 | const amountOfFood =
81 | Math.floor((window.innerWidth - props.border) / props.foodSize) *
82 | Math.floor(
83 | (window.innerHeight - props.border - props.topScoreBoard) /
84 | props.foodSize
85 | );
86 |
87 | setFoodAmount(amountOfFood);
88 | }, []);
89 |
90 | return (
91 |
92 | {gameStatus !== GAME_STATUS.IN_PROGRESS &&
93 | gameStatus !== GAME_STATUS.PAUSED && (
94 |
95 | {gameStatus === GAME_STATUS.WON ? (
96 |
97 |
98 | Congratulations :)
99 |
100 | restartGame()}>
101 | Play again
102 |
103 |
104 | ) : (
105 |
106 |
107 | GAME OVER :(
108 |
109 | restartGame()}>
110 | Try Again
111 |
112 |
113 | )}
114 |
115 | )}
116 | {gameStatus === GAME_STATUS.PAUSED && (
117 |
118 |
119 |
120 | Set Difficulty
121 |
122 |
123 |
131 |
132 |
133 | setGameStatus(GAME_STATUS.IN_PROGRESS)}
135 | >
136 | Play!
137 |
138 |
139 |
140 | )}
141 | {generateFoodMatrix(props, foodAmount)}
142 |
150 |
158 |
166 | {difficulty !== DIFFICULTY.EASY && (
167 |
175 | )}
176 | {difficulty === DIFFICULTY.ADVANCED && (
177 |
185 | )}
186 |
187 | );
188 | };
189 |
190 | const CenterContainer = styled.div`
191 | position: absolute;
192 | margin: 0 auto;
193 | left: 50%;
194 | top: 50%;
195 | transform: translate(-50%, -50%);
196 | text-align: center;
197 | z-index: 9999;
198 | background-color: ${colors.color2};
199 | color: ${colors.color3};
200 | padding: 20px;
201 | button {
202 | cursor: pointer;
203 | }
204 | `;
205 |
206 | const OverlayContent = styled.div`
207 | position: absolute;
208 | width: 100%;
209 | height: 100%;
210 | background-color: rgba(0, 0, 0, 0.9);
211 | font-size: 40px;
212 | `;
213 |
214 | const StyledScene = styled.div`
215 | --container-width: 100vw - 20px;
216 | height: calc(100vh - 120px);
217 | width: calc(var(--container-width));
218 | background-color: ${colors.color1};
219 | position: relative;
220 | border: 10px ${colors.color3} solid;
221 | `;
222 |
223 | const StyledButton = styled.button`
224 | padding: 8px 16px;
225 | font-size: 24px;
226 | background-color: ${colors.color1};
227 | color: ${colors.color2};
228 | border: 1px ${colors.color3} solid;
229 | cursor: pointer;
230 |
231 | :hover {
232 | background-color: ${colors.color2};
233 | color: ${colors.color1};
234 | }
235 | `;
236 |
237 | export default Scene;
238 |
--------------------------------------------------------------------------------
/src/context/GameContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, ReactNode, useState } from "react";
2 | import { Position, pacmanStartPosition } from "../types/position";
3 | import { GAME_STATUS, GameStatus } from "../types/gameStatus";
4 | import { DIFFICULTY, Difficulty } from "../types/difficulty";
5 |
6 | type GameContextType = {
7 | foodAmount: number;
8 | gameStatus: GameStatus;
9 | pacmanPosition: Position;
10 | points: number;
11 | difficulty: Difficulty;
12 | setFoodAmount: (foodAmount: number) => void;
13 | setPacmanPosition: (position: Position) => void;
14 | setPoints: (points: number) => void;
15 | setDifficulty: (difficulty: Difficulty) => void;
16 | setGameStatus: (gameStatus: GameStatus) => void;
17 | restartGame: () => void;
18 | };
19 |
20 | const contextDefaultValues: GameContextType = {
21 | foodAmount: 0,
22 | gameStatus: GAME_STATUS.PAUSED,
23 | pacmanPosition: { top: 0, left: 0 },
24 | points: 0,
25 | difficulty: DIFFICULTY.MEDIUM,
26 | setFoodAmount: () => {},
27 | setPacmanPosition: () => {},
28 | setPoints: () => {},
29 | setGameStatus: () => {},
30 | restartGame: () => {},
31 | setDifficulty: () => {},
32 | };
33 |
34 | const GameContext = createContext(contextDefaultValues);
35 |
36 | export function useGameContext() {
37 | return useContext(GameContext);
38 | }
39 |
40 | type Props = {
41 | children: ReactNode;
42 | };
43 |
44 | export function GameProvider({ children }: Props) {
45 | const [pacmanPosition, _setPacmanPosition] = useState(
46 | contextDefaultValues.pacmanPosition
47 | );
48 | const [points, _setPoints] = useState(contextDefaultValues.points);
49 | const [foodAmount, _setFoodAmount] = useState(
50 | contextDefaultValues.foodAmount
51 | );
52 |
53 | const [difficulty, _setDifficulty] = useState(
54 | contextDefaultValues.difficulty
55 | );
56 |
57 | const [gameStatus, _setGameStatus] = useState(
58 | contextDefaultValues.gameStatus
59 | );
60 |
61 | const setFoodAmount = (foodAmount: number) => {
62 | _setFoodAmount(foodAmount);
63 | };
64 |
65 | const setGameStatus = (gameStatus: GameStatus) => {
66 | _setGameStatus(gameStatus);
67 | };
68 |
69 | const setPacmanPosition = (pacmanPosition: Position) => {
70 | _setPacmanPosition(pacmanPosition);
71 | };
72 | const setPoints = (points: number) => {
73 | _setPoints(points);
74 | };
75 |
76 | const setDifficulty = (difficulty: Difficulty) => {
77 | _setDifficulty(difficulty);
78 | };
79 |
80 | const restartGame = () => {
81 | _setPoints(0);
82 | _setGameStatus(GAME_STATUS.IN_PROGRESS);
83 | _setPacmanPosition(pacmanStartPosition);
84 |
85 | const event = new Event("restart-game");
86 | document.dispatchEvent(event);
87 | };
88 |
89 | const value = {
90 | foodAmount,
91 | gameStatus,
92 | pacmanPosition,
93 | points,
94 | difficulty,
95 | restartGame,
96 | setFoodAmount,
97 | setGameStatus,
98 | setPacmanPosition,
99 | setPoints,
100 | setDifficulty,
101 | };
102 |
103 | return {children};
104 | }
105 |
--------------------------------------------------------------------------------
/src/hooks/useInterval.ts:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 |
3 | export function useInterval(callback: Function, delay: number) {
4 | const savedCallback: any = useRef();
5 |
6 | // Remember the latest callback.
7 | useEffect(() => {
8 | savedCallback.current = callback;
9 | }, [callback]);
10 |
11 | // Set up the interval.
12 | useEffect(() => {
13 | function tick() {
14 | savedCallback.current();
15 | }
16 | if (delay !== null) {
17 | let id = setInterval(tick, delay);
18 | return () => clearInterval(id);
19 | }
20 | }, [delay]);
21 | }
22 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | const root = ReactDOM.createRoot(
8 | document.getElementById('root') as HTMLElement
9 | );
10 | root.render(
11 |
12 |
13 |
14 | );
15 |
16 | // If you want to start measuring performance in your app, pass a function
17 | // to log results (for example: reportWebVitals(console.log))
18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
19 | reportWebVitals();
20 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/styles/Colors.ts:
--------------------------------------------------------------------------------
1 | const colors = {
2 | color1: "black",
3 | color2: "rgb(255, 233, 80)",
4 | color3: "rgb(7, 10, 45)",
5 | color4: "#bb0a30",
6 | };
7 |
8 | export default colors;
9 |
--------------------------------------------------------------------------------
/src/types/character.ts:
--------------------------------------------------------------------------------
1 | export type Character = {
2 | velocity: number;
3 | size: number;
4 | border: number;
5 | topScoreBoard: number;
6 | color: string;
7 | name: string;
8 | };
9 |
--------------------------------------------------------------------------------
/src/types/color.ts:
--------------------------------------------------------------------------------
1 | export enum COLOR {
2 | PACMAN_DEAD = "white",
3 | GHOST_DEAD = "white",
4 | RED = "red",
5 | BLUE = "blue",
6 | ORANGE = "orange",
7 | GREEN = "green",
8 | }
9 |
--------------------------------------------------------------------------------
/src/types/difficulty.ts:
--------------------------------------------------------------------------------
1 | export enum DIFFICULTY {
2 | EASY = "easy",
3 | MEDIUM = "medium",
4 | ADVANCED = "advanced",
5 | }
6 |
7 | export type Difficulty =
8 | | DIFFICULTY.EASY
9 | | DIFFICULTY.MEDIUM
10 | | DIFFICULTY.ADVANCED;
11 |
--------------------------------------------------------------------------------
/src/types/direction.ts:
--------------------------------------------------------------------------------
1 | export enum DIRECTION {
2 | LEFT = "left",
3 | RIGHT = "right",
4 | UP = "up",
5 | DOWN = "down",
6 | }
7 |
8 | export type Direction =
9 | | DIRECTION.LEFT
10 | | DIRECTION.RIGHT
11 | | DIRECTION.UP
12 | | DIRECTION.DOWN;
13 |
14 | export enum ARROW {
15 | LEFT = 37,
16 | RIGHT = 39,
17 | UP = 38,
18 | DOWN = 40,
19 | }
20 |
--------------------------------------------------------------------------------
/src/types/gameStatus.ts:
--------------------------------------------------------------------------------
1 | export enum GAME_STATUS {
2 | IN_PROGRESS = "in_progress",
3 | PAUSED = "paused",
4 | LOST = "lost",
5 | WON = "won",
6 | }
7 |
8 | export type GameStatus =
9 | | GAME_STATUS.IN_PROGRESS
10 | | GAME_STATUS.LOST
11 | | GAME_STATUS.WON
12 | | GAME_STATUS.PAUSED;
13 |
--------------------------------------------------------------------------------
/src/types/position.ts:
--------------------------------------------------------------------------------
1 | export type Position = {
2 | top: number;
3 | left: number;
4 | };
5 |
6 | export const ghostStartPosition: Position = {
7 | top: 300,
8 | left: 300,
9 | };
10 |
11 | export const pacmanStartPosition: Position = {
12 | top: 0,
13 | left: 0,
14 | };
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------