├── nodemon.json ├── red_tetris.pdf ├── src ├── client │ ├── assets │ │ ├── sky.png │ │ ├── space.png │ │ └── Original_Tetris_theme.mp3 │ ├── config │ │ └── routes.ts │ ├── component │ │ ├── Offline.tsx │ │ ├── Modal.tsx │ │ ├── Fireworks.tsx │ │ ├── app.tsx │ │ ├── Opponents.tsx │ │ ├── home.tsx │ │ └── game.tsx │ ├── index.html │ ├── index.tsx │ ├── redux │ │ ├── reducer.ts │ │ ├── socketMiddleware.ts │ │ └── actions │ │ │ └── action-creators.ts │ ├── socket-handler.ts │ └── style.scss ├── server │ ├── main.ts │ ├── Piece.ts │ ├── Player.ts │ ├── updateWin.ts │ ├── GamesDispatcher.ts │ ├── client.ts │ └── Game.ts └── common │ ├── ITypeRoomManager.ts │ ├── socketEventServer.ts │ ├── socketEventClient.ts │ └── grid-piece-handler.ts ├── .babelrc ├── .prittierrc.json ├── .editorconfig ├── test ├── server │ ├── Piece.test.ts │ ├── updateWin.test.ts │ └── Game.test.ts ├── client │ └── redux │ │ ├── reducer.test.ts │ │ └── actions │ │ └── action-creators.test.ts └── common │ └── grid-piece-handler.test.ts ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── webpack.config.js ├── tsconfig.json ├── tslint.json └── package.json /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src/server/"] 3 | } 4 | -------------------------------------------------------------------------------- /red_tetris.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BertaFly/Red-Tetris-42/HEAD/red_tetris.pdf -------------------------------------------------------------------------------- /src/client/assets/sky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BertaFly/Red-Tetris-42/HEAD/src/client/assets/sky.png -------------------------------------------------------------------------------- /src/client/config/routes.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | index: '/', 3 | game: '/:roomName' 4 | } as const 5 | -------------------------------------------------------------------------------- /src/client/assets/space.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BertaFly/Red-Tetris-42/HEAD/src/client/assets/space.png -------------------------------------------------------------------------------- /src/client/assets/Original_Tetris_theme.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BertaFly/Red-Tetris-42/HEAD/src/client/assets/Original_Tetris_theme.mp3 -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/react", 4 | "@babel/typescript", 5 | [ 6 | "@babel/env", 7 | { 8 | "modules": false 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.prittierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": false, 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "jsxBracketSameLine": false, 11 | "arrowParens": "avoid" 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 120 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /src/client/component/Offline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const OffLine = () => ( 4 |
5 |
6 | Sorry, but the server is offline 7 |
8 |
9 | ); 10 | 11 | export default OffLine; 12 | -------------------------------------------------------------------------------- /test/server/Piece.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Piece } from '../../src/server/Piece'; 3 | 4 | it('randomPiece', () => { 5 | expect(Piece.randomPiece()).to.have.keys(['num', 'rot']); 6 | expect(Piece.randomPiece()).to.not.have.keys(['x', 'y']); 7 | }) 8 | 9 | it('genFlow', () => { 10 | expect(Piece.genFlow(10)).to.have.length(10); 11 | }) 12 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/server/main.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import { Server } from 'http'; 3 | import { Socket } from 'socket.io'; 4 | import { handleClient } from '@src/server/client'; 5 | 6 | const app = express(); 7 | 8 | app.use(express.static('build')); 9 | 10 | const server = new Server(app); 11 | 12 | const io = require('socket.io')(server); 13 | 14 | io.on('connection', (s: Socket) => handleClient(s)); 15 | 16 | server.listen(8000, () => { 17 | console.log('Server on port : 8000'); 18 | }); 19 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | red-tetris 7 | 8 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/server/Piece.ts: -------------------------------------------------------------------------------- 1 | import { ENUM_PIECES, IPiece } from '@src/common/grid-piece-handler'; 2 | 3 | class Piece { 4 | 5 | static randomPiece = (): IPiece => { 6 | return { 7 | num: Math.floor(Math.random() * (ENUM_PIECES.n7 - ENUM_PIECES.n1 + 1)) + ENUM_PIECES.n1, 8 | rot: Math.floor(Math.random() * 4), 9 | }; 10 | }; 11 | 12 | static genFlow = (n: number): IPiece[] => { 13 | return Array(n).fill(0).map(() => Piece.randomPiece()); 14 | }; 15 | } 16 | 17 | export { 18 | Piece, 19 | }; 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "eslint.enable": true, 4 | "eslint.autoFixOnSave": true, 5 | "eslint.validate": [ 6 | "javascript", 7 | "javascriptreact", 8 | { 9 | "language": "typescript", 10 | "autoFix": true 11 | }, 12 | { 13 | "language": "typescriptreact", 14 | "autoFix": true 15 | } 16 | ], 17 | "typescript.tsdk": "./node_modules/typescript/lib", 18 | "files.exclude": { 19 | "**/node_modules": true, 20 | "build": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/client/component/Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | type Props = { 4 | onClose: () => void 5 | show: boolean 6 | children: React.ReactChild 7 | isWin?: string 8 | } 9 | 10 | export const Modal: React.FC = ({ onClose, show, children, isWin = 'default' }) => { 11 | const showHideClassName = show ? "modal display-block" : "modal display-none"; 12 | 13 | return ( 14 |
15 |
16 | {children} 17 | 18 |
19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/server/Player.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | 3 | import { gridInit, initPose } from '@src/common/grid-piece-handler'; 4 | import { IPlayer } from '@src/common/ITypeRoomManager'; 5 | 6 | class Player { 7 | static newPlayer = (playerName: string, socket: Socket, isMaster: boolean, gridHeight: number): IPlayer => { 8 | 9 | return { 10 | playerName: playerName, 11 | socket: socket, 12 | isSpectator: true, 13 | grid: gridInit(gridHeight), 14 | score: 0, 15 | nbLineCompleted: 0, 16 | playing: false, 17 | win: false, 18 | lost: false, 19 | gameOver: false, 20 | isMaster: isMaster, 21 | flow: [], 22 | posPiece: initPose(), 23 | }; 24 | }; 25 | } 26 | 27 | export { Player } 28 | -------------------------------------------------------------------------------- /src/common/ITypeRoomManager.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | 3 | import { ENUM_PIECES, IPiece, IPos } from '@src/common/grid-piece-handler'; 4 | 5 | interface IPlayer { 6 | readonly playerName: string; 7 | readonly socket: Socket; 8 | readonly isSpectator: boolean; 9 | readonly grid: ENUM_PIECES[][]; 10 | readonly score: number; 11 | readonly nbLineCompleted: number; 12 | readonly playing: boolean; 13 | readonly win: boolean; 14 | readonly lost: boolean; 15 | readonly gameOver: boolean; 16 | readonly flow: IPiece[]; 17 | readonly posPiece: IPos; 18 | readonly isMaster: boolean; 19 | } 20 | 21 | interface IRoomState { 22 | readonly roomName: string; 23 | readonly playing: boolean; 24 | readonly players: IPlayer[]; 25 | } 26 | 27 | export { 28 | IPlayer, 29 | IRoomState, 30 | }; 31 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux'; 4 | import { applyMiddleware, createStore } from 'redux'; 5 | import { StoreContext } from 'redux-react-hook'; 6 | import { App } from '@src/client/component/app'; 7 | import { onAll } from '@src/client/socket-handler'; 8 | import { reducer } from '@src/client/redux/reducer'; 9 | import { socketMiddleware } from '@src/client/redux/socketMiddleware'; 10 | import './style.scss'; 11 | 12 | const store = createStore( 13 | reducer, 14 | applyMiddleware( 15 | socketMiddleware, 16 | ), 17 | ); 18 | 19 | onAll(store)(); 20 | 21 | ReactDOM.render( 22 | 23 | 24 | 25 | 26 | 27 | , document.getElementById('app')); 28 | -------------------------------------------------------------------------------- /src/client/component/Fireworks.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRef, useEffect } from 'react'; 3 | 4 | import * as FireworksCanvas from 'fireworks-canvas' 5 | 6 | export const FireWorks = () => { 7 | const fireworkRef = useRef(null); 8 | 9 | useEffect(() => { 10 | if (fireworkRef.current) { 11 | const options = { 12 | maxRockets: 6, 13 | explosionChance: 0.5, 14 | numParticles: 200, // number of particles to spawn when rocket explodes (+0-10) 15 | explosionMinHeight: 0.8, // percentage. min height at which rockets can explode 16 | explosionMaxHeight: 0.9, // percentage. max height before a particle is exploded 17 | } 18 | // @ts-ignore 19 | const fire = new FireworksCanvas(fireworkRef.current, options) 20 | fire.start() 21 | } 22 | }, []) 23 | 24 | return ( 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/server/updateWin.ts: -------------------------------------------------------------------------------- 1 | import { IPlayer } from '@src/common/ITypeRoomManager'; 2 | 3 | const updateWin = (players: IPlayer[]): IPlayer[] => { 4 | 5 | if (players.filter((p) => !p.isSpectator).length > 1) { 6 | if (players.filter((p) => p.playing).length === 1) { 7 | const playerMaxScore = players.reduce((prev, current) => (prev.score > current.score) ? prev : current) 8 | return players.map(p => 9 | ({ 10 | ...p, 11 | playing: false, 12 | win: p.playerName === playerMaxScore.playerName, 13 | lost: p.playerName !== playerMaxScore.playerName, 14 | gameOver: true, 15 | }) 16 | ); 17 | } 18 | } else if (players.length === 1 && !players[0].playing) { 19 | return players.map(pl => ({ 20 | ...pl, 21 | win: Boolean(pl.score), 22 | lost: !Boolean(pl.score), 23 | })) 24 | } 25 | 26 | return players; 27 | }; 28 | 29 | export { 30 | updateWin, 31 | }; 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Red-Tetris-42 2 | 3 | This is a project for Unit Factory, aka School 42. 4 | 5 | # Technologies: 6 | TypeScript, React, Redux, Node, Socket.io, SCSS. 7 | 8 | ## To run project locally 9 | In separate terminal windows run `npm run srv-dev`, `npm run client-dev` 10 | 11 | ## Summury 12 | This is a classic tetris game. 13 | To play you need create a room. 14 | Be carefull, music will play loud once you enter the room. 15 | Other users can join to your room, if a player name is uniq and a game hasn't started. 16 | 17 | Once you complete a line, your score will be increased, unless you have positive lines completed count. 18 | Simultaneously completed lines will give you: 19 | 1 line - 20 points 20 | 2 lines - 40 points 21 | 3 lines - 80 points 22 | 4 lines - 160 points 23 | As soon as on of your opponents complete a line you will get - line from your lines completed. 24 | 25 | Details you can read in red-tetris.pdf 26 | 27 | ## Test 28 | Unit tests with jest. Run `npm run test:ci` to check coverage. It will be compiled into `coverage` -> index.html. 29 | 30 | If you ❤️ the game, please give a 🌟 to this repo 😉 31 | -------------------------------------------------------------------------------- /src/client/component/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { HashRouter, Route, Switch } from 'react-router-dom'; 3 | 4 | import { useCallback } from 'react'; 5 | import { useMappedState } from 'redux-react-hook'; 6 | import { IDataState } from '@src/client/redux/reducer'; 7 | 8 | import routes from '@src/client/config/routes'; 9 | 10 | import { Home } from './home'; 11 | import { Game } from './game'; 12 | import OffLine from './Offline'; 13 | 14 | const App = () => { 15 | 16 | const mapState = useCallback( 17 | (state: IDataState) => ({ 18 | connected: state.socket.connected, 19 | playerName: state.playerName, 20 | roomName: state.roomState, 21 | }), 22 | [], 23 | ); 24 | const { connected } = useMappedState(mapState); 25 | 26 | if (!connected) { 27 | return ; 28 | } 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export { App }; 41 | -------------------------------------------------------------------------------- /src/server/GamesDispatcher.ts: -------------------------------------------------------------------------------- 1 | import { ActionRoom, Game } from '@src/server/Game'; 2 | 3 | interface IActionRooms { 4 | roomName?: string, 5 | socketId?: string, 6 | 7 | actionRoom: ActionRoom 8 | } 9 | 10 | class GamesDispatcher { 11 | games: Game[]; 12 | 13 | constructor() { 14 | this.games = []; 15 | } 16 | 17 | public dispatch = (action: IActionRooms): void => { 18 | const { roomName, socketId, actionRoom } = action; 19 | 20 | if (roomName !== undefined) { 21 | let room = this.games.find((r) => r.state.roomName === roomName); 22 | if (room === undefined) { 23 | room = new Game(roomName); 24 | this.games.push(room); 25 | } 26 | room.dispatch(actionRoom); 27 | } else if (socketId !== undefined) { 28 | this.games.forEach((room) => { 29 | if (room.hasSocketId(socketId)) { 30 | room.dispatch(actionRoom); 31 | } 32 | }); 33 | } 34 | 35 | this.games.forEach((g) => { 36 | if (g.nbPlayer() === 0) { 37 | g.unsubscribe(); 38 | } 39 | }); 40 | 41 | this.games = this.games.filter((r) => r.nbPlayer() > 0); 42 | } 43 | } 44 | 45 | export { GamesDispatcher }; 46 | -------------------------------------------------------------------------------- /src/common/socketEventServer.ts: -------------------------------------------------------------------------------- 1 | import { ENUM_PIECES_MOVE } from './grid-piece-handler'; 2 | 3 | // JOIN_ROOM 4 | interface IEventServerJoinRoom { 5 | roomName: string, 6 | 7 | playerName: string, 8 | } 9 | 10 | // QUIT_ROOM 11 | interface IEventServerQuitRoom { 12 | roomName: string, 13 | 14 | playerName: string, 15 | } 16 | 17 | // SUB_ROOMS_PLAYERS_NAME 18 | interface IEventServerSubRoomsPlayersName { 19 | } 20 | 21 | // UN_SUB_ROOMS_PLAYERS_NAME 22 | interface IEventServerUnSubRoomsPlayersName { 23 | } 24 | 25 | // START_GAME 26 | interface IEventServerStartGame { 27 | roomName: string, 28 | } 29 | 30 | // IEventServerMovePiece 31 | 32 | interface IEventServerMovePiece { 33 | roomName: string 34 | move: ENUM_PIECES_MOVE 35 | } 36 | 37 | enum ENUM_SOCKET_EVENT_SERVER { 38 | JOIN_ROOM = 'JOIN_ROOM', 39 | QUIT_ROOM = 'QUIT_ROOM', 40 | SUB_ROOMS_PLAYERS_NAME = 'SUB_ROOMS_PLAYERS_NAME', 41 | UN_SUB_ROOMS_PLAYERS_NAME = 'UN_SUB_ROOMS_PLAYERS_NAME', 42 | SET_GAME_OPTION = 'SET_GAME_OPTION', 43 | START_GAME = 'START_GAME', 44 | MOVE_PIECE = 'MOVE_PIECE', 45 | } 46 | 47 | export { 48 | ENUM_SOCKET_EVENT_SERVER, 49 | IEventServerJoinRoom, 50 | IEventServerStartGame, 51 | IEventServerSubRoomsPlayersName, 52 | IEventServerQuitRoom, 53 | IEventServerUnSubRoomsPlayersName, 54 | IEventServerMovePiece, 55 | }; 56 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const TsConfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 5 | 6 | const htmlWebpackPlugin = new HtmlWebpackPlugin({ 7 | template: path.join(__dirname, "./src/client/index.html"), 8 | filename: "./index.html" 9 | }); 10 | 11 | module.exports = { 12 | mode: "development", 13 | entry: path.join(__dirname, "./src/client/index.tsx"), 14 | output: { 15 | filename: "bundle.js", 16 | path: path.resolve(__dirname, "build") 17 | }, 18 | plugins: [htmlWebpackPlugin], 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(ts|tsx)$/, 23 | use: "ts-loader", 24 | exclude: /node_modules/ 25 | }, 26 | { 27 | test: /\.(js|jsx)$/, 28 | use: "babel-loader" 29 | }, 30 | { 31 | test: /\.scss$/, 32 | use: ["style-loader", "css-loader", "sass-loader"] 33 | }, 34 | { 35 | test: /\.(mp3|otf|jpg|ttf|png|svg)/, 36 | loader: "file-loader" 37 | } 38 | ] 39 | }, 40 | resolve: { 41 | extensions: [".js", ".jsx", ".ts", ".tsx"], 42 | alias: { "@src": path.join(__dirname, "src") }, 43 | plugins: [ 44 | new TsConfigPathsPlugin() 45 | ] 46 | }, 47 | devServer: { 48 | port: 3001 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /test/server/updateWin.test.ts: -------------------------------------------------------------------------------- 1 | import { updateWin } from '../../src/server/updateWin'; 2 | import { GRID_HEIGHT, gridInit, initPose } from '../../src/common/grid-piece-handler'; 3 | 4 | const playerTest = { 5 | playerName: 'test', 6 | socket: ((arg: any) => { }) as any, 7 | isSpectator: true, 8 | grid: gridInit(GRID_HEIGHT), 9 | score: 0, 10 | nbLineCompleted: 0, 11 | playing: true, 12 | win: false, 13 | lost: false, 14 | gameOver: false, 15 | isMaster: true, 16 | flow: [], 17 | posPiece: initPose(), 18 | } 19 | 20 | it('Not modify single player if he is playing', () => { 21 | expect(updateWin([playerTest])).toStrictEqual([playerTest]); 22 | }) 23 | 24 | it('One player win', () => { 25 | const playerFinished = { 26 | ...playerTest, 27 | isSpectator: false, 28 | playing: false, 29 | } 30 | const playerFinishedWin = { 31 | ...playerTest, 32 | isSpectator: false, 33 | playing: false, 34 | score: 20, 35 | } 36 | expect(updateWin([playerFinished])[0].win).toBe(false); 37 | expect(updateWin([playerFinishedWin])[0].win).toBe(true); 38 | }) 39 | 40 | it('Who win', () => { 41 | const winner = 'winnerName'; 42 | const playerFinished1 = { 43 | ...playerTest, 44 | isSpectator: false, 45 | score: 20, 46 | playing: true, 47 | } 48 | const playerFinished2 = { 49 | ...playerTest, 50 | playerName: winner, 51 | score: 60, 52 | playing: false, 53 | isSpectator: false, 54 | } 55 | expect(updateWin([playerFinished1, playerFinished2])[1].win).toBe(true); 56 | }) 57 | -------------------------------------------------------------------------------- /src/common/socketEventClient.ts: -------------------------------------------------------------------------------- 1 | import { ENUM_PIECES, IPiece, IPos } from '@src/common/grid-piece-handler'; 2 | 3 | interface IPlayerClient { 4 | readonly playerName: string; 5 | readonly isSpectator: boolean; 6 | readonly grid: ENUM_PIECES[][]; 7 | readonly score: number; 8 | readonly nbLineCompleted: number; 9 | readonly playing: boolean; 10 | readonly win: boolean; 11 | readonly lost: boolean; 12 | readonly gameOver: boolean; 13 | readonly flow: IPiece[]; 14 | readonly posPiece: IPos; 15 | readonly isMaster: boolean; 16 | } 17 | 18 | interface IRoomStateClient { 19 | readonly roomName: string; 20 | readonly playing: boolean; 21 | readonly players: IPlayerClient[]; 22 | } 23 | 24 | // SET_ERROR 25 | enum EnumError { 26 | PLAYER_SAME_NAME, 27 | PLAYING, 28 | } 29 | 30 | interface IEventClientSetError { 31 | readonly error_type: EnumError, 32 | readonly msg: string, 33 | } 34 | 35 | interface IEventClientSetRoomState { 36 | readonly room: IRoomStateClient 37 | } 38 | 39 | enum ENUM_SOCKET_EVENT_CLIENT { 40 | SET_ROOM_STATE = 'SET_ROOM_STATE', 41 | SET_ROOMS_PLAYERS_NAME = 'SET_ROOMS_PLAYERS_NAME', 42 | SET_ERROR = 'SET_ERROR', 43 | CLEAR_ERROR = 'CLEAR_ERROR', 44 | } 45 | 46 | // SET_ROOMS_PLAYERS_NAME 47 | interface IRoomPlayersName { 48 | readonly roomName: string, 49 | readonly playerNames: string[], 50 | } 51 | 52 | interface IEventClientSetRoomsPlayersName { 53 | readonly roomsPlayersName: IRoomPlayersName[] 54 | } 55 | 56 | export { 57 | ENUM_SOCKET_EVENT_CLIENT, 58 | EnumError, 59 | IEventClientSetRoomState, 60 | IEventClientSetError, 61 | IEventClientSetRoomsPlayersName, 62 | IRoomPlayersName, 63 | IRoomStateClient, 64 | IPlayerClient, 65 | }; 66 | -------------------------------------------------------------------------------- /src/client/component/Opponents.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { IPlayerClient } from '@src/common/socketEventClient'; 4 | import { ENUM_PIECES } from '@src/common/grid-piece-handler'; 5 | 6 | type Props = { 7 | players: IPlayerClient[] 8 | } 9 | 10 | export const Opponents: React.FC = ({ players }) => { 11 | const modifiedPlayers = players.map(pl => { 12 | const hideElArrX: number[] = [] 13 | 14 | return { 15 | ...pl, 16 | grid: pl.grid.slice(4).map((line, y) => { 17 | return line.map((el, x) => { 18 | if (el) { 19 | hideElArrX.push(x) 20 | } 21 | if (hideElArrX.includes(x)) { 22 | el = ENUM_PIECES.wall 23 | } 24 | return el 25 | }) 26 | }) 27 | } 28 | }) 29 | 30 | return ( 31 |
32 |

Opponents

33 |
34 | {modifiedPlayers.map(competitor => ( 35 |
36 | {competitor.grid.map((line: number[], i: number) => 37 |
38 | {line.map((el: number, j: number) =>
)} 39 |
, 40 | )} 41 |

Name: {competitor.playerName}

42 | {!competitor.playing && (competitor.win || competitor.lost) ? ( 43 |

{competitor.win ? 'Win' : 'Lost'}

44 | ) : null} 45 |

Score: {competitor.score}

46 |

Lines: {competitor.nbLineCompleted}

47 |
48 | ))} 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /test/client/redux/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { reducer, initApp } from '../../../src/client/redux/reducer'; 2 | import { 3 | ON_SET_ERROR, 4 | ON_SET_ROOM_STATE, 5 | ON_SET_ROOMS_PLAYERS_NAME, 6 | REFRESH, 7 | ON_CLEAR_ERROR, 8 | SEND_QUIT_ROOM, 9 | SEND_JOIN_ROOM, 10 | } from '../../../src/client/redux/actions/action-creators'; 11 | import { EnumError } from '../../../src/common/socketEventClient'; 12 | 13 | const state = reducer(initApp(), {} as any); 14 | 15 | it('ON_SET_ERROR', () => { 16 | const newState = reducer(state, ON_SET_ERROR({ 17 | error_type: EnumError.PLAYER_SAME_NAME, 18 | msg: 'msg', 19 | })) 20 | if (newState) { 21 | expect(newState.errorMsg).toBe('msg'); 22 | } 23 | }); 24 | 25 | it('ON_SET_ROOM_STATE', () => { 26 | const newState = reducer(state, ON_SET_ROOM_STATE({ 27 | room: { 28 | roomName: 'roomname', 29 | playing: true, 30 | players: [], 31 | }, 32 | })) 33 | if (newState && newState.roomState) { 34 | expect(newState.roomState.roomName).toBe('roomname'); 35 | } 36 | }); 37 | 38 | it('ON_SET_ROOMS_PLAYERS_NAME', () => { 39 | const newState = reducer(state, ON_SET_ROOMS_PLAYERS_NAME({ 40 | roomsPlayersName: [], 41 | })) 42 | if (newState) { 43 | expect(newState.roomsPlayersName.length).toBe(0); 44 | } 45 | }); 46 | 47 | it('REFRESH', () => { 48 | reducer(state, REFRESH()); 49 | }); 50 | 51 | it('ON_CLEAR_ERROR', () => { 52 | expect(reducer(state, ON_CLEAR_ERROR()).errorMsg).toBe(undefined) 53 | }) 54 | 55 | it('SEND_QUIT_ROOM', () => { 56 | expect(reducer(state, SEND_QUIT_ROOM()).playerName).toBe(undefined) 57 | }) 58 | 59 | it('SEND_JOIN_ROOM', () => { 60 | expect(reducer(state, SEND_JOIN_ROOM('test', 'test')).playerName).toBe('test') 61 | }) 62 | 63 | state.socket.disconnect(); 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | // enables project relative paths config 5 | "paths": { 6 | // define paths mappings 7 | "@src/*": [ 8 | "src/*" 9 | ] 10 | // will enable -> import { ... } from '@src/components' 11 | // in webpack you need to add -> resolve: { alias: { '@src': PATH_TO_SRC } } 12 | }, 13 | "outDir": "dist/", 14 | // target for compiled files 15 | "allowSyntheticDefaultImports": true, 16 | // no errors with commonjs modules interop 17 | // "esModuleInterop": true, 18 | "allowJs": true, 19 | // include js files 20 | "checkJs": true, 21 | // typecheck js files 22 | "declaration": false, 23 | // don't emit declarations 24 | "emitDecoratorMetadata": true, 25 | "experimentalDecorators": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "importHelpers": true, 28 | // importing helper functions from tslib 29 | "noEmitHelpers": true, 30 | // disable emitting inline helper functions 31 | "jsx": "react", 32 | // process JSX 33 | "lib": [ 34 | "dom", 35 | "es2016", 36 | "es2017.object" 37 | ], 38 | "target": "es5", 39 | // "es2015" for ES6+ engines 40 | "module": "commonjs", 41 | // "es2015" for tree-shaking 42 | "moduleResolution": "node", 43 | "noEmitOnError": true, 44 | "noFallthroughCasesInSwitch": true, 45 | "noImplicitAny": true, 46 | "noImplicitReturns": true, 47 | "noImplicitThis": true, 48 | "noUnusedLocals": true, 49 | "strict": true, 50 | "pretty": true, 51 | "removeComments": true, 52 | "sourceMap": true 53 | }, 54 | "include": [ 55 | "src/**/*" 56 | // to change 57 | ], 58 | "exclude": [ 59 | "node_modules", 60 | "src/**/*.spec.*" 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/client/redux/actions/action-creators.test.ts: -------------------------------------------------------------------------------- 1 | import { IEventClientSetError, EnumError } from "../../../../src/common/socketEventClient"; 2 | import { ON_SET_ERROR, EnumAction, ON_CLEAR_ERROR, SEND_ROOM_PLAYER_NAME, SEND_START_GAME, SEND_JOIN_ROOM, SEND_QUIT_ROOM, SEND_SUB_ROOMS_PLAYERS_NAME, SEND_UN_SUB_ROOMS_PLAYERS_NAME } from "../../../../src/client/redux/actions/action-creators"; 3 | 4 | it('ON_SET_ERROR', () => { 5 | const toSend: IEventClientSetError = { 6 | error_type: EnumError.PLAYING, 7 | msg: 'You are not allowed to join the game in progress. Please select another room or try later', 8 | }; 9 | 10 | const response = { 11 | type: EnumAction.ON_SET_ERROR, 12 | arg: toSend, 13 | } 14 | 15 | expect(ON_SET_ERROR(toSend)).toStrictEqual(response) 16 | }) 17 | 18 | it('ON_CLEAR_ERROR', () => { 19 | const responce = { 20 | type: EnumAction.ON_CLEAR_ERROR 21 | } 22 | expect(ON_CLEAR_ERROR()).toStrictEqual(responce) 23 | }) 24 | 25 | it('SEND_ROOM_PLAYER_NAME', () => { 26 | const responce = { 27 | type: EnumAction.SEND_ROOM_PLAYER_NAME 28 | } 29 | expect(SEND_ROOM_PLAYER_NAME()).toStrictEqual(responce) 30 | }) 31 | 32 | it('SEND_START_GAME', () => { 33 | const responce = { 34 | type: EnumAction.SEND_START_GAME 35 | } 36 | expect(SEND_START_GAME()).toStrictEqual(responce) 37 | }) 38 | 39 | it('SEND_JOIN_ROOM', () => { 40 | const playerName = 'testUser' 41 | const roomName = 'testRoom' 42 | 43 | const responce = { 44 | type: EnumAction.SEND_JOIN_ROOM, 45 | playerName, 46 | roomName, 47 | } 48 | expect(SEND_JOIN_ROOM(playerName, roomName)).toStrictEqual(responce) 49 | }) 50 | 51 | it('SEND_QUIT_ROOM', () => { 52 | const responce = { 53 | type: EnumAction.SEND_QUIT_ROOM 54 | } 55 | expect(SEND_QUIT_ROOM()).toStrictEqual(responce) 56 | }) 57 | 58 | it('SEND_SUB_ROOMS_PLAYERS_NAME', () => { 59 | const responce = { 60 | type: EnumAction.SEND_SUB_ROOMS_PLAYERS_NAME 61 | } 62 | expect(SEND_SUB_ROOMS_PLAYERS_NAME()).toStrictEqual(responce) 63 | }) 64 | 65 | it('SEND_UN_SUB_ROOMS_PLAYERS_NAME', () => { 66 | const responce = { 67 | type: EnumAction.SEND_UN_SUB_ROOMS_PLAYERS_NAME 68 | } 69 | expect(SEND_UN_SUB_ROOMS_PLAYERS_NAME()).toStrictEqual(responce) 70 | }) 71 | -------------------------------------------------------------------------------- /src/client/redux/reducer.ts: -------------------------------------------------------------------------------- 1 | import * as io from 'socket.io-client'; 2 | 3 | import { IRoomPlayersName, IRoomStateClient } from '@src/common/socketEventClient'; 4 | 5 | import { EnumAction, ReduxAction } from './actions/action-creators'; 6 | 7 | const SOCKET_URL = 'http://localhost:8000'; 8 | interface IDataState { 9 | readonly socket: SocketIOClient.Socket, 10 | readonly playerName: string | undefined, 11 | readonly roomName: string | undefined, 12 | 13 | readonly roomState: IRoomStateClient | undefined, 14 | readonly roomsPlayersName: IRoomPlayersName[], 15 | readonly errorMsg: string | undefined, 16 | } 17 | 18 | const initApp = (): IDataState => { 19 | const socket: SocketIOClient.Socket = io(SOCKET_URL); 20 | 21 | console.log("Init Reducer"); 22 | 23 | return { 24 | socket: socket, 25 | playerName: undefined, 26 | roomName: undefined, 27 | roomState: undefined, 28 | roomsPlayersName: [], 29 | errorMsg: undefined, 30 | }; 31 | }; 32 | 33 | const reducer = (state = initApp(), action: ReduxAction): IDataState => { 34 | switch (action.type) { 35 | case EnumAction.ON_SET_ROOM_STATE: 36 | return { 37 | ...state, 38 | roomState: action.arg.room, 39 | }; 40 | case EnumAction.ON_SET_ROOMS_PLAYERS_NAME: 41 | return { 42 | ...state, 43 | roomsPlayersName: action.arg.roomsPlayersName, 44 | }; 45 | case EnumAction.ON_SET_ERROR: 46 | return { 47 | ...state, 48 | errorMsg: action.arg.msg, 49 | playerName: undefined, 50 | roomName: undefined, 51 | }; 52 | case EnumAction.ON_CLEAR_ERROR: 53 | return { 54 | ...state, 55 | errorMsg: undefined, 56 | } 57 | case EnumAction.REFRESH: 58 | return { ...state }; 59 | case EnumAction.SEND_QUIT_ROOM: 60 | return { 61 | ...state, 62 | playerName: undefined, 63 | roomName: undefined, 64 | }; 65 | case EnumAction.SEND_JOIN_ROOM: 66 | return { 67 | ...state, 68 | playerName: action.playerName, 69 | roomName: action.roomName, 70 | }; 71 | default: 72 | return state; 73 | } 74 | } 75 | 76 | export { 77 | reducer, 78 | EnumAction, 79 | IDataState, 80 | initApp, 81 | }; 82 | -------------------------------------------------------------------------------- /src/client/socket-handler.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, Store } from 'redux'; 2 | 3 | import { 4 | ENUM_SOCKET_EVENT_CLIENT, 5 | IEventClientSetError, 6 | IEventClientSetRoomsPlayersName, 7 | IEventClientSetRoomState, 8 | } from '@src/common/socketEventClient'; 9 | import { 10 | ON_SET_ERROR, 11 | ON_SET_ROOM_STATE, 12 | ON_SET_ROOMS_PLAYERS_NAME, 13 | ReduxAction, REFRESH, ON_CLEAR_ERROR 14 | } from "@src/client/redux/actions/action-creators"; 15 | import { IDataState } from "@src/client/redux/reducer"; 16 | 17 | const cbSetRoomState = ( 18 | dispatch: Dispatch, 19 | ) => ( 20 | arg: IEventClientSetRoomState, 21 | ) => { 22 | console.log(ENUM_SOCKET_EVENT_CLIENT.SET_ROOM_STATE, arg); 23 | 24 | dispatch(ON_SET_ROOM_STATE(arg)); 25 | }; 26 | 27 | const cbOnConnection = ( 28 | dispatch: Dispatch, 29 | ) => async () => { 30 | console.log('connect'); 31 | 32 | dispatch(REFRESH()); 33 | }; 34 | 35 | const cbSetError = ( 36 | dispatch: Dispatch, 37 | ) => ( 38 | arg: IEventClientSetError, 39 | ) => { 40 | console.log(ENUM_SOCKET_EVENT_CLIENT.SET_ERROR, arg); 41 | 42 | dispatch(ON_SET_ERROR(arg)); 43 | }; 44 | 45 | const cbClearError = (dispatch: Dispatch) => () => { 46 | console.log(ENUM_SOCKET_EVENT_CLIENT.CLEAR_ERROR); 47 | 48 | dispatch(ON_CLEAR_ERROR()) 49 | } 50 | 51 | const cbSetRoomsPlayersName = ( 52 | dispatch: Dispatch, 53 | ) => ( 54 | arg: IEventClientSetRoomsPlayersName, 55 | ) => { 56 | console.log(ENUM_SOCKET_EVENT_CLIENT.SET_ROOMS_PLAYERS_NAME, arg); 57 | 58 | dispatch(ON_SET_ROOMS_PLAYERS_NAME(arg)); 59 | }; 60 | 61 | const onAll = (store: Store) => () => { 62 | 63 | const socket = store.getState().socket; 64 | const dispatch = store.dispatch; 65 | 66 | socket.on('connect', cbOnConnection(dispatch)); 67 | 68 | socket.on(ENUM_SOCKET_EVENT_CLIENT.SET_ROOM_STATE, cbSetRoomState(dispatch)); 69 | socket.on(ENUM_SOCKET_EVENT_CLIENT.SET_ROOMS_PLAYERS_NAME, cbSetRoomsPlayersName(dispatch)); 70 | socket.on(ENUM_SOCKET_EVENT_CLIENT.SET_ERROR, cbSetError(dispatch)); 71 | socket.on(ENUM_SOCKET_EVENT_CLIENT.CLEAR_ERROR, cbClearError(dispatch)) 72 | }; 73 | 74 | export { 75 | onAll, 76 | cbSetRoomState, 77 | cbOnConnection, 78 | cbSetError, 79 | cbSetRoomsPlayersName, 80 | cbClearError, 81 | }; 82 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-react" 5 | ], 6 | "rules": { 7 | "jsx-alignment": false, 8 | "arrow-parens": false, 9 | "arrow-return-shorthand": [ 10 | false 11 | ], 12 | "comment-format": [ 13 | true, 14 | "check-space" 15 | ], 16 | "import-blacklist": [ 17 | true 18 | ], 19 | "interface-over-type-literal": false, 20 | "interface-name": false, 21 | "max-line-length": [ 22 | true, 23 | 120 24 | ], 25 | "member-access": false, 26 | "member-ordering": [ 27 | true, 28 | { 29 | "order": "fields-first" 30 | } 31 | ], 32 | "newline-before-return": false, 33 | "no-any": false, 34 | "no-empty-interface": false, 35 | "no-import-side-effect": [ 36 | false 37 | ], 38 | "no-inferrable-types": [ 39 | true, 40 | "ignore-params", 41 | "ignore-properties" 42 | ], 43 | "no-invalid-this": [ 44 | true, 45 | "check-function-in-method" 46 | ], 47 | "no-null-keyword": false, 48 | "no-require-imports": false, 49 | "no-submodule-imports": [ 50 | true, 51 | "@src" 52 | ], 53 | "no-this-assignment": [ 54 | true, 55 | { 56 | "allow-destructuring": true 57 | } 58 | ], 59 | "no-trailing-whitespace": true, 60 | "no-unused-variable": [ 61 | true, 62 | "react" 63 | ], 64 | "object-literal-sort-keys": false, 65 | "object-literal-shorthand": false, 66 | "one-variable-per-declaration": [ 67 | false 68 | ], 69 | "only-arrow-functions": [ 70 | true, 71 | "allow-declarations" 72 | ], 73 | "ordered-imports": [ 74 | false 75 | ], 76 | "prefer-method-signature": false, 77 | "prefer-template": [ 78 | true, 79 | "allow-single-concat" 80 | ], 81 | "quotemark": [ 82 | true, 83 | "single", 84 | "jsx-double" 85 | ], 86 | "semicolon": [ 87 | false, 88 | "always" 89 | ], 90 | "triple-equals": [ 91 | true, 92 | "allow-null-check" 93 | ], 94 | "type-literal-delimiter": true, 95 | "typedef": [ 96 | true, 97 | "parameter", 98 | "property-declaration" 99 | ], 100 | "variable-name": [ 101 | true, 102 | "ban-keywords", 103 | "check-format", 104 | "allow-pascal-case", 105 | "allow-leading-underscore" 106 | ], 107 | // tslint-react 108 | "jsx-no-lambda": false 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "red-tetris", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "client-dev": "webpack-dev-server --mode development", 8 | "client-build": "webpack ./src/client/index.tsx ", 9 | "srv-dev": "nodemon --exec ts-node -r tsconfig-paths/register ./src/server/main.ts", 10 | "srv-build": "npm run client-build && ts-node -r tsconfig-paths/register ./src/server/main.ts", 11 | "coverage": "nyc npm run test-all", 12 | "deploy": "gh-pages -d build", 13 | "test:ci": "jest --coverage" 14 | }, 15 | "keywords": [], 16 | "author": "inovykov", 17 | "license": "", 18 | "dependencies": { 19 | "@babel/preset-react": "^7.0.0", 20 | "@babel/preset-typescript": "^7.3.3", 21 | "@types/express": "^4.17.0", 22 | "@types/react": "^16.8.23", 23 | "@types/react-dom": "^16.8.5", 24 | "@types/react-redux": "^6.0.14", 25 | "@types/react-router-dom": "^5.1.3", 26 | "@types/socket.io": "^2.1.2", 27 | "@types/socket.io-client": "^1.4.32", 28 | "@types/webpack-env": "^1.14.0", 29 | "awesome-typescript-loader": "^5.2.1", 30 | "babel-core": "^7.0.0-beta.3", 31 | "babel-loader": "^7.1.5", 32 | "babel-preset-env": "^1.7.0", 33 | "babel-preset-react": "^6.24.1", 34 | "express": "^4.17.1", 35 | "fireworks-canvas": "^2.4.0", 36 | "html-loader": "^0.5.5", 37 | "html-webpack-plugin": "^3.2.0", 38 | "node-sass": "^4.12.0", 39 | "nodemon": "^1.19.1", 40 | "react": "^16.8.6", 41 | "react-dom": "^16.8.6", 42 | "react-redux": "^6.0.1", 43 | "react-router": "^4.3.1", 44 | "react-router-dom": "^5.1.2", 45 | "redux": "^4.0.4", 46 | "redux-react-hook": "^3.3.2", 47 | "rxjs": "^6.5.2", 48 | "sass-loader": "^8.0.0", 49 | "socket.io": "^2.2.0", 50 | "socket.io-client": "^2.2.0", 51 | "ts-loader": "^5.4.5", 52 | "tslib": "^1.10.0", 53 | "typescript": "^3.5.3", 54 | "webpack": "^4.38.0", 55 | "webpack-cli": "^3.3.6", 56 | "webpack-dev-server": "^3.7.2" 57 | }, 58 | "devDependencies": { 59 | "@babel/code-frame": "^7.8.3", 60 | "@babel/core": "^7.6.0", 61 | "@babel/preset-env": "^7.6.0", 62 | "@types/chai": "^4.2.1", 63 | "@types/enzyme": "^3.10.4", 64 | "@types/jest": "^25.1.1", 65 | "@types/mocha": "^5.2.7", 66 | "babel-cli": "^6.26.0", 67 | "babel-watch": "^7.0.0", 68 | "chai": "^4.2.0", 69 | "chai-as-promised": "^5.3.0", 70 | "chai-equal-jsx": "^1.0.9", 71 | "css-loader": "^0.28.11", 72 | "enzyme": "^3.11.0", 73 | "file-loader": "^1.1.11", 74 | "gh-pages": "^1.2.0", 75 | "ignore-styles": "^5.0.1", 76 | "jest": "^25.1.0", 77 | "jsdom": "^11.12.0", 78 | "jsdom-global": "^3.0.2", 79 | "mocha": "^5.2.0", 80 | "nyc": "^13.3.0", 81 | "react-test-renderer": "^16.12.0", 82 | "style-loader": "^0.20.3", 83 | "ts-jest": "^25.2.0", 84 | "ts-node": "^7.0.1", 85 | "tsconfig-paths": "^3.8.0", 86 | "tsconfig-paths-webpack-plugin": "^3.2.0", 87 | "tslint": "^5.19.0", 88 | "tslint-react": "^3.6.0" 89 | }, 90 | "jest": { 91 | "transform": { 92 | ".(ts|tsx)": "ts-jest" 93 | }, 94 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 95 | "moduleFileExtensions": [ 96 | "ts", 97 | "tsx", 98 | "js" 99 | ], 100 | "moduleNameMapper": { 101 | "@src/(.*)$": "/src/$1" 102 | }, 103 | "coverageReporters": [ 104 | "json", 105 | "html" 106 | ], 107 | "coverageThreshold": { 108 | "global": { 109 | "branches": 70, 110 | "functions": 70, 111 | "lines": 70, 112 | "statements": 70 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/server/client.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import { BehaviorSubject, Subscription } from 'rxjs'; 3 | 4 | import { GamesDispatcher } from '@src/server/GamesDispatcher'; 5 | import { ADD_PLAYER, DEL_PLAYER, START_GAME, MOVE_PIECE } from '@src/server/Game'; 6 | 7 | import { 8 | ENUM_SOCKET_EVENT_SERVER, 9 | IEventServerSubRoomsPlayersName, 10 | IEventServerJoinRoom, 11 | IEventServerQuitRoom, 12 | IEventServerUnSubRoomsPlayersName, 13 | IEventServerStartGame, 14 | IEventServerMovePiece 15 | } from '@src/common/socketEventServer'; 16 | import { IRoomPlayersName, IEventClientSetRoomsPlayersName, ENUM_SOCKET_EVENT_CLIENT } from '@src/common/socketEventClient'; 17 | 18 | const gamesDispatcher = new GamesDispatcher(); 19 | const roomsPlayersNameSub: BehaviorSubject = new BehaviorSubject([]); 20 | 21 | const handleClient = (socket: Socket) => { 22 | let subRoomsPlayersName: Subscription | undefined = undefined; 23 | 24 | socket.on(ENUM_SOCKET_EVENT_SERVER.JOIN_ROOM, (arg: IEventServerJoinRoom) => { 25 | console.log(ENUM_SOCKET_EVENT_SERVER.JOIN_ROOM, arg); 26 | 27 | gamesDispatcher.dispatch({ 28 | roomName: arg.roomName, 29 | actionRoom: ADD_PLAYER(arg.playerName, socket), 30 | }); 31 | }); 32 | 33 | socket.on(ENUM_SOCKET_EVENT_SERVER.SUB_ROOMS_PLAYERS_NAME, (arg: IEventServerSubRoomsPlayersName) => { 34 | console.log(ENUM_SOCKET_EVENT_SERVER.SUB_ROOMS_PLAYERS_NAME, arg); 35 | 36 | if (subRoomsPlayersName && !subRoomsPlayersName.closed) { 37 | return 38 | } 39 | 40 | subRoomsPlayersName = roomsPlayersNameSub.subscribe((roomsPlayersName: IRoomPlayersName[]) => { 41 | const sendSetRoomsPlayersName = (sock: Socket, ag: IEventClientSetRoomsPlayersName) => { 42 | sock.emit(ENUM_SOCKET_EVENT_CLIENT.SET_ROOMS_PLAYERS_NAME, ag); 43 | }; 44 | 45 | sendSetRoomsPlayersName(socket, { 46 | roomsPlayersName: roomsPlayersName, 47 | }); 48 | }); 49 | }) 50 | 51 | socket.on(ENUM_SOCKET_EVENT_SERVER.UN_SUB_ROOMS_PLAYERS_NAME, (arg: IEventServerUnSubRoomsPlayersName) => { 52 | if (subRoomsPlayersName !== undefined) { 53 | subRoomsPlayersName.unsubscribe(); 54 | } 55 | }); 56 | 57 | socket.on(ENUM_SOCKET_EVENT_SERVER.QUIT_ROOM, (arg: IEventServerQuitRoom) => { 58 | console.log(ENUM_SOCKET_EVENT_SERVER.QUIT_ROOM, arg); 59 | 60 | gamesDispatcher.dispatch({ 61 | socketId: socket.id, 62 | actionRoom: DEL_PLAYER(socket.id), 63 | }); 64 | }); 65 | 66 | socket.on('disconnect', () => { 67 | console.log('disconnect', socket.id); 68 | 69 | gamesDispatcher.dispatch({ 70 | socketId: socket.id, 71 | actionRoom: DEL_PLAYER(socket.id), 72 | }); 73 | 74 | if (subRoomsPlayersName !== undefined) { 75 | subRoomsPlayersName.unsubscribe(); 76 | } 77 | 78 | socket.removeAllListeners(); 79 | socket.disconnect(true); 80 | }); 81 | 82 | socket.on(ENUM_SOCKET_EVENT_SERVER.START_GAME, (arg: IEventServerStartGame) => { 83 | console.log(ENUM_SOCKET_EVENT_SERVER.START_GAME, arg); 84 | 85 | gamesDispatcher.dispatch({ 86 | roomName: arg.roomName, 87 | actionRoom: START_GAME(), 88 | }); 89 | }) 90 | 91 | socket.on(ENUM_SOCKET_EVENT_SERVER.MOVE_PIECE, (arg: IEventServerMovePiece) => { 92 | const { roomName, move } = arg 93 | gamesDispatcher.dispatch({ 94 | roomName, 95 | actionRoom: MOVE_PIECE(socket.id, move), 96 | }) 97 | }) 98 | } 99 | 100 | setInterval(() => { 101 | roomsPlayersNameSub.next( 102 | gamesDispatcher.games.map((r) => ({ 103 | roomName: r.state.roomName, 104 | playerNames: r.state.players.map((p) => p.playerName), 105 | }), 106 | ), 107 | ); 108 | }, 1000); 109 | 110 | export { handleClient } 111 | -------------------------------------------------------------------------------- /src/client/redux/socketMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { EnumAction, ReduxAction } from './actions/action-creators'; 2 | import { 3 | ENUM_SOCKET_EVENT_SERVER, 4 | IEventServerJoinRoom, 5 | IEventServerStartGame, IEventServerSubRoomsPlayersName, IEventServerQuitRoom, IEventServerUnSubRoomsPlayersName, IEventServerMovePiece 6 | } from '@src/common/socketEventServer'; 7 | import { IDataState } from "@src/client/redux/reducer"; 8 | 9 | const isPlaying = (state: IDataState): boolean => { 10 | const { playerName, roomState } = state 11 | if (!playerName || !roomState) { 12 | return false; 13 | } 14 | 15 | const player = roomState.players.find((p) => p.playerName === playerName); 16 | 17 | return player ? player.playing : false; 18 | }; 19 | 20 | const sendJoinRoom = (socket: SocketIOClient.Socket, arg: IEventServerJoinRoom) => { 21 | socket.emit(ENUM_SOCKET_EVENT_SERVER.JOIN_ROOM, arg); 22 | }; 23 | 24 | const sendQuitRoom = (socket: SocketIOClient.Socket, arg: IEventServerQuitRoom) => { 25 | socket.emit(ENUM_SOCKET_EVENT_SERVER.QUIT_ROOM, arg); 26 | }; 27 | 28 | const sendSubRoomsPlayersName = (socket: SocketIOClient.Socket, arg: IEventServerSubRoomsPlayersName) => { 29 | socket.emit(ENUM_SOCKET_EVENT_SERVER.SUB_ROOMS_PLAYERS_NAME, arg); 30 | }; 31 | 32 | const sendUnSubRoomsPlayersName = (socket: SocketIOClient.Socket, arg: IEventServerUnSubRoomsPlayersName) => { 33 | socket.emit(ENUM_SOCKET_EVENT_SERVER.UN_SUB_ROOMS_PLAYERS_NAME, arg); 34 | }; 35 | 36 | const sendStartGame = (socket: SocketIOClient.Socket, arg: IEventServerStartGame): void => { 37 | socket.emit(ENUM_SOCKET_EVENT_SERVER.START_GAME, arg); 38 | }; 39 | 40 | const sendRoomPlayerName = (socket: SocketIOClient.Socket, arg: IEventServerJoinRoom) => { 41 | socket.emit(ENUM_SOCKET_EVENT_SERVER.JOIN_ROOM, arg); 42 | }; 43 | 44 | const sendMovePiece = (socket: SocketIOClient.Socket, arg: IEventServerMovePiece) => { 45 | socket.emit(ENUM_SOCKET_EVENT_SERVER.MOVE_PIECE, arg) 46 | } 47 | 48 | const socketMiddleware = (store: any) => (next: any) => (action: ReduxAction) => { 49 | 50 | const state: IDataState = store.getState(); 51 | 52 | switch (action.type) { 53 | case EnumAction.SEND_ROOM_PLAYER_NAME: 54 | if (state.socket !== undefined 55 | && state.roomName !== undefined 56 | && state.playerName !== undefined 57 | ) { 58 | sendRoomPlayerName(state.socket, { 59 | roomName: state.roomName, 60 | playerName: state.playerName, 61 | }); 62 | } 63 | break; 64 | case EnumAction.SEND_START_GAME: 65 | if (state.socket !== undefined && state.roomName !== undefined) { 66 | sendStartGame(state.socket, { 67 | roomName: state.roomName, 68 | }); 69 | } 70 | break; 71 | case EnumAction.SEND_JOIN_ROOM: 72 | if (state.socket !== undefined) { 73 | sendJoinRoom(state.socket, { 74 | playerName: action.playerName, 75 | roomName: action.roomName, 76 | }); 77 | } 78 | break; 79 | case EnumAction.SEND_QUIT_ROOM: 80 | if (state.socket !== undefined 81 | && state.roomName !== undefined 82 | && state.playerName !== undefined 83 | ) { 84 | sendQuitRoom(state.socket, { 85 | playerName: state.playerName, 86 | roomName: state.roomName, 87 | }); 88 | } 89 | break; 90 | case EnumAction.SEND_SUB_ROOMS_PLAYERS_NAME: 91 | if (state.socket !== undefined) { 92 | sendSubRoomsPlayersName(state.socket, {}); 93 | } 94 | break; 95 | case EnumAction.SEND_UN_SUB_ROOMS_PLAYERS_NAME: 96 | if (state.socket !== undefined) { 97 | sendUnSubRoomsPlayersName(state.socket, {}); 98 | } 99 | break; 100 | case EnumAction.SEND_MOVE_PIECE: 101 | if (state.socket !== undefined && state.roomName !== undefined && isPlaying(state)) { 102 | const { roomName } = state 103 | const { move } = action 104 | sendMovePiece(state.socket, { 105 | roomName, 106 | move, 107 | }) 108 | } 109 | break; 110 | default: 111 | break; 112 | } 113 | 114 | return next(action); 115 | }; 116 | 117 | export { socketMiddleware }; 118 | -------------------------------------------------------------------------------- /src/client/redux/actions/action-creators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IEventClientSetError, 3 | IEventClientSetRoomsPlayersName, 4 | IEventClientSetRoomState, 5 | } from '@src/common/socketEventClient'; 6 | import { ENUM_PIECES_MOVE } from '@src/common/grid-piece-handler'; 7 | 8 | interface IAction { 9 | readonly type: EnumAction 10 | } 11 | 12 | // ON_SET_ERROR 13 | 14 | interface IOnSetError extends IAction { 15 | readonly type: EnumAction.ON_SET_ERROR 16 | readonly arg: IEventClientSetError 17 | } 18 | 19 | const ON_SET_ERROR = (arg: IEventClientSetError): IOnSetError => { 20 | return { 21 | type: EnumAction.ON_SET_ERROR, 22 | arg: arg, 23 | }; 24 | }; 25 | 26 | // ON_CLEAR_ERROR 27 | 28 | interface IOnClearError extends IAction { 29 | readonly type: EnumAction.ON_CLEAR_ERROR 30 | } 31 | 32 | const ON_CLEAR_ERROR = (): IOnClearError => { 33 | return { 34 | type: EnumAction.ON_CLEAR_ERROR, 35 | }; 36 | }; 37 | 38 | // ON_SET_ROOM_STATE 39 | 40 | interface IOnSetRoomeState extends IAction { 41 | readonly type: EnumAction.ON_SET_ROOM_STATE 42 | readonly arg: IEventClientSetRoomState 43 | } 44 | 45 | const ON_SET_ROOM_STATE = (arg: IEventClientSetRoomState): IOnSetRoomeState => { 46 | return { 47 | type: EnumAction.ON_SET_ROOM_STATE, 48 | arg: arg, 49 | }; 50 | }; 51 | 52 | // ON_SET_ROOMS_PLAYERS_NAME 53 | 54 | interface IOnSetRoomesPlayersName extends IAction { 55 | readonly type: EnumAction.ON_SET_ROOMS_PLAYERS_NAME 56 | readonly arg: IEventClientSetRoomsPlayersName 57 | } 58 | 59 | const ON_SET_ROOMS_PLAYERS_NAME = (arg: IEventClientSetRoomsPlayersName): IOnSetRoomesPlayersName => { 60 | return { 61 | type: EnumAction.ON_SET_ROOMS_PLAYERS_NAME, 62 | arg: arg, 63 | }; 64 | }; 65 | 66 | // REFRESH 67 | 68 | interface IRefresh { 69 | readonly type: EnumAction.REFRESH, 70 | } 71 | 72 | const REFRESH = (): IRefresh => { 73 | return { 74 | type: EnumAction.REFRESH, 75 | }; 76 | }; 77 | 78 | // SEND_ROOM_PLAYER_NAME 79 | 80 | interface ISendRoomPlayerName { 81 | readonly type: EnumAction.SEND_ROOM_PLAYER_NAME, 82 | } 83 | 84 | const SEND_ROOM_PLAYER_NAME = (): ISendRoomPlayerName => { 85 | return { 86 | type: EnumAction.SEND_ROOM_PLAYER_NAME, 87 | }; 88 | }; 89 | 90 | // SEND_START_GAME 91 | 92 | interface ISendStartGame extends IAction { 93 | readonly type: EnumAction.SEND_START_GAME, 94 | } 95 | 96 | const SEND_START_GAME = (): ISendStartGame => { 97 | return { 98 | type: EnumAction.SEND_START_GAME, 99 | }; 100 | }; 101 | 102 | // SEND_JOIN_ROOM 103 | 104 | interface ISendJoinRoom { 105 | readonly type: EnumAction.SEND_JOIN_ROOM, 106 | 107 | readonly playerName: string, 108 | readonly roomName: string, 109 | } 110 | 111 | const SEND_JOIN_ROOM = (playerName: string, roomName: string): ISendJoinRoom => { 112 | return { 113 | type: EnumAction.SEND_JOIN_ROOM, 114 | 115 | playerName, 116 | roomName, 117 | }; 118 | }; 119 | 120 | // SEND_QUIT_ROOM 121 | 122 | interface ISendQuitRoom { 123 | readonly type: EnumAction.SEND_QUIT_ROOM, 124 | } 125 | 126 | const SEND_QUIT_ROOM = (): ISendQuitRoom => { 127 | return { 128 | type: EnumAction.SEND_QUIT_ROOM, 129 | }; 130 | }; 131 | 132 | // SEND_SUB_ROOMS_PLAYERS_NAME 133 | 134 | interface ISendSubRoomsPlayersName { 135 | readonly type: EnumAction.SEND_SUB_ROOMS_PLAYERS_NAME, 136 | } 137 | 138 | const SEND_SUB_ROOMS_PLAYERS_NAME = (): ISendSubRoomsPlayersName => { 139 | return { 140 | type: EnumAction.SEND_SUB_ROOMS_PLAYERS_NAME, 141 | }; 142 | }; 143 | 144 | // SEND_UN_SUB_ROOMS_PLAYERS_NAME 145 | 146 | interface ISendUnSubRoomsPlayersName { 147 | readonly type: EnumAction.SEND_UN_SUB_ROOMS_PLAYERS_NAME, 148 | } 149 | 150 | const SEND_UN_SUB_ROOMS_PLAYERS_NAME = (): ISendUnSubRoomsPlayersName => { 151 | return { 152 | type: EnumAction.SEND_UN_SUB_ROOMS_PLAYERS_NAME, 153 | }; 154 | }; 155 | 156 | // SEND_MOVE_PIECE 157 | 158 | interface ISendMovePiece extends IAction { 159 | readonly type: EnumAction.SEND_MOVE_PIECE, 160 | readonly move: ENUM_PIECES_MOVE, 161 | } 162 | 163 | const SEND_MOVE_PIECE = (move: ENUM_PIECES_MOVE): ISendMovePiece => ({ 164 | type: EnumAction.SEND_MOVE_PIECE, 165 | move, 166 | }) 167 | 168 | enum EnumAction { 169 | ON_SET_ROOM_STATE, 170 | ON_SET_ROOMS_PLAYERS_NAME, 171 | ON_SET_ERROR, 172 | ON_CLEAR_ERROR, 173 | SEND_ROOM_PLAYER_NAME, 174 | SEND_JOIN_ROOM, 175 | SEND_QUIT_ROOM, 176 | SEND_START_GAME, 177 | SEND_SUB_ROOMS_PLAYERS_NAME, 178 | SEND_UN_SUB_ROOMS_PLAYERS_NAME, 179 | REFRESH, 180 | SEND_MOVE_PIECE, 181 | } 182 | 183 | type ReduxAction = IOnSetRoomeState 184 | | IOnSetRoomesPlayersName 185 | | IRefresh 186 | | IOnSetError 187 | | ISendRoomPlayerName 188 | | ISendStartGame 189 | | ISendJoinRoom 190 | | ISendQuitRoom 191 | | ISendUnSubRoomsPlayersName 192 | | ISendSubRoomsPlayersName 193 | | ISendMovePiece 194 | | IOnClearError 195 | 196 | export { 197 | EnumAction, 198 | ReduxAction, 199 | ON_SET_ROOM_STATE, 200 | ON_SET_ROOMS_PLAYERS_NAME, 201 | REFRESH, 202 | ON_SET_ERROR, 203 | ON_CLEAR_ERROR, 204 | SEND_ROOM_PLAYER_NAME, 205 | SEND_START_GAME, 206 | SEND_JOIN_ROOM, 207 | SEND_QUIT_ROOM, 208 | SEND_SUB_ROOMS_PLAYERS_NAME, 209 | SEND_UN_SUB_ROOMS_PLAYERS_NAME, 210 | SEND_MOVE_PIECE, 211 | }; 212 | -------------------------------------------------------------------------------- /test/server/Game.test.ts: -------------------------------------------------------------------------------- 1 | import { ADD_PLAYER, DEL_PLAYER, Game, MOVE_PIECE, START_GAME, EnumActionRoomStore } from '../../src/server/Game'; 2 | import { ENUM_PIECES_MOVE } from '../../src/common/grid-piece-handler'; 3 | import { IPlayer } from '../../src/common/ITypeRoomManager'; 4 | import { ENUM_SOCKET_EVENT_CLIENT, IEventClientSetError, EnumError } from '../../src/common/socketEventClient'; 5 | 6 | const roomName = 'test'; 7 | let game: any = undefined; 8 | let socket: any = undefined; 9 | const user = { 10 | playerName: 'playerName', 11 | socket, 12 | type: EnumActionRoomStore.ADD_PLAYER, 13 | } 14 | 15 | beforeEach(() => { 16 | game = new Game(roomName); 17 | 18 | socket = { 19 | emit: jest.fn((...arg: any[]) => { }), 20 | id: '123', 21 | }; 22 | }) 23 | 24 | afterEach(() => { 25 | game.unsubscribe(); 26 | }) 27 | 28 | it('ADD_PLAYER', () => { 29 | 30 | game.dispatch(ADD_PLAYER(user.playerName, socket)); 31 | 32 | expect(game.state.players.some((pl: IPlayer) => pl.playerName === user.playerName)).toBe(true); 33 | 34 | expect(ADD_PLAYER(user.playerName, user.socket)).toHaveProperty("playerName"); 35 | 36 | // add same player name 37 | game.dispatch(ADD_PLAYER(user.playerName, socket)); 38 | const socketErrDuplicateName: IEventClientSetError = { 39 | error_type: EnumError.PLAYER_SAME_NAME, 40 | msg: 'A player has already this name in this room', 41 | }; 42 | expect(socket.emit.mock.calls).toContainEqual([ENUM_SOCKET_EVENT_CLIENT.SET_ERROR, socketErrDuplicateName]); 43 | 44 | // add player to ongoing game 45 | game.dispatch(START_GAME()) 46 | game.dispatch(ADD_PLAYER('bertaFly', socket)); 47 | const socketErrGameOn: IEventClientSetError = { 48 | error_type: EnumError.PLAYING, 49 | msg: 'You are not allowed to join the game in progress. Please select another room or try later', 50 | }; 51 | expect(socket.emit.mock.calls).toContainEqual([ENUM_SOCKET_EVENT_CLIENT.SET_ERROR, socketErrGameOn]); 52 | }); 53 | 54 | it('DEL_PLAYER', () => { 55 | game.dispatch(ADD_PLAYER(user.playerName, socket)); 56 | expect(DEL_PLAYER(socket.id)).toHaveProperty('type'); 57 | 58 | game.dispatch(DEL_PLAYER('qwerty')); 59 | game.dispatch(DEL_PLAYER(socket.id)); 60 | 61 | expect(game.state.players).toHaveLength(0); 62 | 63 | const socket2: any = { 64 | emit: (...arg: any[]) => { }, 65 | id: '321', 66 | } 67 | game.dispatch(ADD_PLAYER(user.playerName, socket)); 68 | game.dispatch(ADD_PLAYER('playerName2', socket2)); 69 | game.dispatch(DEL_PLAYER(socket.id)); 70 | expect(game.state.players).toHaveLength(1); 71 | }); 72 | 73 | it('START_GAME', () => { 74 | game.dispatch(START_GAME()); 75 | 76 | const startGameActionObject = { 77 | type: EnumActionRoomStore.START_GAME 78 | } 79 | expect(START_GAME()).toStrictEqual(startGameActionObject); 80 | expect(game.state.playing).toBe(true); 81 | }); 82 | 83 | it('MOVE_PIECE', () => { 84 | const mooveDownActionObject = { 85 | type: EnumActionRoomStore.MOVE_PIECE, 86 | socketId: socket.id, 87 | move: ENUM_PIECES_MOVE.DOWN, 88 | } 89 | expect(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.DOWN)).toStrictEqual(mooveDownActionObject); 90 | const prevState = game.state 91 | game.dispatch(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.DOWN)); 92 | expect(game.state !== prevState).toBe(true); 93 | 94 | const mooveLeftActionObject = { 95 | type: EnumActionRoomStore.MOVE_PIECE, 96 | socketId: socket.id, 97 | move: ENUM_PIECES_MOVE.LEFT, 98 | } 99 | expect(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.LEFT)).toStrictEqual(mooveLeftActionObject); 100 | 101 | const mooveRightActionObject = { 102 | type: EnumActionRoomStore.MOVE_PIECE, 103 | socketId: socket.id, 104 | move: ENUM_PIECES_MOVE.RIGHT, 105 | } 106 | expect(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.RIGHT)).toStrictEqual(mooveRightActionObject); 107 | 108 | const mooveDropActionObject = { 109 | type: EnumActionRoomStore.MOVE_PIECE, 110 | socketId: socket.id, 111 | move: ENUM_PIECES_MOVE.DROP, 112 | } 113 | expect(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.DROP)).toStrictEqual(mooveDropActionObject); 114 | 115 | const mooveSwitchActionObject = { 116 | type: EnumActionRoomStore.MOVE_PIECE, 117 | socketId: socket.id, 118 | move: ENUM_PIECES_MOVE.SWITCH, 119 | } 120 | expect(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.SWITCH)).toStrictEqual(mooveSwitchActionObject); 121 | 122 | game.dispatch(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.RIGHT)); 123 | game.dispatch(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.LEFT)); 124 | game.dispatch(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.ROT_RIGHT)); 125 | game.dispatch(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.SWITCH)); 126 | game.dispatch(MOVE_PIECE(socket.id, ENUM_PIECES_MOVE.DROP)); 127 | }); 128 | 129 | it('Unknow action', () => { 130 | const prevState = game.state 131 | game.dispatch((() => ({ type: 'UNKNOWN_ACTION' }))); 132 | expect(prevState).toStrictEqual(game.state); 133 | }) 134 | 135 | it('game other', () => { 136 | // check constructor with room name 137 | expect(game.state.roomName).toBe(roomName); 138 | 139 | // check state with no players 140 | expect(game.nbPlayer()).toBe(0); 141 | expect(game.hasSocketId(socket.id)).toBe(false); 142 | 143 | // check state after player joined 144 | game.dispatch(ADD_PLAYER(user.playerName, socket)); 145 | expect(game.nbPlayer()).toBe(1); 146 | expect(game.hasSocketId(socket.id)).toBe(true); 147 | }); 148 | -------------------------------------------------------------------------------- /test/common/grid-piece-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ENUM_PIECES, 3 | getPiece, 4 | PIECES_DESCR, 5 | calcScore, 6 | ENUM_PIECES_MOVE, 7 | movePose, 8 | IPos, 9 | hasCollision, 10 | gridInit, 11 | ENUM_COLLISION_TYPE, 12 | updatePiecePosOnRot, 13 | GRID_WIDTH, 14 | gridAddWall, 15 | placePiecePreview, 16 | placePiece, 17 | moveCollision, 18 | gridDelLine, 19 | updatePiecePos 20 | } from '../../src/common/grid-piece-handler'; 21 | 22 | it('getPiece', () => { 23 | expect(getPiece(ENUM_PIECES.n1, 1)).toStrictEqual(PIECES_DESCR[0][1].piece) 24 | 25 | expect(getPiece(ENUM_PIECES.n2)).toStrictEqual(PIECES_DESCR[1][0].piece) 26 | }) 27 | 28 | it('calcScore', () => { 29 | expect(calcScore(-1)).toBe(0) 30 | expect(calcScore(1)).toBe(20) 31 | expect(calcScore(2)).toBe(40) 32 | expect(calcScore(3)).toBe(80) 33 | expect(calcScore(10)).toBe(160) 34 | }) 35 | 36 | it('movePose', () => { 37 | const posPiece: IPos = { 38 | x: 5, 39 | y: 1, 40 | } 41 | 42 | expect(movePose(posPiece, ENUM_PIECES_MOVE.DOWN)).toStrictEqual({ x: posPiece.x, y: posPiece.y + 1 }) 43 | expect(movePose(posPiece, ENUM_PIECES_MOVE.LEFT)).toStrictEqual({ x: posPiece.x - 1, y: posPiece.y }) 44 | expect(movePose(posPiece, ENUM_PIECES_MOVE.RIGHT)).toStrictEqual({ x: posPiece.x + 1, y: posPiece.y }) 45 | expect(movePose(posPiece, ENUM_PIECES_MOVE.DROP)).toStrictEqual(posPiece) 46 | }) 47 | 48 | it('hasCollision', () => { 49 | const grid = gridInit() 50 | // long horizontal line 51 | const piece = { 52 | num: ENUM_PIECES.n1, 53 | rot: 0, 54 | } 55 | // end right 56 | const rightWall = { x: 9, y: 4 } 57 | 58 | const newPieceDescr = getPiece(piece.num, piece.rot); 59 | const newPosR = movePose(rightWall, ENUM_PIECES_MOVE.RIGHT); 60 | expect(hasCollision(grid, newPieceDescr, newPosR)).toBe(ENUM_COLLISION_TYPE.WALL_RIGHT); 61 | 62 | // end left 63 | const leftWall = { x: 0, y: 4 } 64 | const newPosL = movePose(leftWall, ENUM_PIECES_MOVE.LEFT); 65 | expect(hasCollision(grid, newPieceDescr, newPosL)).toBe(ENUM_COLLISION_TYPE.WALL_LEFT); 66 | 67 | //bottom 68 | const bottomWall = { x: 5, y: 23 } 69 | const newPosBottom = movePose(bottomWall, ENUM_PIECES_MOVE.DOWN); 70 | expect(hasCollision(grid, newPieceDescr, newPosBottom)).toBe(ENUM_COLLISION_TYPE.WALL_BOTTOM); 71 | 72 | // piece 73 | const bottomPiece = { x: 3, y: 22 } 74 | const occupiedGrid = [...grid] 75 | occupiedGrid.splice(22, 2, [0, 0, 0, 0, 4, 4, 0, 0, 0, 0], [0, 0, 0, 0, 4, 4, 0, 0, 0, 0]) 76 | const newPosBottomPiece = movePose(bottomPiece, ENUM_PIECES_MOVE.RIGHT); 77 | expect(hasCollision(occupiedGrid, newPieceDescr, newPosBottomPiece)).toBe(ENUM_COLLISION_TYPE.PIECE); 78 | 79 | // allright 80 | const posPis = { x: 2, y: 5 } 81 | const newPos = movePose(posPis, ENUM_PIECES_MOVE.DOWN); 82 | expect(hasCollision(grid, newPieceDescr, newPos)).toBe(undefined); 83 | }) 84 | 85 | it('updatePiecePosOnRot', () => { 86 | // vertical line 87 | expect(updatePiecePosOnRot(gridInit(), { x: 1, y: 8 }, { num: ENUM_PIECES.n1, rot: 1 }).piecePlaced).toBe(false) 88 | }) 89 | 90 | it('gridAddWall', () => { 91 | const grid = gridInit() 92 | const gridWithPenalty = gridInit() 93 | gridWithPenalty.splice(23, 1, Array(GRID_WIDTH).fill(ENUM_PIECES.wall)) 94 | 95 | expect(gridAddWall(grid, 1)).toStrictEqual(gridWithPenalty) 96 | }) 97 | 98 | it('placePiecePreview', () => { 99 | const gridWithPreview = gridInit() 100 | gridWithPreview.splice(23, 1, [ENUM_PIECES.preview, ENUM_PIECES.preview, ENUM_PIECES.preview, ENUM_PIECES.preview, 0, 0, 0, 0, 0, 0]) 101 | expect(placePiecePreview(gridInit(), { num: 1, rot: 2 }, { x: 0, y: 4 })).toStrictEqual(gridWithPreview) 102 | }) 103 | 104 | it('moveCollision', () => { 105 | const grid = gridInit() 106 | expect(moveCollision(grid, { x: -1, y: 4 }, { num: 1, rot: 0 }).x).toBe(0) 107 | expect(moveCollision(grid, { x: 7, y: 4 }, { num: 1, rot: 0 }).x).toBe(6) 108 | expect(moveCollision(grid, { x: 5, y: 24 }, { num: 1, rot: 0 }).y).toBe(22) 109 | }) 110 | 111 | it('placePiece', () => { 112 | const gridWithPreview = gridInit() 113 | gridWithPreview.splice(23, 1, [ENUM_PIECES.preview, ENUM_PIECES.preview, ENUM_PIECES.preview, ENUM_PIECES.preview, 0, 0, 0, 0, 0, 0]) 114 | expect(placePiece(gridInit(), { num: 1, rot: 2 }, { x: 0, y: 21 }, true)).toStrictEqual(gridWithPreview) 115 | 116 | const gridWithPiece = gridInit() 117 | gridWithPiece.splice(23, 1, [ENUM_PIECES.n1, ENUM_PIECES.n1, ENUM_PIECES.n1, ENUM_PIECES.n1, 0, 0, 0, 0, 0, 0]) 118 | expect(placePiece(gridInit(), { num: 1, rot: 2 }, { x: 0, y: 21 })).toStrictEqual(gridWithPiece) 119 | }) 120 | 121 | it('gridDelLine', () => { 122 | const gridWithLine = gridInit() 123 | gridWithLine.splice(23, 1, Array(GRID_WIDTH).fill(ENUM_PIECES.n1)) 124 | 125 | expect(gridDelLine(gridWithLine)).toStrictEqual({ grid: gridInit(), nbLineToAdd: 1 }) 126 | 127 | const gridLineWall = gridInit() 128 | gridLineWall.splice(21, 3, Array(GRID_WIDTH).fill(ENUM_PIECES.n1), Array(GRID_WIDTH).fill(ENUM_PIECES.n1), Array(GRID_WIDTH).fill(ENUM_PIECES.wall)) 129 | expect(gridDelLine(gridLineWall)).toStrictEqual({ grid: gridInit(), nbLineToAdd: 2 }) 130 | }) 131 | 132 | it('updatePiecePos', () => { 133 | expect(updatePiecePos(gridInit(), { x: 0, y: 4 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.ROT_RIGHT).piecePlaced).toBe(false) 134 | 135 | expect(updatePiecePos(gridInit(), { x: 0, y: 4 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.RIGHT).piecePlaced).toBe(false) 136 | 137 | expect(updatePiecePos(gridInit(), { x: 0, y: 4 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.LEFT).pos).toStrictEqual({ x: 0, y: 4 }) 138 | expect(updatePiecePos(gridInit(), { x: 1, y: 4 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.LEFT).pos).toStrictEqual({ x: 0, y: 4 }) 139 | 140 | expect(updatePiecePos(gridInit(), { x: 0, y: 4 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.DROP).piecePlaced).toBe(true) 141 | 142 | expect(updatePiecePos(gridInit(), { x: 0, y: 4 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.DOWN).piecePlaced).toBe(false) 143 | 144 | const takenGrid = gridInit() 145 | takenGrid.splice(22, 2, [0, 0, 0, 4, 4, 0, 0, 0, 0, 0], [0, 0, 0, 4, 4, 0, 0, 0, 0, 0]) 146 | expect(updatePiecePos(takenGrid, { x: 0, y: 21 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.DOWN).piecePlaced).toBe(true) 147 | 148 | expect(updatePiecePos(gridInit(), { x: 0, y: 4 }, { num: ENUM_PIECES.n1, rot: 0 }, ENUM_PIECES_MOVE.SWITCH).piecePlaced).toBe(false) 149 | }) 150 | 151 | // moveHandler 152 | -------------------------------------------------------------------------------- /src/client/component/home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState, useCallback, useEffect } from 'react'; 3 | import { useHistory, generatePath } from 'react-router-dom'; 4 | import { useDispatch, useMappedState } from 'redux-react-hook'; 5 | 6 | import routes from '@src/client/config/routes'; 7 | import { IRoomPlayersName } from '@src/common/socketEventClient'; 8 | 9 | import { IDataState } from '../redux/reducer'; 10 | import { SEND_SUB_ROOMS_PLAYERS_NAME, SEND_UN_SUB_ROOMS_PLAYERS_NAME } from '../redux/actions/action-creators'; 11 | 12 | type Error = { 13 | field: string 14 | msg: string 15 | } 16 | 17 | export const Home = () => { 18 | const [roomName, setRoomName] = useState(''); 19 | const [playerName, setPlayerName] = useState(''); 20 | const [errors, setErrors] = useState([]); 21 | 22 | const dispatch = useDispatch(); 23 | 24 | const mapState = useCallback( 25 | (state: IDataState) => ({ 26 | roomsPlayersName: state.roomsPlayersName, 27 | }), 28 | [], 29 | ); 30 | const { roomsPlayersName } = useMappedState(mapState); 31 | 32 | const history = useHistory() 33 | 34 | useEffect(() => { 35 | dispatch(SEND_SUB_ROOMS_PLAYERS_NAME()); 36 | return () => { 37 | dispatch(SEND_UN_SUB_ROOMS_PLAYERS_NAME()); 38 | } 39 | }, []) 40 | 41 | const getRoom = (): IRoomPlayersName | undefined => roomsPlayersName.find(room => room.roomName === roomName) 42 | 43 | const checkOcupiedNames = (): boolean => { 44 | if (roomName && playerName) { 45 | if (roomsPlayersName.length) { 46 | const roomToCheck = getRoom() 47 | if (roomToCheck && roomToCheck.playerNames.includes(playerName)) { 48 | return true 49 | } else { 50 | return false 51 | } 52 | } 53 | } 54 | return false 55 | } 56 | 57 | const validateInput = (field: string, value: string) => { 58 | if (value === '') { 59 | setErrors((prevErrors) => [...(errors.every(err => err.field !== field) ? prevErrors : prevErrors.filter(e => e.field !== field)), { field: field, msg: 'This field is required' }]) 60 | } else if (value.length < 3) { 61 | setErrors((prevErrors) => [...(errors.every(err => err.field !== field) ? prevErrors : prevErrors.filter(e => e.field !== field)), { field: field, msg: `${field} length should have at least 3 letters` }]) 62 | } else if (value.length > 20) { 63 | setErrors((prevErrors) => [...(errors.every(err => err.field !== field) ? prevErrors : prevErrors.filter(e => e.field !== field)), { field: field, msg: `${field} length should be less than 20 letters` }]) 64 | } else { 65 | setErrors((prevErrors) => checkOcupiedNames() ? [...prevErrors, { field: field, msg: `Entered player name has already taken in the '${roomName}'. Change room or player` }] : prevErrors.filter(error => error.field !== field)) 66 | } 67 | } 68 | 69 | const onChange = (event: React.ChangeEvent) => { 70 | if (event.target.name === 'Room name') { 71 | setRoomName(event.target.value) 72 | } else { 73 | setPlayerName(event.target.value) 74 | } 75 | event.persist() 76 | validateInput(event.target.name, event.target.value) 77 | } 78 | 79 | const getRoomError = (): string => { 80 | if (errors.length) { 81 | const err = errors.find(error => error.field === 'Room name') 82 | return err ? err.msg : '' 83 | } 84 | return '' 85 | } 86 | 87 | const getPlayerError = (): string => { 88 | if (errors.length) { 89 | const err = errors.find(error => error.field === 'Player name') 90 | return err ? err.msg : '' 91 | } 92 | return '' 93 | } 94 | 95 | const onSubmit = (event: React.FormEvent) => { 96 | event.preventDefault(); 97 | 98 | history.push(generatePath(`${routes.game}[${playerName}]`, { roomName })) 99 | } 100 | 101 | const selectedRoom = getRoom() 102 | 103 | return ( 104 |
105 |
106 |
107 |

108 | Tetris 109 |

110 |
111 | 112 | ) => { 113 | validateInput('Room name', e.target.value) 114 | }} title="This is a required field. At least 3 letters" maxLength={20} /> 115 | {getRoomError() && ({getRoomError()})} 116 |
117 |
118 | 119 | ) => { 120 | validateInput('Player name', e.target.value) 121 | }} title="This is a required field. At least 3 letters" maxLength={20} /> 122 | {getPlayerError() && ({getPlayerError()})} 123 |
124 |
125 | 126 |
127 | {roomsPlayersName.length ? ( 128 |
129 | 130 | or you can select a room from list 131 | 132 | {roomsPlayersName.map(room => )} 136 | 137 | {selectedRoom ? ( 138 | <> 139 | Currently in room: 140 |
    141 | {selectedRoom.playerNames.map(player =>
  • {player}
  • )} 142 |
143 | 144 | ) : null} 145 |
146 | ) : null} 147 |
148 |
149 |
150 | 151 | ) 152 | }; 153 | -------------------------------------------------------------------------------- /src/client/style.scss: -------------------------------------------------------------------------------- 1 | $maroon: rgb(202, 60, 60); 2 | $green: rgb(28, 184, 65);; 3 | 4 | body { 5 | font-family: 'Roboto', sans-serif; 6 | } 7 | 8 | .text-center { 9 | text-align: center; 10 | } 11 | 12 | .text-white { 13 | color: white; 14 | } 15 | 16 | .splash-container { 17 | background: url(./assets/space.png); 18 | z-index: 1; 19 | width: 100%; 20 | height: 100%; 21 | min-height: fit-content; 22 | top: 0; 23 | left: 0; 24 | position: absolute; 25 | } 26 | 27 | .splash { 28 | width: 80%; 29 | height: 50%; 30 | margin: auto; 31 | position: absolute; 32 | top: 100px; 33 | left: 0; 34 | bottom: 0; 35 | right: 0; 36 | } 37 | 38 | .login-form, 39 | .server-offline-msg { 40 | padding: 0 2rem 2rem; 41 | background: rgba(255, 255, 255, 0.8); 42 | box-shadow: inset 0px 0px 13px 1px #999; 43 | border: 1px solid white; 44 | border-radius: 4px; 45 | } 46 | 47 | .error-text { 48 | color: $maroon; 49 | } 50 | 51 | .button-success, 52 | .button-error, 53 | .button-warning, 54 | .button-secondary { 55 | color: white; 56 | border-radius: 4px; 57 | } 58 | 59 | .button-success { 60 | background: $green; 61 | } 62 | 63 | .button-error { 64 | background: $maroon; 65 | } 66 | 67 | .button-warning { 68 | background: rgb(223, 117, 20); /* this is an orange */ 69 | } 70 | 71 | .button-secondary { 72 | background: rgb(66, 184, 221); /* this is a light blue */ 73 | } 74 | 75 | .pure-control-group { 76 | margin-bottom: 1rem; 77 | } 78 | 79 | .server-offline-msg { 80 | width: 50%; 81 | height: 40%; 82 | display: flex; 83 | align-items: center; 84 | justify-content: center; 85 | 86 | .text { 87 | font-size: 1.2rem; 88 | font-weight: 700; 89 | text-align: center; 90 | } 91 | } 92 | 93 | .room-options-container { 94 | margin-top: 1rem; 95 | display: flex; 96 | flex-direction: column; 97 | 98 | .room-option { 99 | text-align: center; 100 | width: 100%; 101 | border: 1px solid #999; 102 | padding: .5em 1em; 103 | box-sizing: border-box; 104 | border-radius: 4px; 105 | background: #eee; 106 | } 107 | 108 | .selected-room-option { 109 | background: #bfbfbf; 110 | } 111 | 112 | .players-in-room { 113 | margin: 0; 114 | padding-left: 1rem; 115 | } 116 | } 117 | 118 | .game-page { 119 | padding: 1rem; 120 | display: flex; 121 | justify-content: space-evenly; 122 | max-width: 1000px; 123 | width: 100%; 124 | background: #eee; 125 | margin: auto; 126 | border-radius: 4px; 127 | 128 | .left-column { 129 | max-width: 350px; 130 | } 131 | 132 | .welcome { 133 | margin-bottom: 0.5rem; 134 | display: flex; 135 | flex-direction: column; 136 | text-align: center; 137 | } 138 | 139 | .legend { 140 | display: flex; 141 | flex-direction: column; 142 | border: 1px solid #595959; 143 | border-radius: 4px; 144 | padding: 1rem; 145 | background: #ffff; 146 | } 147 | 148 | .instructions { 149 | margin: 0.5rem 0; 150 | } 151 | 152 | .game-controlls { 153 | margin: 1rem 0; 154 | display: flex; 155 | justify-content: space-between; 156 | 157 | .play, 158 | .exit { 159 | padding: 0.5rem; 160 | border-radius: 4px; 161 | border: none; 162 | width: 40%; 163 | } 164 | 165 | .play { 166 | background: #2ecc71; 167 | 168 | &:hover { 169 | background: #27ae60; 170 | } 171 | } 172 | 173 | .exit { 174 | background: #95a5a6; 175 | 176 | &:hover { 177 | background: #7f8c8d; 178 | } 179 | } 180 | } 181 | 182 | .user-description { 183 | display: flex; 184 | flex-direction: column; 185 | text-align: center; 186 | } 187 | } 188 | 189 | .row { 190 | display: flex; 191 | flex-direction: row; 192 | } 193 | 194 | .right-column { 195 | display: flex; 196 | justify-content: space-evenly; 197 | flex: 0 1 40%; 198 | } 199 | 200 | .preview-grid, 201 | .game-grid { 202 | display: flex; 203 | flex-direction: column; 204 | } 205 | 206 | .preview-grid { 207 | height: fit-content; 208 | border: 3px solid #2c3e50; 209 | } 210 | 211 | .my-score { 212 | margin-bottom: 0.3rem; 213 | text-transform: uppercase; 214 | font-weight: 700; 215 | color: #d03f2f; 216 | } 217 | 218 | .casePlayer { 219 | height: 20px; 220 | width: 20px; 221 | border: 3px; 222 | } 223 | 224 | .color-0 { 225 | box-sizing: border-box; 226 | border: 1px solid #34495e; 227 | background-color: #bdc3c7; 228 | } 229 | 230 | .color-1 { 231 | box-sizing: border-box; 232 | 233 | border: 1px solid #16a085; 234 | background-color: #1abc9c; 235 | } 236 | 237 | .color-3 { 238 | box-sizing: border-box; 239 | 240 | border: 1px solid #2980b9; 241 | background-color: #3498db; 242 | } 243 | 244 | .color-2 { 245 | box-sizing: border-box; 246 | 247 | background-color: #e67e22; 248 | border: 1px solid #d35400; 249 | } 250 | 251 | .color-4 { 252 | box-sizing: border-box; 253 | 254 | border: 1px solid #f39c12; 255 | background-color: #f1c40f; 256 | } 257 | 258 | .color-5 { 259 | box-sizing: border-box; 260 | 261 | border: solid #27ae60; 262 | background-color: #2ecc71; 263 | } 264 | 265 | .color-6 { 266 | box-sizing: border-box; 267 | 268 | border: solid #8e44ad; 269 | background-color: #9b59b6; 270 | } 271 | 272 | .color-7 { 273 | box-sizing: border-box; 274 | 275 | border: solid #c0392b; 276 | background-color: #e74c3c; 277 | } 278 | 279 | // penalty line 280 | .color-8 { 281 | box-sizing: border-box; 282 | 283 | border: solid #2c3e50; 284 | background-color: #34495e; 285 | } 286 | 287 | // preview 288 | .color-9 { 289 | box-sizing: border-box; 290 | 291 | background-color: #9fa7ad; 292 | border: 1px solid #5e686e; 293 | } 294 | 295 | .color-10 { 296 | box-sizing: border-box; 297 | 298 | border: solid #888888; 299 | background-color: #777777; 300 | } 301 | 302 | .audio { 303 | position: fixed; 304 | bottom: 10px; 305 | left: 10px; 306 | } 307 | 308 | .modal { 309 | position: fixed; 310 | top: 0; 311 | left: 0; 312 | width: 100%; 313 | height: 100%; 314 | z-index: 1; 315 | } 316 | 317 | .modal-main { 318 | position: fixed; 319 | background: white; 320 | min-width: 320px; 321 | width: 25%; 322 | height: auto; 323 | top:50%; 324 | left:50%; 325 | transform: translate(-50%,-50%); 326 | padding: 1rem; 327 | border-radius: 4px; 328 | text-align: center; 329 | 330 | button { 331 | padding: 0.5rem 1rem; 332 | outline: none; 333 | border: none; 334 | background: #34495e; 335 | border-radius: 4px; 336 | text-transform: capitalize; 337 | color: white; 338 | } 339 | 340 | h2 { 341 | margin: 0; 342 | } 343 | 344 | } 345 | 346 | .modal-color-default { 347 | background: #f2ca26; 348 | } 349 | 350 | .modal-color-lost { 351 | background: #ee8377; 352 | } 353 | 354 | .modal-color-win { 355 | background: #82e3aa; 356 | } 357 | 358 | .display-block { 359 | display: block; 360 | } 361 | 362 | .display-none { 363 | display: none; 364 | } 365 | 366 | .opponents { 367 | margin: 1rem 0; 368 | 369 | .game-page { 370 | flex-wrap: wrap; 371 | justify-content: space-evenly 372 | } 373 | 374 | .game-grid { 375 | flex: 0 1 22%; 376 | } 377 | 378 | h2 { 379 | text-align: center; 380 | color: #fff; 381 | } 382 | 383 | p { 384 | margin: 0; 385 | } 386 | } 387 | 388 | .home-btn { 389 | padding: 0.5rem; 390 | border-radius: 4px; 391 | border: none; 392 | background: #95a5a6; 393 | color: #2c3e50; 394 | text-decoration: none; 395 | 396 | &:visited { 397 | color: inherit; 398 | } 399 | } 400 | 401 | #canvas { 402 | width: 100%; 403 | height: 100%; 404 | position: absolute; 405 | top: 0; 406 | bottom: 0; 407 | left: 0; 408 | right: 0; 409 | background: rgba(0,0,0,0.5); 410 | z-index: 0; 411 | } 412 | 413 | .author { 414 | position: fixed; 415 | display: block; 416 | right: 10px; 417 | bottom: 10px; 418 | padding: 0.25rem; 419 | background: rgba(238, 238, 238, 0.5); 420 | border-radius: 4px; 421 | } 422 | 423 | @media (min-width: 48em) { 424 | .splash { 425 | width: 50%; 426 | height: 50%; 427 | } 428 | 429 | .server-offline-msg { 430 | width: 35%; 431 | height: 35%; 432 | 433 | .text { 434 | font-size: 2rem; 435 | } 436 | } 437 | } 438 | 439 | @media only screen and (max-width: 650px) { 440 | .pure-form-message-inline { 441 | margin-left: 11rem; 442 | padding-left: 0; 443 | padding-top: 0.5rem; 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /src/server/Game.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import { BehaviorSubject, Subscription } from 'rxjs'; 3 | import Timeout = NodeJS.Timeout; 4 | 5 | import { Player } from '@src/server/Player'; 6 | 7 | import { 8 | GRID_HEIGHT, initPose, gridInit, ENUM_PIECES_MOVE, ENUM_PIECES, moveHandler 9 | } from '@src/common/grid-piece-handler'; 10 | 11 | import { IRoomState } from '@src/common/ITypeRoomManager'; 12 | import { 13 | ENUM_SOCKET_EVENT_CLIENT, 14 | IEventClientSetRoomState, 15 | IEventClientSetError, 16 | EnumError, 17 | } from '@src/common/socketEventClient'; 18 | import { updateWin } from './updateWin'; 19 | import { Piece } from './Piece'; 20 | 21 | // -- ACTION 22 | 23 | enum EnumActionRoomStore { 24 | ADD_PLAYER, 25 | DEL_PLAYER, 26 | START_GAME, 27 | MOVE_PIECE, 28 | } 29 | 30 | interface IActionRoom { 31 | readonly type: EnumActionRoomStore; 32 | } 33 | 34 | // ADD_PLAYER 35 | 36 | interface IActionRoomAddPlayer extends IActionRoom { 37 | readonly type: EnumActionRoomStore.ADD_PLAYER; 38 | 39 | readonly playerName: string; 40 | readonly socket: Socket; 41 | } 42 | 43 | const ADD_PLAYER = (playerName: string, socket: Socket): IActionRoomAddPlayer => { 44 | return { 45 | type: EnumActionRoomStore.ADD_PLAYER, 46 | playerName, 47 | socket, 48 | }; 49 | }; 50 | 51 | const reducerAddPlayer = ( 52 | state: IRoomState, 53 | action: IActionRoomAddPlayer, 54 | ): IRoomState => { 55 | 56 | const { playerName, socket } = action; 57 | const { players, playing } = state 58 | 59 | if (playing) { 60 | const toSend: IEventClientSetError = { 61 | error_type: EnumError.PLAYING, 62 | msg: 'You are not allowed to join the game in progress. Please select another room or try later', 63 | }; 64 | 65 | socket.emit(ENUM_SOCKET_EVENT_CLIENT.SET_ERROR, toSend); 66 | return state; 67 | } 68 | 69 | const hasPlayerName = players.some((p) => p.playerName === playerName); 70 | if (hasPlayerName) { 71 | const toSend: IEventClientSetError = { 72 | error_type: EnumError.PLAYER_SAME_NAME, 73 | msg: 'A player has already this name in this room', 74 | }; 75 | 76 | socket.emit(ENUM_SOCKET_EVENT_CLIENT.SET_ERROR, toSend); 77 | return state; 78 | } 79 | 80 | socket.emit(ENUM_SOCKET_EVENT_CLIENT.CLEAR_ERROR) 81 | 82 | const isMaster = state.players.length === 0; 83 | const player = Player.newPlayer(playerName, socket, isMaster, GRID_HEIGHT); 84 | 85 | return { 86 | ...state, 87 | players: [...state.players, { 88 | ...player, 89 | }, 90 | ], 91 | }; 92 | }; 93 | 94 | // DEL_PLAYER 95 | 96 | interface IActionRoomDelPlayer extends IActionRoom { 97 | readonly type: EnumActionRoomStore.DEL_PLAYER; 98 | 99 | readonly socket: string; 100 | } 101 | 102 | const DEL_PLAYER = (socket: string): IActionRoomDelPlayer => { 103 | return { 104 | type: EnumActionRoomStore.DEL_PLAYER, 105 | socket, 106 | }; 107 | }; 108 | 109 | const reducerDelPlayer = ( 110 | state: IRoomState, 111 | action: IActionRoomDelPlayer, 112 | ): IRoomState => { 113 | 114 | const { socket } = action; 115 | 116 | let players = state.players.filter((p) => p.socket.id !== socket); 117 | 118 | if (players.length > 0) { 119 | if (!players.some((p) => p.isMaster)) { 120 | const [frst, ...rest] = players; 121 | players = [{ ...frst, isMaster: true }, ...rest]; 122 | } 123 | 124 | players = updateWin(players); 125 | } 126 | 127 | return { 128 | ...state, 129 | playing: players.some((p) => p.playing), 130 | players: players, 131 | }; 132 | }; 133 | 134 | // START_GAME 135 | 136 | interface IActionStartGame extends IActionRoom { 137 | readonly type: EnumActionRoomStore.START_GAME; 138 | } 139 | 140 | const START_GAME = (): IActionStartGame => { 141 | return { 142 | type: EnumActionRoomStore.START_GAME 143 | } 144 | } 145 | 146 | const reducerStartGame = (state: IRoomState): IRoomState => { 147 | const flow = Piece.genFlow(20); 148 | 149 | return { 150 | ...state, 151 | playing: true, 152 | players: state.players.map((p) => ({ 153 | ...p, 154 | playing: true, 155 | isSpectator: false, 156 | flow: flow, 157 | win: false, 158 | lost: false, 159 | gameOver: false, 160 | score: 0, 161 | nbLineCompleted: 0, 162 | posPiece: initPose(), 163 | grid: gridInit(), 164 | })), 165 | }; 166 | } 167 | 168 | // MOVE_PIECE 169 | 170 | interface IActionMovePiece extends IActionRoom { 171 | readonly type: EnumActionRoomStore.MOVE_PIECE; 172 | 173 | readonly socketId: string, 174 | readonly move: ENUM_PIECES_MOVE, 175 | } 176 | 177 | const MOVE_PIECE = (socketId: string, move: ENUM_PIECES_MOVE): IActionMovePiece => { 178 | return { 179 | type: EnumActionRoomStore.MOVE_PIECE, 180 | 181 | socketId, 182 | move, 183 | }; 184 | }; 185 | 186 | const reducerMovePiece = (state: IRoomState, action: IActionMovePiece): IRoomState => { 187 | const { move, socketId } = action; 188 | 189 | let newplayers = moveHandler(state.players, move, socketId); 190 | 191 | // set for player a game over to check who's won 192 | newplayers = newplayers.map((p) => { 193 | if (p.grid[3].some((pi) => pi !== ENUM_PIECES.empty)) { 194 | return { 195 | ...p, 196 | playing: false, 197 | gameOver: true, 198 | }; 199 | } 200 | return p; 201 | }); 202 | 203 | // update player win 204 | newplayers = updateWin(newplayers); 205 | 206 | // add flow if need 207 | if (newplayers.some((p) => p.flow.length < 9)) { 208 | const flowToAdd = Piece.genFlow(20); 209 | 210 | newplayers = state.players.map((p) => ({ ...p, flow: [...p.flow, ...flowToAdd] })); 211 | } 212 | 213 | return { 214 | ...state, 215 | players: newplayers, 216 | playing: newplayers.some((p) => p.playing), 217 | }; 218 | } 219 | 220 | // -- ACTION ROOM 221 | 222 | type ActionRoom = IActionRoomAddPlayer | IActionRoomDelPlayer | IActionStartGame | IActionMovePiece 223 | 224 | // -- REDUCER 225 | 226 | const reducer = (state: IRoomState, action: ActionRoom): IRoomState => { 227 | switch (action.type) { 228 | case EnumActionRoomStore.ADD_PLAYER: 229 | return reducerAddPlayer(state, action); 230 | case EnumActionRoomStore.DEL_PLAYER: 231 | return reducerDelPlayer(state, action); 232 | case EnumActionRoomStore.START_GAME: 233 | return reducerStartGame(state); 234 | case EnumActionRoomStore.MOVE_PIECE: 235 | return reducerMovePiece(state, action); 236 | default: 237 | return state; 238 | } 239 | }; 240 | 241 | class Game { 242 | state: IRoomState; 243 | stateSub: BehaviorSubject; 244 | sub: Subscription; 245 | intervalDownPiece: Timeout; 246 | 247 | constructor(roomName: string) { 248 | const sendSetRoomState = (socket: Socket, arg: IEventClientSetRoomState) => { 249 | socket.emit(ENUM_SOCKET_EVENT_CLIENT.SET_ROOM_STATE, arg); 250 | }; 251 | 252 | this.state = { 253 | roomName: roomName, 254 | players: [], 255 | playing: false, 256 | }; 257 | 258 | this.stateSub = new BehaviorSubject(this.state); 259 | 260 | this.sub = this.stateSub.subscribe((state: IRoomState) => { 261 | const ToSend = { 262 | room: { 263 | ...state, 264 | players: state.players.map((pl) => ({ 265 | ...pl, 266 | socket: undefined, 267 | })), 268 | }, 269 | }; 270 | 271 | state.players.forEach((p) => { 272 | sendSetRoomState(p.socket, ToSend); 273 | }); 274 | }); 275 | 276 | this.intervalDownPiece = setInterval(() => { 277 | this.state.players.forEach((p) => { 278 | if (p.playing) { 279 | this.dispatch(MOVE_PIECE(p.socket.id, ENUM_PIECES_MOVE.DOWN)); 280 | } 281 | }); 282 | }, 500); 283 | } 284 | 285 | public dispatch(action: ActionRoom): void { 286 | const newState = reducer(this.state, action); 287 | 288 | if (newState !== this.state) { 289 | this.state = newState; 290 | this.stateSub.next(this.state); 291 | } 292 | } 293 | 294 | public hasSocketId(socketId: string): boolean { 295 | return this.state.players.some((p) => p.socket.id === socketId); 296 | } 297 | 298 | public nbPlayer(): number { 299 | return this.state.players.length; 300 | } 301 | 302 | public unsubscribe(): void { 303 | this.sub.unsubscribe(); 304 | clearInterval(this.intervalDownPiece); 305 | } 306 | 307 | } 308 | 309 | export { 310 | Game, 311 | ActionRoom, 312 | ADD_PLAYER, 313 | DEL_PLAYER, 314 | START_GAME, 315 | MOVE_PIECE, 316 | EnumActionRoomStore, 317 | }; 318 | -------------------------------------------------------------------------------- /src/client/component/game.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | 4 | import { useHistory, useParams, Link } from 'react-router-dom'; 5 | import { useDispatch, useMappedState } from 'redux-react-hook'; 6 | 7 | import { SEND_QUIT_ROOM, SEND_START_GAME, SEND_MOVE_PIECE, SEND_JOIN_ROOM } from '@src/client/redux/actions/action-creators'; 8 | import routes from '../config/routes'; 9 | import { IDataState } from '../redux/reducer'; 10 | import { placePiece, ENUM_PIECES_MOVE, ENUM_PIECES, placePiecePreview } from '@src/common/grid-piece-handler'; 11 | 12 | import { Modal } from './Modal' 13 | import { Opponents } from './Opponents'; 14 | import { FireWorks } from './Fireworks'; 15 | import { IPlayerClient } from '@src/common/socketEventClient'; 16 | 17 | const mp3 = require('@src/client/assets/Original_Tetris_theme.mp3'); 18 | 19 | const keySpace = 32; 20 | const keyLeft = 37; 21 | const keyUp = 38; 22 | const keyRight = 39; 23 | const keyDown = 40; 24 | const keyS = 83; 25 | 26 | const initPreviewGrid = (): ENUM_PIECES[][] => { 27 | const lineBuild = [...(Array(4).fill(ENUM_PIECES.empty))]; 28 | 29 | const tetriminoField = [ 30 | lineBuild, 31 | lineBuild, 32 | lineBuild, 33 | lineBuild, 34 | ]; 35 | 36 | return [ 37 | ...tetriminoField, 38 | ...tetriminoField, 39 | ...tetriminoField, 40 | ]; 41 | }; 42 | 43 | export const Game = () => { 44 | const [endGameModal, showEndGameModal] = useState(false) 45 | const history = useHistory(); 46 | const { roomName } = useParams(); 47 | 48 | const mapState = useCallback( 49 | (state: IDataState) => { 50 | const { roomState, errorMsg } = state; 51 | if (roomState === undefined) { 52 | return { 53 | playing: false, 54 | isMaster: false, 55 | player: undefined, 56 | opponents: [] as IPlayerClient[], 57 | errorMsg, 58 | }; 59 | } 60 | 61 | const player = roomState.players.find((p) => p.playerName === state.playerName); 62 | 63 | return { 64 | playing: roomState.playing, 65 | isMaster: player ? player.isMaster : false, 66 | player, 67 | grid: player ? player.grid : undefined, 68 | opponents: roomState.players.filter((p) => p.playerName !== state.playerName), 69 | errorMsg, 70 | }; 71 | }, 72 | [], 73 | ); 74 | 75 | const dispatch = useDispatch(); 76 | 77 | const onUserKeyPress = useCallback(event => { 78 | const { keyCode } = event; 79 | 80 | event.preventDefault(); 81 | 82 | switch (keyCode) { 83 | case keyLeft: 84 | dispatch(SEND_MOVE_PIECE(ENUM_PIECES_MOVE.LEFT)); 85 | break; 86 | 87 | case keyUp: 88 | dispatch(SEND_MOVE_PIECE(ENUM_PIECES_MOVE.ROT_RIGHT)); 89 | break; 90 | 91 | case keyRight: 92 | dispatch(SEND_MOVE_PIECE(ENUM_PIECES_MOVE.RIGHT)); 93 | break; 94 | 95 | case keyDown: 96 | dispatch(SEND_MOVE_PIECE(ENUM_PIECES_MOVE.DOWN)); 97 | break; 98 | 99 | case keySpace: 100 | dispatch(SEND_MOVE_PIECE(ENUM_PIECES_MOVE.DROP)); 101 | break; 102 | 103 | case keyS: 104 | dispatch(SEND_MOVE_PIECE(ENUM_PIECES_MOVE.SWITCH)); 105 | break; 106 | } 107 | }, []); 108 | 109 | useEffect(() => { 110 | window.addEventListener('keydown', onUserKeyPress); 111 | 112 | return () => { 113 | window.removeEventListener('keydown', onUserKeyPress); 114 | }; 115 | }, [onUserKeyPress]); 116 | 117 | const { isMaster, playing, player, grid, opponents, errorMsg } = useMappedState(mapState); 118 | 119 | useEffect(() => { 120 | if (player) { 121 | const { win, lost } = player 122 | if (!playing && (win || lost)) { 123 | showEndGameModal(true); 124 | } 125 | } 126 | }, [playing, player]) 127 | 128 | useEffect(() => { 129 | if (roomName) { 130 | const curRoom = roomName.split('[')[0] 131 | const curPlayer = roomName.split('[')[1].substr(0, roomName.split('[')[1].length - 1) 132 | if (!player && curRoom && curPlayer) { 133 | dispatch(SEND_JOIN_ROOM(curPlayer, curRoom)); 134 | return 135 | } 136 | } 137 | }, [roomName, player]); 138 | 139 | if (errorMsg) { 140 | return ( 141 |
142 |
143 |
147 |

{errorMsg}

148 | Home 149 |
150 |
151 |
152 | ) 153 | } 154 | 155 | if (player === undefined) { 156 | return ( 157 |
158 |
159 |
160 | Waiting server ... 161 |
162 |
163 |
164 | ); 165 | } 166 | 167 | const renderGrid = (): React.ReactNode[] | null => { 168 | if (grid) { 169 | const { flow, posPiece } = player 170 | const gridWithPiece = playing ? placePiece(placePiecePreview(grid, flow[0], posPiece), flow[0], posPiece) : grid 171 | const fieldToRender = gridWithPiece.slice(4) 172 | return fieldToRender.map((line: number[], i: number) => 173 |
174 | {line.map((el: number, j: number) =>
)} 175 |
, 176 | ) 177 | } 178 | return null 179 | } 180 | 181 | //FLOW 182 | let previewGrid = initPreviewGrid() 183 | const piecesRender = player.flow.slice(1, 4); 184 | 185 | if (player.flow.length && !player.lost && !player.win && !player.isSpectator) { 186 | for (let i = 0; i < piecesRender.length; i += 1) { 187 | const piece = piecesRender[i]; 188 | previewGrid = placePiece(previewGrid, piece, { x: 0, y: i * 4 }); 189 | } 190 | } 191 | 192 | return ( 193 |
194 |

