├── Client ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.tsx │ ├── components │ │ ├── Cell.tsx │ │ ├── DragDrop.tsx │ │ ├── GridField.tsx │ │ ├── Header.tsx │ │ └── Tile.tsx │ ├── helpers │ │ └── index.tsx │ ├── index.css │ ├── index.tsx │ ├── interfaces │ │ └── index.tsx │ ├── logo.svg │ ├── pages │ │ └── Game.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ └── setupTests.ts └── tsconfig.json ├── ReadMe.txt └── Server ├── .gitignore ├── index.js ├── package.json └── utils.js /Client/.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 | -------------------------------------------------------------------------------- /Client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /Client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rgb_alchemy_game", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.10", 11 | "@types/react": "^18.0.26", 12 | "@types/react-dom": "^18.0.9", 13 | "@types/styled-components": "^5.1.26", 14 | "axios": "^1.2.1", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-scripts": "5.0.1", 18 | "react-tooltip": "^4.2.21", 19 | "styled-components": "^5.3.6", 20 | "sweetalert2": "^11.6.15", 21 | "typescript": "^4.9.4", 22 | "web-vitals": "^2.1.4" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crypto27dev/RGB_Alchemy_Game/4ce3dc276e41553c698fc093d45eb7e5219dd003/Client/public/favicon.ico -------------------------------------------------------------------------------- /Client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crypto27dev/RGB_Alchemy_Game/4ce3dc276e41553c698fc093d45eb7e5219dd003/Client/public/logo192.png -------------------------------------------------------------------------------- /Client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crypto27dev/RGB_Alchemy_Game/4ce3dc276e41553c698fc093d45eb7e5219dd003/Client/public/logo512.png -------------------------------------------------------------------------------- /Client/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 | -------------------------------------------------------------------------------- /Client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /Client/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 | -------------------------------------------------------------------------------- /Client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Game from './pages/Game'; 3 | import './App.css'; 4 | 5 | function App() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default App; -------------------------------------------------------------------------------- /Client/src/components/Cell.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | import Tile from "./Tile"; 4 | import DragDrop from "./DragDrop"; 5 | import { IGameStatus, ICellType, ICell } from "../interfaces"; 6 | 7 | interface ICellProps { 8 | cell: ICell; 9 | gameStatus?: IGameStatus; 10 | onSourceClick: (cellId: string) => void; 11 | onCellDrop: (e: DragEvent, cellId: string) => void; 12 | } 13 | 14 | const Empty = styled.div` 15 | display: inline-block; 16 | width: 28px; 17 | height: 28px; 18 | background-color: transparent; 19 | `; 20 | 21 | const Cell: FC = (props) => { 22 | switch (props.cell.type) { 23 | case ICellType.Tile: 24 | return ; 25 | case ICellType.Source: 26 | return ( 27 | 33 | ); 34 | default: 35 | return ; 36 | } 37 | }; 38 | 39 | export default Cell; 40 | -------------------------------------------------------------------------------- /Client/src/components/DragDrop.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | import { IGameStatus, ICell } from "../interfaces"; 4 | 5 | interface IDragDropProps { 6 | cell: ICell; 7 | gameStatus?: IGameStatus; 8 | onSourceClick: (cellId: string) => void; 9 | onCellDrop: (e: DragEvent, cellId: string) => void; 10 | } 11 | 12 | interface IDragDrop { 13 | $color?: number[]; 14 | $clickable?: boolean; 15 | } 16 | 17 | const DragContainer = styled.div` 18 | display: inline-block; 19 | width: 26px; 20 | height: 26px; 21 | border-radius: 50%; 22 | border: 2px solid rgb(200, 200, 200); 23 | margin: 1px; 24 | ${(props: IDragDrop): string => { 25 | return ` 26 | background-color: rgb(${props.$color?.[0] || 0}, ${ 27 | props.$color?.[1] || 0 28 | }, ${props.$color?.[2] || 0}); 29 | `; 30 | }}; 31 | ${(props: IDragDrop): string => { 32 | return props.$clickable ? "cursor: pointer;" : ""; 33 | }} 34 | `; 35 | 36 | const DragDrop: FC = (props) => { 37 | const handleSourceClick = () => { 38 | props.onSourceClick(props.cell.id); 39 | }; 40 | 41 | const handleDragOver = (e: DragEvent) => { 42 | e.preventDefault(); 43 | }; 44 | 45 | const handleDrop = (e: DragEvent) => { 46 | props.onCellDrop(e, props.cell.id); 47 | }; 48 | 49 | let additionalProps = {}; 50 | if (props.cell.isDnDEnabled) { 51 | additionalProps = { 52 | onDragOver: handleDragOver, 53 | onDrop: handleDrop, 54 | }; 55 | } 56 | 57 | return ( 58 | 64 | ); 65 | }; 66 | 67 | export default DragDrop; -------------------------------------------------------------------------------- /Client/src/components/GridField.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import styled from "styled-components"; 3 | import Cell from "./Cell"; 4 | import { IData, ICell } from '../interfaces'; 5 | 6 | interface IFieldProps { 7 | data: IData; 8 | field: ICell[][]; 9 | onSourceClick: (cellId: string) => void; 10 | onCellDrop: (e: DragEvent, cellId: string) => void; 11 | } 12 | 13 | const GridContainer = styled.div<{ $columnsNumber: number }>` 14 | margin: 0 20px; 15 | display: inline-grid; 16 | ${(props): string => { 17 | return ` 18 | grid-template-columns: repeat(${props.$columnsNumber}, 1fr); 19 | `; 20 | }}; 21 | gap: 1px; 22 | `; 23 | 24 | const GridItem = styled.div` 25 | width: 28px; 26 | height: 28px; 27 | `; 28 | 29 | const GridField: FC = (props) => { 30 | return ( 31 | (typeof props.data.initial?.width !== "undefined" && ( 32 | 0 ? props.data.initial.width + 2 : 0 35 | } 36 | > 37 | {Array.isArray(props.field) && 38 | props.field.map((row) => { 39 | return row.map((cell) => { 40 | return ( 41 | 42 | 48 | 49 | ); 50 | }); 51 | })} 52 | 53 | )) || 54 | null 55 | ); 56 | }; 57 | 58 | export default GridField; -------------------------------------------------------------------------------- /Client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | import Tile from "./Tile"; 4 | import { ICellType, ICell, DEFAULT_BORDER_COLOR, IData } from "../interfaces"; 5 | 6 | interface IInfoBoxProps { 7 | data: IData; 8 | delta: number; 9 | } 10 | 11 | const Header = styled.div` 12 | margin: 20px; 13 | `; 14 | 15 | const InfoRow = styled.div` 16 | margin-bottom: 15px; 17 | `; 18 | 19 | const TargetRow = styled.div` 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | line-height: 20px; 24 | margin-bottom: 10px; 25 | `; 26 | 27 | const InfoBox: FC = (props) => { 28 | const targetCell: ICell = { 29 | id: "0", 30 | color: props.data.initial?.target || [0, 0, 0], 31 | borderColor: DEFAULT_BORDER_COLOR, 32 | type: ICellType.Empty, 33 | isDnDEnabled: false, 34 | }; 35 | 36 | const closestCell: ICell = { 37 | id: "1", 38 | color: props.data.game?.closestColor || [0, 0, 0], 39 | borderColor: DEFAULT_BORDER_COLOR, 40 | type: ICellType.Empty, 41 | isDnDEnabled: false, 42 | }; 43 | 44 | return ( 45 |
46 | 47 |

