├── 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 | close
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 |
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 | {
209 | dispatch(SEND_QUIT_ROOM())
210 | history.push(routes.index)
211 | }} className="exit">Exit
212 | {!playing && isMaster ? ( {
213 | dispatch(SEND_START_GAME())
214 | }} className="play">Play ) : 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 |
259 |
260 |
If you ❤️, pls give a 🌟 on the github 😉
261 |
262 |
{
263 | dispatch(SEND_QUIT_ROOM())
264 | history.push(routes.index)
265 | }
266 | }>
267 | <>
268 | Game over
269 | Please waite untill all players finish to know who is the winner.
270 | Your exit will automatically cause a defeat and you will be redirected to the home screen.
271 | >
272 |
273 |
274 |
showEndGameModal(false)} isWin={player.win ? 'win' : 'lost'}>
275 | {player.win ? (<>
276 | Congratulations
277 | You win
278 | Score: {player.score}
279 | Lines completed: {player.nbLineCompleted}
280 | >) : (<>
281 | Unfortunately
282 | You lost
283 | Score: {player.score}
284 | Lines completed: {player.nbLineCompleted}
285 | >)}
286 |
287 |
288 | {endGameModal && player.win ?
: null}
289 |
290 |
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 |
--------------------------------------------------------------------------------