TETRIS

195 |
196 |
197 |
198 | Welcome {player ? player.playerName : ''}! 199 |
200 | 201 |
202 | 203 | You are {isMaster ? 'an Owner of' : 'a Guest in'} this room 204 | 205 |
206 | 207 |
208 | 212 | {!playing && isMaster ? () : null} 215 |
216 | 217 |

Instructions

218 |
219 | Move right: ➡️ 220 | Move left: ⬅️ 221 | Move down: ⬇️ 222 | Rotate: ⬆️ 223 | Place a piece: space 224 | Switch a piece: S️ 225 | Once your opponent complete a line you will get -line and a penalty line at bottom, but you can reduce it by complete a line on your side 226 | 1 line - 20 227 | 2 line - 40 228 | 3 line - 80 229 | 4 line - 160 230 |
231 | 232 | 233 | Lines completed: {player ? player.nbLineCompleted : ''} 234 | 235 |
236 |
237 | 238 |
239 | Score: {player ? player.score : ''} 240 | {renderGrid()} 241 |
242 | 243 | {playing ? (
244 | {previewGrid.map((line: number[], i: number) => 245 |
246 | {line.map((el: number, j: number) =>
)} 247 |
248 | )} 249 |
) : null} 250 | 251 |
252 |
253 | 254 | {opponents && opponents.length ? ( 255 | 256 | ) : null} 257 | 258 |
291 | ) 292 | }; 293 | -------------------------------------------------------------------------------- /src/common/grid-piece-handler.ts: -------------------------------------------------------------------------------- 1 | import { IPlayer } from './ITypeRoomManager'; 2 | 3 | interface IPos { 4 | readonly x: number, 5 | readonly y: number 6 | } 7 | 8 | interface IPiece { 9 | readonly num: number, 10 | readonly rot: number, 11 | } 12 | 13 | enum ENUM_PIECES { 14 | empty = 0, 15 | n1 = 1, 16 | n2 = 2, 17 | n3 = 3, 18 | n4 = 4, 19 | n5 = 5, 20 | n6 = 6, 21 | n7 = 7, 22 | wall, 23 | preview, 24 | } 25 | 26 | enum ENUM_PIECES_MOVE { 27 | ROT_RIGHT = 'ROT_RIGHT', 28 | RIGHT = 'RIGHT', 29 | LEFT = 'LEFT', 30 | DOWN = 'DOWN', 31 | DROP = 'DROP', 32 | SWITCH = 'SWITCH', 33 | } 34 | 35 | // --- GRID 36 | 37 | const GRID_HEIGHT = 24; 38 | const GRID_WIDTH = 10; 39 | 40 | enum ENUM_COLLISION_TYPE { 41 | PIECE = 'collision_piece', 42 | WALL_RIGHT = 'collision_wall_right', 43 | WALL_LEFT = 'collision_wall_left', 44 | WALL_BOTTOM = 'collision_wall_bottom', 45 | } 46 | 47 | const PRIO_COLLISION = [ 48 | ENUM_COLLISION_TYPE.PIECE, 49 | ENUM_COLLISION_TYPE.WALL_BOTTOM, 50 | ENUM_COLLISION_TYPE.WALL_RIGHT, 51 | ENUM_COLLISION_TYPE.WALL_LEFT, 52 | ]; 53 | 54 | const gridInit = (gridHeight = GRID_HEIGHT): ENUM_PIECES[][] => { 55 | return Array(gridHeight).fill(0).map(() => 56 | Array(GRID_WIDTH).fill(ENUM_PIECES.empty), 57 | ); 58 | }; 59 | 60 | const initPose = () => { 61 | return { 62 | x: Math.floor(GRID_WIDTH / 2), 63 | y: 0, 64 | }; 65 | }; 66 | 67 | const getPiece = (pieces: ENUM_PIECES, rot = 0): ENUM_PIECES[][] => PIECES_DESCR[pieces - 1][rot].piece; 68 | 69 | const calcScore = (nbLine: number): number => { 70 | if (nbLine <= 0) { 71 | return 0; 72 | } 73 | if (nbLine === 1) { 74 | return 20; 75 | } 76 | if (nbLine === 2) { 77 | return 40; 78 | } 79 | if (nbLine === 3) { 80 | return 80; 81 | } 82 | return 160; 83 | }; 84 | 85 | const movePose = (posPiece: IPos, move: ENUM_PIECES_MOVE): IPos => { 86 | switch (move) { 87 | case ENUM_PIECES_MOVE.DOWN: 88 | return { x: posPiece.x, y: posPiece.y + 1 }; 89 | case ENUM_PIECES_MOVE.LEFT: 90 | return { x: posPiece.x - 1, y: posPiece.y }; 91 | case ENUM_PIECES_MOVE.RIGHT: 92 | return { x: posPiece.x + 1, y: posPiece.y }; 93 | default: 94 | return posPiece; 95 | } 96 | } 97 | 98 | const hasCollision = (grid: ENUM_PIECES[][], pieces: ENUM_PIECES[][], loc: IPos): ENUM_COLLISION_TYPE | undefined => { 99 | let collisionType: ENUM_COLLISION_TYPE | undefined = undefined; 100 | 101 | const comp = (col1: ENUM_COLLISION_TYPE | undefined, col2: ENUM_COLLISION_TYPE): boolean => { 102 | if (col1 === undefined) { 103 | return true; 104 | } 105 | return PRIO_COLLISION.indexOf(col1) < PRIO_COLLISION.indexOf(col2); 106 | } 107 | 108 | pieces.forEach((line, y) => line.forEach((nb, x) => { 109 | const newX = x + loc.x; 110 | const newY = y + loc.y; 111 | 112 | if (newY >= grid.length && nb) { 113 | if (comp(collisionType, ENUM_COLLISION_TYPE.WALL_BOTTOM)) { 114 | collisionType = ENUM_COLLISION_TYPE.WALL_BOTTOM; 115 | } 116 | } else if (newX < 0 && nb) { 117 | if (comp(collisionType, ENUM_COLLISION_TYPE.WALL_LEFT)) { 118 | collisionType = ENUM_COLLISION_TYPE.WALL_LEFT; 119 | } 120 | } else if (newX >= GRID_WIDTH && nb) { 121 | if (comp(collisionType, ENUM_COLLISION_TYPE.WALL_RIGHT)) { 122 | collisionType = ENUM_COLLISION_TYPE.WALL_RIGHT; 123 | } 124 | } else if (nb && grid[newY][newX]) { 125 | if (comp(collisionType, ENUM_COLLISION_TYPE.PIECE)) { 126 | collisionType = ENUM_COLLISION_TYPE.PIECE; 127 | } 128 | } 129 | })) 130 | 131 | return collisionType; 132 | } 133 | 134 | const updatePiecePosOnRot = (grid: ENUM_PIECES[][], posPiece: IPos, piece: IPiece): { piecePlaced: boolean; pos: IPos; piece: IPiece } => { 135 | const newPiece = { 136 | ...piece, 137 | rot: (piece.rot + 1) % 4, 138 | } 139 | 140 | const newPosPiece: IPos = moveCollision(grid, posPiece, newPiece); 141 | 142 | return { piecePlaced: false, pos: newPosPiece, piece: newPiece } 143 | } 144 | 145 | const updatePiecePos = (grid: ENUM_PIECES[][], posPiece: IPos, piece: IPiece, move: ENUM_PIECES_MOVE, ): { piecePlaced: boolean; pos: IPos; piece: IPiece } => { 146 | const newPieceDescr = getPiece(piece.num, piece.rot); 147 | const newPos = movePose(posPiece, move); 148 | const isCol = hasCollision(grid, newPieceDescr, newPos); 149 | 150 | if (move === ENUM_PIECES_MOVE.ROT_RIGHT) { 151 | return updatePiecePosOnRot(grid, posPiece, piece); 152 | } 153 | 154 | if (move === ENUM_PIECES_MOVE.RIGHT || move === ENUM_PIECES_MOVE.LEFT) { 155 | return { 156 | piecePlaced: false, 157 | pos: isCol ? posPiece : newPos, 158 | piece, 159 | } 160 | } 161 | 162 | if (move === ENUM_PIECES_MOVE.DROP) { 163 | let calcPos = posPiece; 164 | while (!hasCollision(grid, newPieceDescr, calcPos)) { 165 | calcPos = { 166 | ...calcPos, 167 | y: calcPos.y + 1, 168 | } 169 | } 170 | 171 | calcPos = { 172 | ...calcPos, 173 | y: calcPos.y - 1, 174 | }; 175 | 176 | return { piecePlaced: true, pos: calcPos, piece } 177 | } 178 | 179 | if (move === ENUM_PIECES_MOVE.DOWN) { 180 | return isCol ? { 181 | piecePlaced: true, 182 | pos: posPiece, 183 | piece, 184 | } : { 185 | piecePlaced: false, 186 | pos: newPos, 187 | piece, 188 | }; 189 | } 190 | 191 | return { piecePlaced: false, pos: posPiece, piece }; 192 | } 193 | 194 | const placePiece = (grid: ENUM_PIECES[][], piece: IPiece, pos: IPos, isPreview = false): ENUM_PIECES[][] => { 195 | const pieceDescr = getPiece(piece.num, piece.rot); 196 | 197 | return grid.map((line, y) => line.map((element, x) => { 198 | if (y >= pos.y && x >= pos.x && y < pos.y + pieceDescr.length && x < pos.x + pieceDescr.length && pieceDescr[y - pos.y][x - pos.x] !== 0) { 199 | return isPreview ? ENUM_PIECES.preview : pieceDescr[y - pos.y][x - pos.x]; 200 | } 201 | return element 202 | })) 203 | 204 | } 205 | 206 | const moveCollision = ( 207 | grid: ENUM_PIECES[][], 208 | posPiece: IPos, 209 | piece: IPiece, 210 | ): IPos => { 211 | const newPieceDescr = getPiece(piece.num, piece.rot); 212 | 213 | let collisionType = hasCollision(grid, newPieceDescr, posPiece); 214 | 215 | let newPos = posPiece; 216 | while (collisionType) { 217 | newPos = { 218 | ...newPos, 219 | ...(collisionType === ENUM_COLLISION_TYPE.WALL_LEFT ? { x: newPos.x + 1 } : 220 | collisionType === ENUM_COLLISION_TYPE.WALL_RIGHT ? { x: newPos.x - 1 } : 221 | { y: newPos.y - 1 } 222 | ), 223 | }; 224 | collisionType = hasCollision(grid, newPieceDescr, newPos); 225 | } 226 | return newPos; 227 | }; 228 | 229 | const gridDelLine = (grid: ENUM_PIECES[][]): { grid: ENUM_PIECES[][], nbLineToAdd: number } => { 230 | let calcLines = 0 231 | let wallToDell = 0 232 | let newGrid = grid.map(line => { 233 | if (line.every(x => x !== ENUM_PIECES.empty && x !== ENUM_PIECES.wall)) { 234 | calcLines += 1 235 | wallToDell += 1 236 | return undefined 237 | } else if (line.every(x => x === ENUM_PIECES.wall) && wallToDell) { 238 | wallToDell -= 1 239 | return undefined 240 | } 241 | return line 242 | }).filter(l => l) as ENUM_PIECES[][]; 243 | 244 | while (newGrid.length < grid.length) { 245 | newGrid = [Array(GRID_WIDTH).fill(ENUM_PIECES.empty), ...newGrid]; 246 | } 247 | 248 | return { 249 | grid: newGrid, 250 | nbLineToAdd: calcLines, 251 | } 252 | } 253 | 254 | const gridAddWall = (grid: ENUM_PIECES[][], linesToAdd: number): ENUM_PIECES[][] => { 255 | const obstacleLine = Array(GRID_WIDTH).fill(ENUM_PIECES.wall) 256 | const newGrid = [...grid] 257 | for (let i = 0; i < linesToAdd; i += 1) { 258 | newGrid.push(obstacleLine) 259 | } 260 | return newGrid.slice(linesToAdd) 261 | } 262 | 263 | const moveHandler = (players: IPlayer[], move: ENUM_PIECES_MOVE, socketId: string): IPlayer[] => { 264 | 265 | // handle no player 266 | const player = players.find((p) => p.socket.id === socketId); 267 | if (player === undefined) { 268 | return players; 269 | } 270 | 271 | // switch a tetrimino 272 | if (move === ENUM_PIECES_MOVE.SWITCH) { 273 | if (player.flow.length < 2) { 274 | return players; 275 | } 276 | 277 | const [frst, scnd, ...rest] = player.flow; 278 | const newFlow = [scnd, frst, ...rest]; 279 | 280 | const { grid, posPiece } = player; 281 | 282 | const newPose = moveCollision(grid, posPiece, newFlow[0]); 283 | 284 | return players.map(pl => { 285 | if (pl.socket.id === socketId) { 286 | return { 287 | ...pl, 288 | flow: newFlow, 289 | posPiece: newPose, 290 | } 291 | } 292 | return pl 293 | }) 294 | } 295 | 296 | // mooved a piece 297 | const { pos, piece, piecePlaced } = updatePiecePos(player.grid, player.posPiece, player.flow[0], move); 298 | 299 | const newFlow = player.flow.map((pi, i) => (i === 0) ? piece : pi); 300 | 301 | if (!piecePlaced) { 302 | return players.map(player => player.socket.id === socketId ? { 303 | ...player, 304 | posPiece: pos, 305 | flow: newFlow, 306 | } : player) 307 | } 308 | 309 | // actions if piece is placed 310 | let newPlayer = { 311 | ...player, 312 | grid: placePiece(player.grid, newFlow[0], pos), 313 | flow: newFlow.slice(1), 314 | posPiece: initPose(), 315 | }; 316 | 317 | const { grid, nbLineToAdd } = gridDelLine(newPlayer.grid); 318 | const { score, nbLineCompleted } = newPlayer 319 | const scoreToAdd = calcScore(nbLineToAdd) 320 | 321 | // update score 322 | newPlayer = { 323 | ...newPlayer, 324 | score: nbLineCompleted >= 0 ? score + scoreToAdd : score, 325 | nbLineCompleted: nbLineCompleted + nbLineToAdd, 326 | grid, 327 | }; 328 | 329 | // add penalty line, reduce score if needed (order should be remained) 330 | return players.map(person => { 331 | if (person.socket.id === socketId) { 332 | return newPlayer 333 | } 334 | return nbLineToAdd ? { 335 | ...person, 336 | nbLineCompleted: person.nbLineCompleted - nbLineToAdd, 337 | grid: gridAddWall(person.grid, nbLineToAdd), 338 | posPiece: moveCollision(person.grid, person.posPiece, person.flow[0]) 339 | } : person; 340 | }) 341 | } 342 | 343 | const placePiecePreview = (grid: ENUM_PIECES[][], piece: IPiece, pos: IPos): ENUM_PIECES[][] => { 344 | const pieceDescr = getPiece(piece.num, piece.rot); 345 | let loc = pos; 346 | 347 | // change y pos while not faced with obstacle 348 | while (!hasCollision(grid, pieceDescr, loc)) { 349 | loc = { ...loc, y: loc.y + 1 }; 350 | } 351 | if (loc.y > 0) { 352 | loc = { ...loc, y: loc.y - 1 }; 353 | } 354 | return placePiece(grid, piece, loc, true); 355 | }; 356 | 357 | interface IPieceInfo { 358 | readonly x: number, 359 | readonly y: number, 360 | readonly width: number 361 | } 362 | 363 | interface IPiecesDescr { 364 | readonly info: IPieceInfo, 365 | readonly piece: ENUM_PIECES[][], 366 | } 367 | 368 | const PIECES_DESCR: IPiecesDescr[][] = [ 369 | [ 370 | { 371 | info: { x: 0, y: -1, width: 4 }, 372 | piece: [ 373 | [0, 0, 0, 0], 374 | [1, 1, 1, 1], 375 | [0, 0, 0, 0], 376 | [0, 0, 0, 0], 377 | ], 378 | }, 379 | { 380 | info: { x: -2, y: 0, width: 1 }, 381 | piece: [ 382 | [0, 0, 1, 0], 383 | [0, 0, 1, 0], 384 | [0, 0, 1, 0], 385 | [0, 0, 1, 0], 386 | ], 387 | }, 388 | { 389 | info: { x: 0, y: -2, width: 4 }, 390 | piece: [ 391 | [0, 0, 0, 0], 392 | [0, 0, 0, 0], 393 | [1, 1, 1, 1], 394 | [0, 0, 0, 0], 395 | ], 396 | }, 397 | { 398 | info: { x: -1, y: 0, width: 1 }, 399 | piece: [ 400 | [0, 1, 0, 0], 401 | [0, 1, 0, 0], 402 | [0, 1, 0, 0], 403 | [0, 1, 0, 0], 404 | ], 405 | }, 406 | ], 407 | [ 408 | { 409 | info: { x: 0, y: 0, width: 3 }, 410 | piece: [ 411 | [2, 0, 0], 412 | [2, 2, 2], 413 | [0, 0, 0], 414 | ], 415 | }, 416 | { 417 | info: { x: -1, y: 0, width: 2 }, 418 | piece: [ 419 | [0, 2, 2], 420 | [0, 2, 0], 421 | [0, 2, 0], 422 | ], 423 | }, 424 | { 425 | info: { x: 0, y: -1, width: 3 }, 426 | piece: [ 427 | [0, 0, 0], 428 | [2, 2, 2], 429 | [0, 0, 2], 430 | ], 431 | }, 432 | { 433 | info: { x: 0, y: 0, width: 2 }, 434 | piece: [ 435 | [0, 2, 0], 436 | [0, 2, 0], 437 | [2, 2, 0], 438 | ], 439 | }, 440 | ], 441 | [ 442 | { 443 | info: { x: 0, y: 0, width: 3 }, 444 | piece: [ 445 | [0, 0, 3], 446 | [3, 3, 3], 447 | [0, 0, 0], 448 | ], 449 | }, 450 | { 451 | info: { x: -1, y: 0, width: 2 }, 452 | piece: [ 453 | [0, 3, 0], 454 | [0, 3, 0], 455 | [0, 3, 3], 456 | ], 457 | }, 458 | { 459 | info: { x: 0, y: -1, width: 3 }, 460 | piece: [ 461 | [0, 0, 0], 462 | [3, 3, 3], 463 | [3, 0, 0], 464 | ], 465 | }, 466 | { 467 | info: { x: 0, y: 0, width: 2 }, 468 | piece: [ 469 | [3, 3, 0], 470 | [0, 3, 0], 471 | [0, 3, 0], 472 | ], 473 | }, 474 | ], 475 | [ 476 | { 477 | info: { x: -1, y: 0, width: 4 }, 478 | piece: [ 479 | [0, 4, 4, 0], 480 | [0, 4, 4, 0], 481 | [0, 0, 0, 0], 482 | ], 483 | }, 484 | { 485 | info: { x: -1, y: 0, width: 4 }, 486 | piece: [ 487 | [0, 4, 4, 0], 488 | [0, 4, 4, 0], 489 | [0, 0, 0, 0], 490 | ], 491 | }, 492 | { 493 | info: { x: -1, y: 0, width: 4 }, 494 | piece: [ 495 | [0, 4, 4, 0], 496 | [0, 4, 4, 0], 497 | [0, 0, 0, 0], 498 | ], 499 | }, 500 | { 501 | info: { x: -1, y: 0, width: 4 }, 502 | piece: [ 503 | [0, 4, 4, 0], 504 | [0, 4, 4, 0], 505 | [0, 0, 0, 0], 506 | ], 507 | }, 508 | ], 509 | [ 510 | { 511 | info: { x: 0, y: 0, width: 3 }, 512 | piece: [ 513 | [0, 5, 5], 514 | [5, 5, 0], 515 | [0, 0, 0], 516 | ], 517 | }, 518 | { 519 | info: { x: -1, y: 0, width: 2 }, 520 | piece: [ 521 | [0, 5, 0], 522 | [0, 5, 5], 523 | [0, 0, 5], 524 | ], 525 | }, 526 | { 527 | info: { x: 0, y: -1, width: 3 }, 528 | piece: [ 529 | [0, 0, 0], 530 | [0, 5, 5], 531 | [5, 5, 0], 532 | ], 533 | }, 534 | { 535 | info: { x: 0, y: 0, width: 2 }, 536 | piece: [ 537 | [5, 0, 0], 538 | [5, 5, 0], 539 | [0, 5, 0], 540 | ], 541 | }, 542 | ], 543 | [ 544 | { 545 | info: { x: 0, y: 0, width: 3 }, 546 | piece: [ 547 | [0, 6, 0], 548 | [6, 6, 6], 549 | [0, 0, 0], 550 | ], 551 | }, 552 | { 553 | info: { x: -1, y: 0, width: 2 }, 554 | piece: [ 555 | [0, 6, 0], 556 | [0, 6, 6], 557 | [0, 6, 0], 558 | ], 559 | }, 560 | { 561 | info: { x: 0, y: -1, width: 3 }, 562 | piece: [ 563 | [0, 0, 0], 564 | [6, 6, 6], 565 | [0, 6, 0], 566 | ], 567 | }, 568 | { 569 | info: { x: 0, y: 0, width: 2 }, 570 | piece: [ 571 | [0, 6, 0], 572 | [6, 6, 0], 573 | [0, 6, 0], 574 | ], 575 | }, 576 | ], 577 | [ 578 | { 579 | info: { x: 0, y: 0, width: 3 }, 580 | piece: [ 581 | [7, 7, 0], 582 | [0, 7, 7], 583 | [0, 0, 0], 584 | ], 585 | }, 586 | { 587 | info: { x: -1, y: 0, width: 2 }, 588 | piece: [ 589 | [0, 0, 7], 590 | [0, 7, 7], 591 | [0, 7, 0], 592 | ], 593 | }, 594 | { 595 | info: { x: 0, y: -1, width: 3 }, 596 | piece: [ 597 | [0, 0, 0], 598 | [7, 7, 0], 599 | [0, 7, 7], 600 | ], 601 | }, 602 | { 603 | info: { x: 0, y: 0, width: 2 }, 604 | piece: [ 605 | [0, 7, 0], 606 | [7, 7, 0], 607 | [7, 0, 0], 608 | ], 609 | }, 610 | ], 611 | ]; 612 | 613 | export { 614 | IPos, 615 | IPiece, 616 | ENUM_PIECES, 617 | GRID_HEIGHT, 618 | gridInit, 619 | initPose, 620 | ENUM_PIECES_MOVE, 621 | moveHandler, 622 | placePiece, 623 | placePiecePreview, 624 | getPiece, 625 | calcScore, 626 | movePose, 627 | hasCollision, 628 | moveCollision, 629 | PIECES_DESCR, 630 | ENUM_COLLISION_TYPE, 631 | updatePiecePosOnRot, 632 | GRID_WIDTH, 633 | gridAddWall, 634 | gridDelLine, 635 | updatePiecePos, 636 | }; 637 | --------------------------------------------------------------------------------