RGB Alchemy

48 |
49 | User ID: {props.data.initial?.userId} 50 | 51 | Moves left:{" "} 52 | {(props.data.initial?.maxMoves as number) - 53 | (props.data.game?.stepCount as number)} 54 | 55 | Target color:   56 | Closest color:   57 | Δ= {props.delta.toFixed(2)}% 58 |
59 | ); 60 | }; 61 | 62 | export default InfoBox; -------------------------------------------------------------------------------- /Client/src/components/Tile.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | import { ITileProps } from "../interfaces"; 4 | 5 | interface ITile { 6 | $color?: number[]; 7 | $borderColor?: number[]; 8 | $isDraggable?: boolean; 9 | } 10 | 11 | const TileBody = styled.div`Body 12 | display: inline-block; 13 | width: 26px; 14 | height: 26px; 15 | border-radius: 4px; 16 | margin: 1px; 17 | ${(props: ITile): string => { 18 | return ` 19 | background-color: rgb(${props.$color?.[0] || 0}, ${props.$color?.[1] || 0 20 | }, ${props.$color?.[2] || 0}); 21 | `; 22 | }}; 23 | border-style: solid; 24 | border-width: 2px; 25 | ${(props: ITile): string => { 26 | return ` 27 | border-color: rgb(${props.$borderColor?.[0] || 0}, ${props.$borderColor?.[1] || 0 28 | }, ${props.$borderColor?.[2] || 0}); 29 | `; 30 | }}; 31 | ${(props: ITile): string => { 32 | return props.$isDraggable ? "cursor: pointer;" : ""; 33 | }}; 34 | `; 35 | 36 | const Tile: FC = (props) => { 37 | const handleDragStart = (e: DragEvent) => { 38 | e.dataTransfer?.setData("id", props.cell.id); 39 | }; 40 | 41 | let additionalProps = {}; 42 | if (props.cell.isDnDEnabled) { 43 | additionalProps = { 44 | onDragStart: handleDragStart, 45 | }; 46 | } 47 | 48 | return ( 49 | <> 50 | 52 | colorComponent.toFixed(0) 53 | )} 54 | {...additionalProps} 55 | draggable={props.cell.isDnDEnabled} 56 | $isDraggable={props.cell.isDnDEnabled} 57 | $color={props.cell.color} 58 | $borderColor={props.cell.borderColor} 59 | /> 60 | 61 | ); 62 | }; 63 | 64 | export default Tile; -------------------------------------------------------------------------------- /Client/src/helpers/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IFetchedData, 3 | ICellType, 4 | ICell, 5 | DEFAULT_BORDER_COLOR, 6 | IData 7 | } from "../interfaces"; 8 | 9 | export const enum sourcePosition { 10 | Top, 11 | Right, 12 | Bottom, 13 | Left, 14 | } 15 | 16 | export const generateInitialField = (initialData: IFetchedData): ICell[][] => { 17 | const initialField: ICell[][] = []; 18 | const h = initialData.height; 19 | const w = initialData.width; 20 | let type: ICellType; 21 | 22 | if (h === 0 || w === 0) { 23 | // not initialized 24 | return []; 25 | } 26 | 27 | for (let y = 0; y < h + 2; y++) { 28 | // + 2 - to add source circles 29 | initialField[y] = []; 30 | 31 | for (let x = 0; x < w + 2; x++) { 32 | // + 2 - to add source circles 33 | if (x === 0 || y === 0 || x > w || y > h) { 34 | // Find empty corner Sources 35 | if ( 36 | (x === 0 && y === 0) || 37 | (x === 0 && y === h + 1) || 38 | (x === w + 1 && y === 0) || 39 | (x === w + 1 && y === h + 1) 40 | ) { 41 | type = ICellType.Empty; 42 | } else { 43 | type = ICellType.Source; 44 | } 45 | } else { 46 | type = ICellType.Tile; 47 | } 48 | 49 | let cell: ICell = { 50 | id: x + "," + y, 51 | color: [0, 0, 0], 52 | borderColor: DEFAULT_BORDER_COLOR, 53 | type: type, 54 | isDnDEnabled: false, 55 | }; 56 | 57 | initialField[y][x] = cell; 58 | } 59 | } 60 | 61 | return initialField; 62 | }; 63 | 64 | export const getXFromCellId = (cellId: string) => { 65 | const coords = cellId.split(","); 66 | return Number(coords[0]); 67 | }; 68 | 69 | export const getYFromCellId = (cellId: string) => { 70 | const coords = cellId.split(","); 71 | return Number(coords[1]); 72 | }; 73 | 74 | export const getSourcePosition = (sourceCellId: string, data: IData) => { 75 | const sourceX = getXFromCellId(sourceCellId); 76 | const sourceY = getYFromCellId(sourceCellId); 77 | let position; 78 | 79 | if (sourceX === 0) { 80 | position = sourcePosition.Left; 81 | } else if (sourceX === (data.initial?.width as number) + 1) { 82 | position = sourcePosition.Right; 83 | } else if (sourceY === 0) { 84 | position = sourcePosition.Top; 85 | } else { 86 | position = sourcePosition.Bottom; 87 | } 88 | 89 | return position; 90 | }; 91 | 92 | export const getCalculatedTileColor = ( 93 | color: number[], 94 | distance: number, 95 | dimension: number 96 | ): number[] => { 97 | const k = (dimension + 1 - distance) / (dimension + 1); 98 | 99 | return color.map((colorComponent) => colorComponent * k); 100 | }; 101 | 102 | export const getFieldWithUpdatedTilesLine = ( 103 | data: IData, 104 | sourceCellId: string, 105 | updatedField: ICell[][] 106 | ) => { 107 | const sourceX = getXFromCellId(sourceCellId); 108 | const sourceY = getYFromCellId(sourceCellId); 109 | const targetSourcePosition = getSourcePosition(sourceCellId, data); 110 | const height = data.initial?.height as number; 111 | const width = data.initial?.width as number; 112 | 113 | switch (targetSourcePosition) { 114 | case sourcePosition.Top: 115 | for (let currentCellY = 1; currentCellY <= height; currentCellY++) { 116 | const currentCellX = sourceX; 117 | paintCellInMixedColors( 118 | updatedField, 119 | currentCellX, 120 | currentCellY, 121 | height, 122 | width 123 | ); 124 | } 125 | break; 126 | case sourcePosition.Right: 127 | for (let currentCellX = width; currentCellX > 0; currentCellX--) { 128 | const currentCellY = sourceY; 129 | paintCellInMixedColors( 130 | updatedField, 131 | currentCellX, 132 | currentCellY, 133 | height, 134 | width 135 | ); 136 | } 137 | break; 138 | case sourcePosition.Bottom: 139 | for (let currentCellY = height; currentCellY > 0; currentCellY--) { 140 | const currentCellX = sourceX; 141 | paintCellInMixedColors( 142 | updatedField, 143 | currentCellX, 144 | currentCellY, 145 | height, 146 | width 147 | ); 148 | } 149 | break; 150 | case sourcePosition.Left: 151 | default: 152 | for (let currentCellX = 0; currentCellX <= width; currentCellX++) { 153 | const currentCellY = sourceY; 154 | paintCellInMixedColors( 155 | updatedField, 156 | currentCellX, 157 | currentCellY, 158 | height, 159 | width 160 | ); 161 | } 162 | } 163 | 164 | return updatedField; 165 | }; 166 | 167 | const paintCellInMixedColors = ( 168 | updatedField: ICell[][], 169 | currentCellX: number, 170 | currentCellY: number, 171 | height: number, 172 | width: number 173 | ) => { 174 | let currentCellColorList = []; 175 | 176 | // Iterate by 4 Sources that affect to the current cell color 177 | for (let position = 0; position < 4; position++) { 178 | let sourceColor; 179 | 180 | switch (position) { 181 | case sourcePosition.Top: 182 | sourceColor = getSourceColorByCellCoordsAndSourcePosition( 183 | updatedField, 184 | currentCellX, 185 | currentCellY, 186 | position 187 | ); 188 | 189 | currentCellColorList.push( 190 | getCalculatedTileColor(sourceColor, currentCellY, height) 191 | ); 192 | break; 193 | case sourcePosition.Right: 194 | sourceColor = getSourceColorByCellCoordsAndSourcePosition( 195 | updatedField, 196 | currentCellX, 197 | currentCellY, 198 | position 199 | ); 200 | 201 | currentCellColorList.push( 202 | getCalculatedTileColor(sourceColor, width - currentCellX + 1, width) 203 | ); 204 | break; 205 | case sourcePosition.Bottom: 206 | sourceColor = getSourceColorByCellCoordsAndSourcePosition( 207 | updatedField, 208 | currentCellX, 209 | currentCellY, 210 | position 211 | ); 212 | 213 | currentCellColorList.push( 214 | getCalculatedTileColor(sourceColor, height - currentCellY + 1, height) 215 | ); 216 | break; 217 | case sourcePosition.Left: 218 | default: 219 | sourceColor = getSourceColorByCellCoordsAndSourcePosition( 220 | updatedField, 221 | currentCellX, 222 | currentCellY, 223 | position 224 | ); 225 | 226 | currentCellColorList.push( 227 | getCalculatedTileColor(sourceColor, currentCellX, width) 228 | ); 229 | break; 230 | } 231 | } 232 | 233 | // If the tile is "shined" by multiple sources, the resulting color can be calculated by the following equations 234 | // r = r_1 + r_2 + r_3 + r_4 235 | // 236 | // g = g_1 + g_2 + g_3 + g_4 237 | // 238 | // b = b_1 + b_2 + b_3 + b_4 239 | // 240 | // f=255/max(r,g,b,255) 241 | // 242 | // Result=rgb(r×f,g×f,b×f) 243 | // 244 | // Where rgb(ri,gi,bi),i=1,2,3,4 are the contributions from each source. 245 | // f is a normalization factor to make sure that all RGB elements of the resulting color does not exceed 255. 246 | 247 | let r = 0; 248 | let g = 0; 249 | let b = 0; 250 | 251 | for (let i = 0; i < currentCellColorList.length; i++) { 252 | r += currentCellColorList[i][0]; 253 | g += currentCellColorList[i][1]; 254 | b += currentCellColorList[i][2]; 255 | } 256 | 257 | const f = 255 / Math.max(r, g, b, 255); 258 | const cellMixedColor = [r * f, g * f, b * f]; 259 | 260 | updatedField[currentCellY][currentCellX] = { 261 | ...updatedField[currentCellY][currentCellX], 262 | color: cellMixedColor, 263 | }; 264 | }; 265 | 266 | // Deep copy of field 267 | export const getFieldCopy = (field: ICell[][]) => { 268 | let newFieldState: ICell[][] = []; 269 | 270 | for (let y = 0; y < field.length; y++) { 271 | newFieldState[y] = []; 272 | for (let x = 0; x < field[y].length; x++) { 273 | newFieldState[y][x] = { ...field[y][x] }; 274 | } 275 | } 276 | 277 | return newFieldState; 278 | }; 279 | 280 | export const getCellColorById = (field: ICell[][], cellId: string) => { 281 | const x = getXFromCellId(cellId); 282 | const y = getYFromCellId(cellId); 283 | 284 | return field[y][x].color; 285 | }; 286 | 287 | export const getSourceColorByCellCoordsAndSourcePosition = ( 288 | updatedField: ICell[][], 289 | currentCellX: number, 290 | currentCellY: number, 291 | position: number 292 | ) => { 293 | let fullLineLength; 294 | switch (position) { 295 | case sourcePosition.Top: 296 | return updatedField[0][currentCellX].color; 297 | case sourcePosition.Bottom: 298 | fullLineLength = updatedField.length - 1; 299 | return updatedField[fullLineLength][currentCellX].color; 300 | case sourcePosition.Right: 301 | fullLineLength = updatedField[currentCellY].length - 1; 302 | return updatedField[currentCellY][fullLineLength].color; 303 | case sourcePosition.Left: 304 | default: 305 | return updatedField[currentCellY][0].color; 306 | } 307 | }; 308 | -------------------------------------------------------------------------------- /Client/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 | -------------------------------------------------------------------------------- /Client/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 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /Client/src/interfaces/index.tsx: -------------------------------------------------------------------------------- 1 | export const INITIAL_STEPS_NUMBER = 3; 2 | export const DEFAULT_BORDER_COLOR = [200, 200, 200]; 3 | export const CLOSEST_COLOR_BORDER = [255, 0, 0]; 4 | export const DEFAULT_CELL_ID_WITH_CLOSEST_COLOR = "1,1"; 5 | export const DEFAULT_DELTA = 100; 6 | export const DELTA_WIN_CONDITION = 10; 7 | export const INIT_URL = "http://localhost:9876/init"; 8 | 9 | export const enum IGameStatus { 10 | Initial, 11 | InGame, 12 | Finished, 13 | } 14 | 15 | export interface IFetchedData { 16 | userId: string; 17 | width: number; 18 | height: number; 19 | maxMoves: number; 20 | target: number[]; 21 | } 22 | 23 | export const enum ICellType { 24 | Empty, 25 | Tile, 26 | Source, 27 | } 28 | 29 | export interface ICell { 30 | id: string; 31 | color: number[]; 32 | borderColor: number[]; 33 | type: ICellType; 34 | isDnDEnabled: boolean; 35 | } 36 | 37 | export interface ITileProps { 38 | cell: ICell; 39 | } 40 | 41 | export interface IGameData { 42 | closestColor: number[]; 43 | status: IGameStatus; 44 | stepCount: number; 45 | nextColor: number[]; 46 | isDnDEnabled: boolean; 47 | } 48 | 49 | export interface IData { 50 | initial?: IFetchedData; 51 | game?: IGameData; 52 | } 53 | -------------------------------------------------------------------------------- /Client/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Client/src/pages/Game.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useState } from "react"; 2 | import axios from "axios"; 3 | import ReactTooltip from "react-tooltip"; 4 | import Header from "../components/Header"; 5 | import GridField from "../components/GridField"; 6 | import { 7 | CLOSEST_COLOR_BORDER, 8 | DEFAULT_BORDER_COLOR, 9 | DEFAULT_CELL_ID_WITH_CLOSEST_COLOR, 10 | DEFAULT_DELTA, 11 | DELTA_WIN_CONDITION, 12 | INITIAL_STEPS_NUMBER, 13 | INIT_URL, 14 | IFetchedData, 15 | ICellType, 16 | ICell, 17 | IGameStatus, 18 | IData, 19 | IGameData, 20 | } from "../interfaces"; 21 | import { 22 | generateInitialField, 23 | getCellColorById, 24 | getFieldCopy, 25 | getFieldWithUpdatedTilesLine, 26 | getXFromCellId, 27 | getYFromCellId, 28 | } from "../helpers"; 29 | 30 | const Game: FC = () => { 31 | const [data, setData] = useState({}); 32 | const [field, setField] = useState([]); 33 | const [delta, setDelta] = useState(DEFAULT_DELTA); 34 | const [cellIdWithClosestColor, setCellIdWithClosestColor] = useState( 35 | DEFAULT_CELL_ID_WITH_CLOSEST_COLOR 36 | ); 37 | 38 | useEffect(() => { 39 | initGame(); 40 | }, []); 41 | 42 | useEffect(() => { 43 | setClosestColorAndDelta(); 44 | ReactTooltip.rebuild(); 45 | }, [field]); 46 | 47 | useEffect(() => { 48 | resetCellBorders(); 49 | setClosestColorCellBorder(cellIdWithClosestColor); 50 | }, [cellIdWithClosestColor]); 51 | 52 | useEffect(() => { 53 | checkWinConditions(); 54 | }, [delta]); 55 | 56 | const checkGameOverConditions = (stepCount: number) => { 57 | if ((data.initial?.maxMoves as number) - stepCount === 0) { 58 | setData((prevState) => ({ 59 | ...prevState, 60 | game: { 61 | ...(prevState.game as IGameData), 62 | status: IGameStatus.Finished, 63 | }, 64 | })); 65 | 66 | if (window.confirm("You lose. Do you want to play again?")) { 67 | restart(); 68 | } 69 | } 70 | }; 71 | 72 | const checkWinConditions = () => { 73 | if (delta < DELTA_WIN_CONDITION) { 74 | setData((prevState) => ({ 75 | ...prevState, 76 | game: { 77 | ...(prevState.game as IGameData), 78 | status: IGameStatus.Finished, 79 | }, 80 | })); 81 | 82 | if (window.confirm("You win. Do you want to play again?")) { 83 | restart(); 84 | } 85 | } 86 | }; 87 | 88 | const restart = () => { 89 | initGame(data.initial?.userId); 90 | }; 91 | 92 | const setClosestColorAndDelta = () => { 93 | const { leastDelta, closestColor } = getClosestColorAndDelta(); 94 | 95 | setData((prevState) => ({ 96 | ...prevState, 97 | game: { 98 | ...(prevState.game as IGameData), 99 | closestColor, 100 | }, 101 | })); 102 | 103 | setDelta(leastDelta); 104 | }; 105 | 106 | const resetCellBorders = () => { 107 | setField((prevState) => { 108 | const updatedField = getFieldCopy(prevState); 109 | 110 | for (let y = 1; y <= (data.initial?.height as number); y++) { 111 | for (let x = 1; x <= (data.initial?.width as number); x++) { 112 | updatedField[y][x] = { 113 | ...updatedField[y][x], 114 | borderColor: DEFAULT_BORDER_COLOR, 115 | }; 116 | } 117 | } 118 | 119 | return updatedField; 120 | }); 121 | }; 122 | 123 | const setClosestColorCellBorder = (cellId: string) => { 124 | setField((prevState) => { 125 | const x = getXFromCellId(cellId); 126 | const y = getYFromCellId(cellId); 127 | const updatedField = getFieldCopy(prevState); 128 | 129 | if (typeof updatedField[y]?.[x] !== "undefined") { 130 | updatedField[y][x] = { 131 | ...updatedField[y][x], 132 | borderColor: CLOSEST_COLOR_BORDER, 133 | }; 134 | } 135 | 136 | return updatedField; 137 | }); 138 | }; 139 | 140 | const getClosestColorAndDelta = () => { 141 | let leastDelta = DEFAULT_DELTA; 142 | let closestColor = [0, 0, 0]; 143 | const targetColor = data.initial?.target as number[]; 144 | let cellColor; 145 | let currentDelta; 146 | 147 | for (let y = 1; y <= (data.initial?.height as number); y++) { 148 | for (let x = 1; x <= (data.initial?.width as number); x++) { 149 | cellColor = field[y][x].color; 150 | 151 | // Skip a cell if it is black 152 | if (cellColor[0] === 0 && cellColor[1] === 0 && cellColor[2] === 0) { 153 | continue; 154 | } 155 | 156 | currentDelta = 157 | (1 / 255 / Math.sqrt(3)) * 158 | Math.sqrt( 159 | Math.pow(targetColor[0] - cellColor[0], 2) + 160 | Math.pow(targetColor[1] - cellColor[1], 2) + 161 | Math.pow(targetColor[2] - cellColor[2], 2) 162 | ) * 163 | 100; 164 | if (currentDelta < leastDelta) { 165 | setCellIdWithClosestColor(x + "," + y); 166 | 167 | leastDelta = currentDelta; 168 | closestColor = cellColor; 169 | } 170 | } 171 | } 172 | 173 | return { leastDelta, closestColor }; 174 | }; 175 | 176 | const initGame = (userId?: string) => { 177 | let url = INIT_URL; 178 | if (userId) { 179 | url = INIT_URL + "/user/" + userId; 180 | } 181 | 182 | axios.get(url).then(({ data }) => { 183 | setInitialGame(data); 184 | setInitialField(data); 185 | setClosestColorCellBorder(DEFAULT_CELL_ID_WITH_CLOSEST_COLOR); 186 | }); 187 | }; 188 | 189 | const setInitialGame = (initialData: IFetchedData) => { 190 | setData((prevState) => ({ 191 | ...prevState, 192 | initial: initialData, 193 | game: { 194 | closestColor: [0, 0, 0], 195 | status: IGameStatus.Initial, 196 | stepCount: 0, 197 | nextColor: [255, 0, 0], // red color to paint 1st Source 198 | isDnDEnabled: false, 199 | }, 200 | })); 201 | }; 202 | 203 | const setInitialField = (data: IFetchedData) => { 204 | setField(generateInitialField(data)); 205 | }; 206 | 207 | const handleSourceClick = (cellId: string) => { 208 | if (data.game?.status == IGameStatus.Initial) { 209 | initialGameProc(cellId); 210 | } 211 | }; 212 | 213 | const handleCellDrop = (e: DragEvent, sourceCellId: string) => { 214 | const tileCellId = e.dataTransfer?.getData("id") as string; 215 | 216 | if (data.game?.status == IGameStatus.InGame) { 217 | draggingGameProc(tileCellId, sourceCellId); 218 | } 219 | }; 220 | 221 | const setDnD = (isDnDEnabled: boolean) => { 222 | // Enable Drag & Drop 223 | setField((prevState) => { 224 | const updatedField = getFieldCopy(prevState); 225 | 226 | for (let y = 0; y <= (data.initial?.height as number) + 1; y++) { 227 | for (let x = 0; x <= (data.initial?.width as number) + 1; x++) { 228 | if (updatedField[y][x].type !== ICellType.Empty) { 229 | updatedField[y][x] = { 230 | ...updatedField[y][x], 231 | isDnDEnabled: isDnDEnabled, 232 | }; 233 | } 234 | } 235 | } 236 | 237 | return updatedField; 238 | }); 239 | }; 240 | 241 | const disableDnD = () => {}; 242 | 243 | const initialGameProc = (cellId: string) => { 244 | if (typeof data.game?.stepCount !== "undefined") { 245 | const stepCount = data.game.stepCount; 246 | 247 | paintSourceAndTilesLine(cellId, data.game?.nextColor); 248 | 249 | // Set a color for next step to paint a next Source by clicking to it 250 | switch (stepCount) { 251 | case 0: 252 | setData( 253 | (prevState): IData => ({ 254 | ...prevState, 255 | game: { 256 | ...(prevState.game as IGameData), 257 | nextColor: [0, 255, 0], // green to 2nd Source 258 | }, 259 | }) 260 | ); 261 | break; 262 | case 2: 263 | default: 264 | setData( 265 | (prevState): IData => ({ 266 | ...prevState, 267 | game: { 268 | ...(prevState.game as IGameData), 269 | nextColor: [0, 0, 255], // blue to 3rd Source 270 | }, 271 | }) 272 | ); 273 | } 274 | 275 | // Check whether a game status need to be changed 276 | if (stepCount === INITIAL_STEPS_NUMBER - 1) { 277 | setData((prevState) => ({ 278 | ...prevState, 279 | game: { 280 | ...(prevState.game as IGameData), 281 | status: IGameStatus.InGame, 282 | }, 283 | })); 284 | 285 | setDnD(true); 286 | } 287 | increaseStepCount(stepCount); 288 | } 289 | }; 290 | 291 | const increaseStepCount = (stepCount: number) => { 292 | setData((prevState) => ({ 293 | ...prevState, 294 | game: { 295 | ...(prevState.game as IGameData), 296 | stepCount: stepCount + 1, 297 | }, 298 | })); 299 | }; 300 | 301 | const paintSourceAndTilesLine = (cellId: string, cellColor: number[]) => { 302 | setField((prevState) => { 303 | const x = getXFromCellId(cellId); 304 | const y = getYFromCellId(cellId); 305 | const updatedField = getFieldCopy(prevState); 306 | 307 | updatedField[y][x] = { 308 | ...updatedField[y][x], 309 | color: cellColor, 310 | }; 311 | 312 | return getFieldWithUpdatedTilesLine(data, cellId, updatedField); 313 | }); 314 | }; 315 | 316 | const draggingGameProc = (tileCellId: string, sourceCellId: string) => { 317 | if (typeof data.game?.stepCount !== "undefined") { 318 | const stepCount = data.game.stepCount; 319 | const tileColor = getCellColorById(field, tileCellId); 320 | 321 | paintSourceAndTilesLine(sourceCellId, tileColor); 322 | 323 | increaseStepCount(stepCount); 324 | 325 | checkGameOverConditions(stepCount + 1); 326 | } 327 | }; 328 | 329 | return ( 330 | <> 331 |
332 | 338 | 345 | 346 | ); 347 | }; 348 | 349 | export default Game; -------------------------------------------------------------------------------- /Client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /Client/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 | -------------------------------------------------------------------------------- /Client/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 | -------------------------------------------------------------------------------- /Client/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 | -------------------------------------------------------------------------------- /ReadMe.txt: -------------------------------------------------------------------------------- 1 | [Front End Interview Code Challenge] 2 | ------ RGB Alchemy Game -------- 3 | 4 | - Server 5 | npm install 6 | npm start 7 | 8 | - Client 9 | npm install 10 | npm start 11 | 12 | ----------- Vladimir ------------ 13 | 12/17/2022 14 | ------------------------------------ -------------------------------------------------------------------------------- /Server/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # production 7 | /build 8 | 9 | # misc 10 | .DS_Store 11 | .env.local 12 | .env.development.local 13 | .env.test.local 14 | .env.production.local 15 | 16 | package-lock.json 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /Server/index.js: -------------------------------------------------------------------------------- 1 | const { gameInfo } = require('./utils'); 2 | const express = require("express"); 3 | 4 | const app = express(); 5 | const port = 9876; 6 | 7 | app.use((_req, res, next) => { 8 | res.setHeader("Access-Control-Allow-Origin", "*"); 9 | next(); 10 | }); 11 | 12 | app.get("/init", (_, res) => { 13 | return res.json(gameInfo()); 14 | }); 15 | 16 | app.get("/init/user/:id", (req, res) => { 17 | return res.json(gameInfo(req.params.id)); 18 | }); 19 | 20 | app.listen(port, () => { 21 | console.log(`Start rgb_alchemy_game on ${port}`); 22 | }); 23 | -------------------------------------------------------------------------------- /Server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "color-alchemy-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "livebarn-dev", 11 | "license": "UNLICENSED", 12 | "dependencies": { 13 | "express": "^4.17.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Server/utils.js: -------------------------------------------------------------------------------- 1 | const rand = (min, max) => { 2 | return Math.floor(min + Math.random() * (max + 1 - min)); 3 | }; 4 | 5 | const gameInfo = (userId) => { 6 | if (!userId) { 7 | userId = Math.random().toString(16).slice(2, 8); 8 | } 9 | 10 | return { 11 | userId, 12 | width: rand(10, 20), 13 | height: rand(4, 10), 14 | maxMoves: rand(8, 20), 15 | target: [rand(0, 255), rand(0, 255), rand(0, 255)], 16 | }; 17 | }; 18 | 19 | module.exports = { 20 | gameInfo 21 | } --------------------------------------------------------------------------------