├── .husky ├── .gitignore ├── pre-push └── pre-commit ├── .prettierrc.json ├── public ├── cards │ ├── 00.png │ ├── 01.png │ ├── 02.png │ ├── 03.png │ ├── 04.png │ ├── 05.png │ ├── 06.png │ ├── 07.png │ ├── 08.png │ ├── 09.png │ ├── 10.png │ ├── 11.png │ ├── 12.png │ ├── m1.png │ ├── m2.png │ ├── back.png │ └── blank.png ├── favicon.ico ├── fonts │ ├── Roboto-Black.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-Light.ttf │ ├── Roboto-Medium.ttf │ ├── Roboto-Thin.ttf │ └── Roboto-Regular.ttf ├── arrow.svg ├── arrow_right.svg ├── players.svg └── vercel.svg ├── next-env.d.ts ├── .stylelintrc.json ├── nodemon.json ├── .lintstagedrc.json ├── .storybook ├── preview.js └── main.js ├── components ├── misc │ ├── PlayerCount.module.css │ ├── LobbyListItem.module.css │ ├── PlayerCount.tsx │ ├── Misc.stories.tsx │ └── LobbyListItem.tsx ├── button │ ├── RulesBtn.tsx │ ├── StartGameBtn.tsx │ ├── ConfirmBtn.tsx │ ├── BackBtn.tsx │ ├── types.ts │ ├── ExitBtn.tsx │ ├── Continue.tsx │ ├── CreateBtn.tsx │ ├── KeepBtn.tsx │ ├── DiscardBtn.tsx │ ├── ReadyBtn.tsx │ ├── RestartBtn.tsx │ ├── JoinBtn.tsx │ ├── Button.stories.tsx │ └── Button.module.css ├── logo │ ├── Logo.tsx │ ├── Logo.stories.tsx │ └── Logo.module.css ├── gameboard │ ├── DrawPilePrompt.module.css │ ├── types.ts │ ├── Piles.module.css │ ├── RoundCounter.tsx │ ├── OpponentCardGrid.tsx │ ├── Score.module.css │ ├── RoundScoreModal.tsx │ ├── Statusbar.tsx │ ├── DiscardPile.tsx │ ├── TotalScore.tsx │ ├── DrawPile.tsx │ ├── MiscElements.module.css │ ├── GameBoard.stories.tsx │ ├── CardGrid.module.css │ ├── DrawPilePrompt.tsx │ └── CardGrid.tsx ├── input │ ├── Input.stories.tsx │ ├── NameInput.tsx │ └── Input.module.css └── pages │ └── Pages.stories.tsx ├── .eslintignore ├── .prettierignore ├── tsconfig.server.json ├── lib └── functions.ts ├── .github ├── ISSUE_TEMPLATE │ └── issue--user-story-.md └── workflows │ └── node.js.yml ├── styles ├── Home.module.css ├── User.module.css ├── Rules.module.css ├── Lobbies.module.css ├── Game.module.css └── globals.css ├── pages ├── _app.tsx ├── index.tsx ├── user.tsx ├── rules.tsx ├── lobbies.tsx └── game.tsx ├── server ├── lib │ ├── turnPhases.ts │ ├── statusMessages.ts │ ├── gameTypes.ts │ ├── cards.ts │ ├── broadcasts.ts │ └── games.ts ├── index.ts └── socket.ts ├── tsconfig.json ├── .gitignore ├── .eslintrc.json ├── contexts └── SocketContext.tsx ├── package.json └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /public/cards/00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/00.png -------------------------------------------------------------------------------- /public/cards/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/01.png -------------------------------------------------------------------------------- /public/cards/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/02.png -------------------------------------------------------------------------------- /public/cards/03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/03.png -------------------------------------------------------------------------------- /public/cards/04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/04.png -------------------------------------------------------------------------------- /public/cards/05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/05.png -------------------------------------------------------------------------------- /public/cards/06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/06.png -------------------------------------------------------------------------------- /public/cards/07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/07.png -------------------------------------------------------------------------------- /public/cards/08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/08.png -------------------------------------------------------------------------------- /public/cards/09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/09.png -------------------------------------------------------------------------------- /public/cards/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/10.png -------------------------------------------------------------------------------- /public/cards/11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/11.png -------------------------------------------------------------------------------- /public/cards/12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/12.png -------------------------------------------------------------------------------- /public/cards/m1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/m1.png -------------------------------------------------------------------------------- /public/cards/m2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/m2.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/cards/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/back.png -------------------------------------------------------------------------------- /public/cards/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/cards/blank.png -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard", "stylelint-config-prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /public/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/fonts/Roboto-Light.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/fonts/Roboto-Thin.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fre-ben/sisu/HEAD/public/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged --config .lintstagedrc.json 5 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["server"], 3 | "exec": "ts-node --project tsconfig.server.json server/index.ts", 4 | "ext": "js ts" 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.css": "stylelint --fix", 3 | "*.{js,jsx,ts,tsx}": "eslint --cache --fix", 4 | "*.{js,jsx,ts,tsx,css,md,json}": "prettier --write" 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: "^on[A-Z].*" }, 5 | layout: "centered", 6 | }; 7 | -------------------------------------------------------------------------------- /components/misc/PlayerCount.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | grid-auto-flow: column; 4 | align-items: center; 5 | column-gap: 0.5em; 6 | font-size: 1.7em; 7 | font-weight: 500; 8 | } 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # production 14 | /build 15 | /storybook-static 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # production 14 | /build 15 | /storybook-static 16 | -------------------------------------------------------------------------------- /components/misc/LobbyListItem.module.css: -------------------------------------------------------------------------------- 1 | .listItem { 2 | display: grid; 3 | grid-auto-flow: column; 4 | align-items: center; 5 | column-gap: 6em; 6 | } 7 | 8 | .lobbyNr { 9 | font-size: 2.7em; 10 | font-weight: 500; 11 | } 12 | -------------------------------------------------------------------------------- /components/button/RulesBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | 3 | function RulesBtn() { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default RulesBtn; 10 | -------------------------------------------------------------------------------- /components/button/StartGameBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | 3 | function StartGameBtn() { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default StartGameBtn; 10 | -------------------------------------------------------------------------------- /components/logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Logo.module.css"; 2 | 3 | type LogoProps = { 4 | size: string; 5 | }; 6 | 7 | function Logo({ size }: LogoProps) { 8 | return
Sisu
; 9 | } 10 | 11 | export default Logo; 12 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "build", 6 | "target": "es2017", 7 | "isolatedModules": false, 8 | "noEmit": false 9 | }, 10 | "include": ["server/**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /lib/functions.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | export function getPlayerName(): string { 4 | return localStorage.getItem("playerName"); 5 | } 6 | 7 | export function getLobbyNr(): number { 8 | const router = useRouter(); 9 | return +router.query.lobby; 10 | } 11 | -------------------------------------------------------------------------------- /components/button/ConfirmBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | 3 | function ConfirmBtn() { 4 | return ( 5 | 8 | ); 9 | } 10 | 11 | export default ConfirmBtn; 12 | -------------------------------------------------------------------------------- /components/logo/Logo.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react/types-6-0"; 2 | import Logo from "./Logo"; 3 | 4 | export default { 5 | title: "Common/Logo", 6 | } as Meta; 7 | 8 | export const big = () => ; 9 | export const small = () => ; 10 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | "../components/**/*.stories.mdx", 4 | "../components/**/*.stories.@(js|jsx|ts|tsx)", 5 | ], 6 | addons: [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "storybook-css-modules-preset", 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /components/gameboard/DrawPilePrompt.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | row-gap: 1.5em; 4 | justify-items: center; 5 | min-width: 16em; 6 | } 7 | 8 | .buttons { 9 | display: grid; 10 | grid-auto-flow: column; 11 | column-gap: 1em; 12 | } 13 | 14 | .card { 15 | width: 6.5em; 16 | cursor: default; 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue--user-story-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue (User Story) 3 | about: Issue in form of a user story 4 | title: "" 5 | labels: user story 6 | assignees: "" 7 | --- 8 | 9 | ## User story 10 | 11 | **As a**: 12 | **I want to**: 13 | **So I can**: 14 | 15 | ## Acceptance criteria 16 | 17 | - [ ] 18 | - [ ] 19 | - [ ] 20 | -------------------------------------------------------------------------------- /components/button/BackBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | 3 | function BackBtn() { 4 | return ( 5 | 11 | ); 12 | } 13 | 14 | export default BackBtn; 15 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | padding: 3% 10%; 8 | } 9 | 10 | .menuItems { 11 | margin-top: 6em; 12 | display: grid; 13 | grid-auto-flow: row; 14 | row-gap: 1.5em; 15 | justify-items: center; 16 | } 17 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import "../styles/globals.css"; 3 | import { SocketContextProvider } from "../contexts/SocketContext"; 4 | 5 | function App({ Component, pageProps }: AppProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /styles/User.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | padding: 3% 10%; 8 | } 9 | 10 | .pageItems { 11 | margin-top: 6em; 12 | display: grid; 13 | grid-auto-flow: column; 14 | column-gap: 3em; 15 | } 16 | 17 | .form { 18 | display: grid; 19 | justify-items: end; 20 | } 21 | -------------------------------------------------------------------------------- /components/button/types.ts: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react"; 2 | 3 | export type ButtonProps = { 4 | onClick?: MouseEventHandler; 5 | }; 6 | 7 | export type JoinBtnProps = { 8 | onClick: MouseEventHandler; 9 | lobbyIsFull: boolean; 10 | hasStarted: boolean; 11 | }; 12 | 13 | export type KeepDiscardBtnProps = { 14 | handleClick: MouseEventHandler; 15 | }; 16 | -------------------------------------------------------------------------------- /components/button/ExitBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import type { ButtonProps } from "./types"; 3 | 4 | function ExitBtn({ onClick }: ButtonProps) { 5 | return ( 6 | 13 | ); 14 | } 15 | 16 | export default ExitBtn; 17 | -------------------------------------------------------------------------------- /components/button/Continue.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import { ButtonProps } from "./types"; 3 | 4 | function ContinueBtn({ onClick }: ButtonProps) { 5 | return ( 6 | 11 | ); 12 | } 13 | 14 | export default ContinueBtn; 15 | -------------------------------------------------------------------------------- /components/button/CreateBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import { ButtonProps } from "./types"; 3 | 4 | function CreateBtn({ onClick }: ButtonProps) { 5 | return ( 6 | 11 | ); 12 | } 13 | 14 | export default CreateBtn; 15 | -------------------------------------------------------------------------------- /components/input/Input.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, Story } from "@storybook/react/types-6-0"; 2 | import NameInput from "./NameInput"; 3 | 4 | export default { 5 | title: "Common/Input", 6 | } as Meta; 7 | 8 | const Template: Story = (args) => ( 9 | 15 | ); 16 | 17 | export const Name = Template.bind({}); 18 | 19 | Name.args = {}; 20 | -------------------------------------------------------------------------------- /server/lib/turnPhases.ts: -------------------------------------------------------------------------------- 1 | export const phase = { 2 | DRAWDECISION: "drawDecision", 3 | DRAWPILEDECISION: "drawPileDecision", 4 | DRAWPILEDISCARD: "drawPileDiscard", 5 | DRAWPILEKEEP: "drawPileKeep", 6 | DISCARDPILEDECISION: "discardPileDecision", 7 | DISCARDPILEREPLACEOPEN: "discardPileReplaceOpen", 8 | DISCARDPILEREPLACEHIDDEN: "discardPileReplaceHidden", 9 | WAITTURN: "waitTurn", 10 | WAITTURNDRAWPILEDECISION: "waitTurnDrawPileDecision", 11 | }; 12 | -------------------------------------------------------------------------------- /components/button/KeepBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import { KeepDiscardBtnProps } from "./types"; 3 | 4 | function KeepBtn({ handleClick }: KeepDiscardBtnProps) { 5 | return ( 6 | 12 | ); 13 | } 14 | 15 | export default KeepBtn; 16 | -------------------------------------------------------------------------------- /components/button/DiscardBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import { KeepDiscardBtnProps } from "./types"; 3 | 4 | function DiscardBtn({ handleClick }: KeepDiscardBtnProps) { 5 | return ( 6 | 12 | ); 13 | } 14 | 15 | export default DiscardBtn; 16 | -------------------------------------------------------------------------------- /components/button/ReadyBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import { ButtonProps } from "./types"; 3 | 4 | function ReadyBtn({ onClick }: ButtonProps) { 5 | return ( 6 | 14 | ); 15 | } 16 | 17 | export default ReadyBtn; 18 | -------------------------------------------------------------------------------- /public/arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/button/RestartBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import { ButtonProps } from "./types"; 3 | 4 | function RestartBtn({ onClick }: ButtonProps) { 5 | return ( 6 | 14 | ); 15 | } 16 | 17 | export default RestartBtn; 18 | -------------------------------------------------------------------------------- /components/misc/PlayerCount.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./PlayerCount.module.css"; 2 | 3 | export type PlayerCountProps = { 4 | playerCount: number; 5 | }; 6 | 7 | function PlayerCount({ playerCount }: PlayerCountProps) { 8 | const maxPlayers = 8; 9 | 10 | return ( 11 |
12 | 13 | {playerCount}/{maxPlayers} 14 | 15 | players 16 |
17 | ); 18 | } 19 | 20 | export default PlayerCount; 21 | -------------------------------------------------------------------------------- /public/arrow_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/gameboard/types.ts: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react"; 2 | import { ActivePlayer, Card } from "../../server/lib/gameTypes"; 3 | 4 | export type DiscardPileProps = { 5 | onPileClick?: MouseEventHandler; 6 | card: Card; 7 | turnPhase: string; 8 | }; 9 | 10 | export type DrawPileProps = Pick; 11 | 12 | export type RoundCounterProps = { 13 | roundNr: number; 14 | }; 15 | 16 | export type StatusbarProps = { 17 | activePlayer: ActivePlayer; 18 | }; 19 | -------------------------------------------------------------------------------- /styles/Rules.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100vh; 3 | display: grid; 4 | place-items: center; 5 | grid-template-rows: 1fr 70%; 6 | row-gap: 2.2em; 7 | padding: 3% 10%; 8 | } 9 | 10 | .menuItems { 11 | display: grid; 12 | grid-auto-flow: column; 13 | align-items: start; 14 | column-gap: 3.5em; 15 | height: 100%; 16 | width: 90%; 17 | } 18 | 19 | .rules { 20 | font-size: 1.5em; 21 | overflow-y: auto; 22 | max-height: 100%; 23 | padding-right: 1em; 24 | } 25 | 26 | .rules p { 27 | margin-top: 0; 28 | } 29 | -------------------------------------------------------------------------------- /components/button/JoinBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Button.module.css"; 2 | import { JoinBtnProps } from "./types"; 3 | 4 | function JoinBtn({ onClick, lobbyIsFull, hasStarted }: JoinBtnProps) { 5 | return ( 6 | 15 | ); 16 | } 17 | 18 | export default JoinBtn; 19 | -------------------------------------------------------------------------------- /public/players.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /components/logo/Logo.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | line-height: 1em; 3 | background-image: linear-gradient( 4 | var(--secondary-color), 5 | var(--secondary-color) 6 | ), 7 | linear-gradient(var(--secondary-color), var(--secondary-color)); 8 | background-repeat: no-repeat; 9 | background-size: 3px 55%, 64% 3px; 10 | background-position: right bottom, right bottom; 11 | user-select: none; 12 | } 13 | 14 | .big { 15 | font-weight: 900; 16 | font-size: 8.5em; 17 | padding: 0 0.1em; 18 | } 19 | 20 | .small { 21 | font-weight: 600; 22 | font-size: 4.2em; 23 | padding: 0 0.11em; 24 | } 25 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | /storybook-static 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | .eslintcache 23 | *:Zone.Identifier 24 | api.json 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env.local 33 | .env.development.local 34 | .env.test.local 35 | .env.production.local 36 | 37 | # vercel 38 | .vercel 39 | -------------------------------------------------------------------------------- /styles/Lobbies.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | padding: 3% 10%; 8 | } 9 | 10 | .container button:nth-child(2) { 11 | margin: 1em 0; 12 | padding-left: 1em; 13 | } 14 | 15 | .pageItems { 16 | margin-top: 1em; 17 | display: grid; 18 | grid-auto-flow: column; 19 | column-gap: 3em; 20 | min-width: 800px; 21 | } 22 | 23 | .list { 24 | margin: 0; 25 | padding: 0 1em 0 0; 26 | overflow-y: auto; 27 | max-height: 18em; 28 | min-height: 18em; 29 | } 30 | .list li { 31 | margin-bottom: 1em; 32 | } 33 | -------------------------------------------------------------------------------- /components/pages/Pages.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react/types-6-0"; 2 | import { SocketContextProvider } from "../../contexts/SocketContext"; 3 | import Index from "../../pages/index"; 4 | import Lobbies from "../../pages/lobbies"; 5 | import Rules from "../../pages/rules"; 6 | import User from "../../pages/user"; 7 | 8 | export default { 9 | title: "Pages/Main Menu", 10 | parameters: { layout: "fullscreen" }, 11 | } as Meta; 12 | 13 | export const index = () => ; 14 | export const rules = () => ; 15 | export const user = () => ; 16 | export const lobbies = () => ( 17 | 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": 12, 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["react", "@typescript-eslint"], 22 | "rules": { 23 | "react/react-in-jsx-scope": "off", 24 | "@typescript-eslint/explicit-module-boundary-types": "off" 25 | }, 26 | "settings": { 27 | "react": { 28 | "version": "detect" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /components/misc/Misc.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, Story } from "@storybook/react/types-6-0"; 2 | import LobbyListItem, { LobbyListItemProps } from "./LobbyListItem"; 3 | import PlayerCount, { PlayerCountProps } from "./PlayerCount"; 4 | 5 | export default { 6 | title: "Common/Misc", 7 | } as Meta; 8 | 9 | const Template: Story = (args) => ; 10 | const TemplateListItem: Story = (args) => ( 11 | 12 | ); 13 | 14 | export const Playercounter = Template.bind({}); 15 | 16 | Playercounter.args = { 17 | playerCount: 0, 18 | }; 19 | 20 | export const Lobbylistitem = TemplateListItem.bind({}); 21 | 22 | Lobbylistitem.args = { 23 | playerCount: 4, 24 | lobbyNr: 1, 25 | onClick: null, 26 | lobbyIsFull: false, 27 | }; 28 | -------------------------------------------------------------------------------- /contexts/SocketContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useEffect, useState } from "react"; 2 | import { io, Socket } from "socket.io-client"; 3 | 4 | type SocketContextType = { 5 | socket: Socket; 6 | }; 7 | 8 | export const SocketContext = createContext(null); 9 | 10 | type SocketContextProviderProps = { 11 | children: ReactNode; 12 | }; 13 | 14 | export function SocketContextProvider({ 15 | children, 16 | }: SocketContextProviderProps) { 17 | const [socket, setSocket] = useState(null); 18 | 19 | useEffect(() => { 20 | const newSocket = io(); 21 | 22 | newSocket.on("connect", () => { 23 | setSocket(newSocket); 24 | console.log(newSocket.id + " connected"); 25 | }); 26 | }, []); 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [10.x, 12.x, 14.x, 15.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /components/input/NameInput.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Input.module.css"; 2 | 3 | type NameInputProps = { 4 | isMaxLength: boolean; 5 | playerName: string; 6 | onHandleChange(event): void; 7 | }; 8 | 9 | function NameInput({ 10 | isMaxLength, 11 | playerName, 12 | onHandleChange, 13 | }: NameInputProps) { 14 | return ( 15 | <> 16 |
17 | onHandleChange(event)} 23 | minLength={2} 24 | required 25 | value={playerName} 26 | autoFocus={true} 27 | autoComplete="off" 28 | autoCorrect="off" 29 | /> 30 | {isMaxLength && ( 31 |

Character limit reached!

32 | )} 33 |
34 | 35 | ); 36 | } 37 | 38 | export default NameInput; 39 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import RulesBtn from "../components/button/RulesBtn"; 4 | import StartGameBtn from "../components/button/StartGameBtn"; 5 | import Logo from "../components/logo/Logo"; 6 | import styles from "../styles/Home.module.css"; 7 | 8 | export default function Index() { 9 | return ( 10 | <> 11 | 12 | Sisu 13 | 14 | 15 | 16 |
17 |
18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /components/gameboard/Piles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | justify-items: center; 4 | row-gap: 1em; 5 | } 6 | 7 | .drawHeadline { 8 | background-size: 3px 58%, 81% 3px; 9 | } 10 | 11 | .discardHeadline { 12 | background-size: 3px 58%, 84.5% 3px; 13 | } 14 | 15 | .card { 16 | width: 6.5em; 17 | padding: 0.5em 0.5em; 18 | background-image: linear-gradient( 19 | var(--secondary-color), 20 | var(--secondary-color) 21 | ), 22 | linear-gradient(var(--secondary-color), var(--secondary-color)), 23 | linear-gradient(var(--secondary-color), var(--secondary-color)), 24 | linear-gradient(var(--secondary-color), var(--secondary-color)); 25 | background-size: 3px 20%, 37% 3px; 26 | background-repeat: no-repeat; 27 | background-position: right bottom, right bottom, left top, left top; 28 | transition: var(--bottom-transition); 29 | cursor: pointer; 30 | } 31 | 32 | .notClickable { 33 | cursor: default; 34 | } 35 | 36 | .card:hover { 37 | background-size: 3px 20%, 47% 3px; 38 | } 39 | -------------------------------------------------------------------------------- /components/gameboard/RoundCounter.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { SocketContext } from "../../contexts/SocketContext"; 3 | import { getLobbyNr } from "../../lib/functions"; 4 | import styles from "./MiscElements.module.css"; 5 | import type { RoundCounterProps } from "./types"; 6 | 7 | function RoundCounter() { 8 | const { socket } = useContext(SocketContext); 9 | const [roundNr, setRoundNr] = useState(null); 10 | const lobbyNr = getLobbyNr(); 11 | 12 | useEffect(() => { 13 | if (!socket) { 14 | return; 15 | } 16 | 17 | function handleDisplayRoundNr(round) { 18 | setRoundNr(round); 19 | } 20 | 21 | socket.on("display rounds", handleDisplayRoundNr); 22 | socket.emit("get rounds to display", lobbyNr); 23 | }, [socket, roundNr]); 24 | 25 | return ( 26 | <> 27 |

28 | Round:{roundNr} 29 |

30 | 31 | ); 32 | } 33 | 34 | export default RoundCounter; 35 | -------------------------------------------------------------------------------- /components/gameboard/OpponentCardGrid.tsx: -------------------------------------------------------------------------------- 1 | import type { Card } from "../../server/lib/gameTypes"; 2 | import styles from "./CardGrid.module.css"; 3 | 4 | export type CardGridProps = { 5 | cards: Card[]; 6 | name: string; 7 | roundScore: number; 8 | }; 9 | 10 | export type PlayerCardGridProps = { 11 | cards: Card[]; 12 | name: string; 13 | roundScore: number; 14 | gameHasStarted: boolean; 15 | turnPhase: string; 16 | }; 17 | 18 | function OpponentCardGrid({ cards, name, roundScore }: CardGridProps) { 19 | const createPlayerCardGrid = cards.map((card, index) => ( 20 | 24 | )); 25 | 26 | return ( 27 |
28 |

29 | Score: {roundScore} 30 |

31 |

{name}

32 |
{createPlayerCardGrid}
33 |
34 | ); 35 | } 36 | 37 | export default OpponentCardGrid; 38 | -------------------------------------------------------------------------------- /components/input/Input.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | grid-auto-flow: row; 4 | justify-items: end; 5 | align-items: start; 6 | min-height: 105px; 7 | } 8 | 9 | .input { 10 | background: none; 11 | border: none; 12 | width: 8em; 13 | font-size: 2.7em; 14 | font-weight: 600; 15 | text-align: end; 16 | color: var(--text-color); 17 | line-height: 1em; 18 | background-image: linear-gradient( 19 | var(--secondary-color), 20 | var(--secondary-color) 21 | ), 22 | linear-gradient(var(--secondary-color), var(--secondary-color)); 23 | background-repeat: no-repeat; 24 | background-size: 3px 58%, 27% 3px; 25 | background-position: right bottom, right bottom; 26 | padding: 0 0.2em 0.2em 0; 27 | transition: var(--bottom-transition); 28 | } 29 | 30 | .input::placeholder { 31 | color: var(--placeholder-color); 32 | } 33 | 34 | .input:hover { 35 | background-size: 3px 58%, calc(27% + 10%) 3px; 36 | } 37 | 38 | .status { 39 | color: var(--secondary-color); 40 | font-size: 1.2em; 41 | margin: 0; 42 | } 43 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /components/gameboard/Score.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | justify-items: center; 4 | row-gap: 1em; 5 | } 6 | 7 | .container ul { 8 | width: 85%; 9 | margin: 0; 10 | padding: 0.5em 0.7em; 11 | background-image: linear-gradient( 12 | var(--secondary-color), 13 | var(--secondary-color) 14 | ), 15 | linear-gradient(var(--secondary-color), var(--secondary-color)), 16 | linear-gradient(var(--secondary-color), var(--secondary-color)), 17 | linear-gradient(var(--secondary-color), var(--secondary-color)); 18 | background-size: 3px 20%, 25% 3px; 19 | background-repeat: no-repeat; 20 | background-position: right bottom, right bottom, left top, left top; 21 | } 22 | 23 | .totalScoreHeadline { 24 | background-size: 3px 58%, 86% 3px; 25 | } 26 | 27 | .playerListItem { 28 | margin: 0 auto; 29 | display: grid; 30 | grid-template-columns: 1fr 1fr; 31 | width: 100%; 32 | font-size: 1.8em; 33 | } 34 | 35 | .playerScore { 36 | color: var(--secondary-color); 37 | justify-self: end; 38 | } 39 | 40 | .playerName { 41 | justify-self: start; 42 | } 43 | -------------------------------------------------------------------------------- /components/misc/LobbyListItem.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react"; 2 | import JoinBtn from "../button/JoinBtn"; 3 | import styles from "./LobbyListItem.module.css"; 4 | import PlayerCount from "./PlayerCount"; 5 | 6 | export type LobbyListItemProps = { 7 | playerCount: number; 8 | lobbyNr: number; 9 | onClick: MouseEventHandler; 10 | lobbyIsFull: boolean; 11 | hasStarted: boolean; 12 | }; 13 | 14 | function LobbyListItem({ 15 | playerCount, 16 | lobbyNr, 17 | onClick, 18 | lobbyIsFull, 19 | hasStarted, 20 | }: LobbyListItemProps) { 21 | if (playerCount >= 8) { 22 | lobbyIsFull = true; 23 | } 24 | 25 | if (playerCount <= 0) { 26 | playerCount = 0; 27 | } 28 | 29 | return ( 30 |
  • 31 | Lobby #{lobbyNr} 32 | 33 | 38 |
  • 39 | ); 40 | } 41 | 42 | export default LobbyListItem; 43 | -------------------------------------------------------------------------------- /server/lib/statusMessages.ts: -------------------------------------------------------------------------------- 1 | export const status = { 2 | PREREADY: "Start game if all players are connected", 3 | PRESTART: "Reveal two hidden cards on your grid to start the round", 4 | PRESTARTWAIT: "Wait for other players to reveal two cards", 5 | WAITTURN: (activePlayerName: string) => { 6 | return `It's ${activePlayerName}'s turn. Please wait`; 7 | }, 8 | DRAWDECISION: "Draw card from draw pile or discard pile", 9 | DRAWPILEDECISION: "Do you want to keep or discard your drawn card?", 10 | DRAWPILEDISCARD: 11 | "Card added to discard pile. Reveal a hidden card on your grid", 12 | DRAWPILEDISCARDINVALID: 13 | "Invalid card clicked. Please reveal a hidden card on your grid", 14 | DRAWDISCARDPILEKEEP: "Replace open or hidden card on your grid", 15 | DRAWDISCARDPILEKEEPOPEN: 16 | "Replaced open card. Replaced card added to discard pile", 17 | DRAWDISCARDPILEKEEPHIDDEN: 18 | "Replaced hidden card. Replaced card added to discard pile", 19 | ROUNDEND: "Round ended! Press continue to get to the next round", 20 | GAMEEND: `${null} lost (reached total score of ${null}). Game ended!`, 21 | }; 22 | -------------------------------------------------------------------------------- /components/gameboard/RoundScoreModal.tsx: -------------------------------------------------------------------------------- 1 | import ContinueBtn from "../button/Continue"; 2 | import styles from "./MiscElements.module.css"; 3 | 4 | function RoundScoreModal(onClick) { 5 | // Example data, will be removed later by Props handed down to CardGrid 6 | const players = [ 7 | { name: "Frederik", score: 20 }, 8 | { name: "Kalle", score: 10 }, 9 | { name: "Eva", score: 30 }, 10 | { name: "Louis", score: 60 }, 11 | { name: "Boris", score: 20 }, 12 | { name: "Kalle", score: 10 }, 13 | { name: "Eva", score: 30 }, 14 | { name: "Peter", score: 30 }, 15 | ]; 16 | 17 | const createRoundScoreList = players.map((player) => ( 18 |
  • 19 | {player.name}{" "} 20 | {player.score} 21 |
  • 22 | )); 23 | 24 | return ( 25 |
    26 |

    Round Score

    27 |
      {createRoundScoreList}
    28 | 29 |
    30 | ); 31 | } 32 | 33 | export default RoundScoreModal; 34 | -------------------------------------------------------------------------------- /components/gameboard/Statusbar.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { SocketContext } from "../../contexts/SocketContext"; 3 | import { getLobbyNr } from "../../lib/functions"; 4 | import { status } from "../../server/lib/statusMessages"; 5 | import styles from "./MiscElements.module.css"; 6 | import { StatusbarProps } from "./types"; 7 | 8 | function Statusbar({ activePlayer }: StatusbarProps) { 9 | const { socket } = useContext(SocketContext); 10 | const lobbyNr = getLobbyNr(); 11 | const [statusMessage, setStatusMessage] = useState(status.PREREADY); 12 | 13 | useEffect(() => { 14 | if (!socket || !lobbyNr) { 15 | return; 16 | } 17 | 18 | function handleDisplayStatus(statusMessage: string) { 19 | setStatusMessage(statusMessage); 20 | } 21 | 22 | socket.on("display status", handleDisplayStatus); 23 | return () => { 24 | socket.off("display status", handleDisplayStatus); 25 | }; 26 | }, [socket, lobbyNr, activePlayer]); 27 | 28 | return ( 29 | <> 30 |

    31 | arrow 32 | {statusMessage} 33 |

    34 | 35 | ); 36 | } 37 | 38 | export default Statusbar; 39 | -------------------------------------------------------------------------------- /server/lib/gameTypes.ts: -------------------------------------------------------------------------------- 1 | export type Game = { 2 | lobbyNr: number; 3 | roundNr: number; 4 | playerCount: number; 5 | lobbyIsFull: boolean; 6 | hasStarted: boolean; 7 | activePlayerIndex: number; 8 | players: Player[]; 9 | drawPileCards: Card[]; 10 | tempDrawPileCard: Card; 11 | discardPileCards: Card[]; 12 | currentRoundScores: CurrentRoundScore[]; 13 | }; 14 | 15 | export type GamesType = { 16 | [lobbyNr: number]: Game; 17 | }; 18 | 19 | export type GameForLobby = Pick< 20 | Game, 21 | "lobbyNr" | "playerCount" | "lobbyIsFull" | "hasStarted" 22 | >; 23 | 24 | export type Player = { 25 | name: string; 26 | socketID: string; 27 | isReady: boolean; 28 | cards: Card[]; 29 | totalScore: number; 30 | roundScore: number[]; 31 | allCardsRevealed: boolean; 32 | }; 33 | 34 | export type PlayerForCardGrid = Pick< 35 | Player, 36 | "name" | "cards" | "roundScore" | "socketID" | "isReady" 37 | >; 38 | 39 | export type PlayerScoreList = Pick; 40 | 41 | export type PlayerRoundScore = Pick; 42 | 43 | export type ActivePlayer = Pick; 44 | 45 | export type CurrentRoundScore = Pick; 46 | 47 | export type Card = { 48 | id?: number; 49 | value: number; 50 | imgSrc: string; 51 | hidden: boolean; 52 | }; 53 | 54 | export type CardToGenerate = [Card, number]; 55 | -------------------------------------------------------------------------------- /components/gameboard/DiscardPile.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { SocketContext } from "../../contexts/SocketContext"; 3 | import { getLobbyNr } from "../../lib/functions"; 4 | import styles from "./Piles.module.css"; 5 | import type { DiscardPileProps } from "./types"; 6 | 7 | function DiscardPile({ turnPhase, card }: DiscardPileProps) { 8 | const { socket } = useContext(SocketContext); 9 | const lobbyNr = getLobbyNr(); 10 | const notClickable = `${styles.card} ${styles.notClickable}`; 11 | 12 | function handlePileClick() { 13 | switch (turnPhase) { 14 | case "drawDecision": 15 | socket.emit("DRAWDECISION: click discardpile", socket.id, lobbyNr); 16 | break; 17 | case "waitTurn": 18 | return; 19 | default: 20 | return; 21 | } 22 | } 23 | 24 | function cardStyle() { 25 | switch (turnPhase) { 26 | case "drawDecision": 27 | return styles.card; 28 | case "waitTurn": 29 | return notClickable; 30 | default: 31 | return notClickable; 32 | } 33 | } 34 | 35 | return ( 36 | <> 37 |
    38 |

    Discard Pile

    39 | 44 |
    45 | 46 | ); 47 | } 48 | 49 | export default DiscardPile; 50 | -------------------------------------------------------------------------------- /components/gameboard/TotalScore.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { SocketContext } from "../../contexts/SocketContext"; 3 | import { getLobbyNr } from "../../lib/functions"; 4 | import type { PlayerScoreList } from "../../server/lib/gameTypes"; 5 | import styles from "./Score.module.css"; 6 | type ScoreListProps = { 7 | name: string; 8 | totalScore: number; 9 | }; 10 | 11 | function TotalScore() { 12 | const { socket } = useContext(SocketContext); 13 | const [scoreList, setScoreList] = useState([]); 14 | const lobbyNr = getLobbyNr(); 15 | 16 | const renderScoreList = scoreList.map(({ name, totalScore }) => { 17 | return ( 18 |
  • 19 | {name} 20 | {totalScore} 21 |
  • 22 | ); 23 | }); 24 | 25 | useEffect(() => { 26 | if (!socket || !lobbyNr) { 27 | return; 28 | } 29 | function handleDisplayScores(scores: PlayerScoreList[]) { 30 | setScoreList(scores); 31 | } 32 | 33 | socket.on("display scores", handleDisplayScores); 34 | socket.emit("get scores to display", lobbyNr); 35 | }, [socket, lobbyNr]); 36 | 37 | return ( 38 | <> 39 |
    40 |

    Total Score

    41 |
      {renderScoreList}
    42 |
    43 | 44 | ); 45 | } 46 | 47 | export default TotalScore; 48 | -------------------------------------------------------------------------------- /components/gameboard/DrawPile.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { SocketContext } from "../../contexts/SocketContext"; 3 | import { getLobbyNr } from "../../lib/functions"; 4 | import { phase } from "../../server/lib/turnPhases"; 5 | import styles from "./Piles.module.css"; 6 | import type { DrawPileProps } from "./types"; 7 | 8 | function DrawPile({ turnPhase }: DrawPileProps) { 9 | const { socket } = useContext(SocketContext); 10 | const lobbyNr = getLobbyNr(); 11 | const notClickable = `${styles.card} ${styles.notClickable}`; 12 | 13 | function handlePileClick() { 14 | switch (turnPhase) { 15 | case phase.DRAWDECISION: 16 | socket.emit("DRAWDECISION: click drawpile", socket.id, lobbyNr); 17 | socket.emit("get new drawpilecard", lobbyNr); 18 | break; 19 | case "waitTurn": 20 | return; 21 | default: 22 | return; 23 | } 24 | } 25 | 26 | function cardStyle() { 27 | switch (turnPhase) { 28 | case phase.DRAWDECISION: 29 | return styles.card; 30 | case "waitTurn": 31 | return notClickable; 32 | default: 33 | return notClickable; 34 | } 35 | } 36 | 37 | return ( 38 | <> 39 |
    40 |

    Draw Pile

    41 | 46 |
    47 | 48 | ); 49 | } 50 | 51 | export default DrawPile; 52 | -------------------------------------------------------------------------------- /styles/Game.module.css: -------------------------------------------------------------------------------- 1 | .viewContainer { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | padding: 2% 2%; 8 | } 9 | 10 | .topBar { 11 | display: grid; 12 | grid-auto-flow: column; 13 | align-items: center; 14 | column-gap: 2em; 15 | justify-items: center; 16 | grid-area: topbar; 17 | } 18 | 19 | .sideBar { 20 | display: grid; 21 | grid-auto-flow: row; 22 | justify-items: center; 23 | row-gap: 2em; 24 | grid-area: sidebar; 25 | } 26 | 27 | .pageElements { 28 | display: grid; 29 | grid-template-columns: 80% 20%; 30 | grid-template-rows: auto 1fr; 31 | align-items: start; 32 | column-gap: 1em; 33 | width: 100%; 34 | grid-template-areas: 35 | "topbar sidebar" 36 | "game sidebar"; 37 | } 38 | 39 | .gameElements { 40 | display: grid; 41 | justify-items: center; 42 | grid-area: game; 43 | grid-template-rows: auto auto; 44 | grid-template-columns: auto 30%; 45 | grid-template-areas: "opp opp"; 46 | } 47 | 48 | .opponents { 49 | margin-top: 1em; 50 | display: grid; 51 | grid-template-columns: repeat(6, 1fr); 52 | justify-items: center; 53 | align-items: start; 54 | column-gap: 1em; 55 | row-gap: 1.5em; 56 | min-height: 230px; 57 | grid-area: opp; 58 | } 59 | 60 | .playerCardGridDrawPilePrompt { 61 | display: grid; 62 | justify-self: end; 63 | align-items: center; 64 | } 65 | 66 | .playerCardGrid { 67 | justify-self: end; 68 | padding-right: 3em; 69 | } 70 | 71 | .playerCardGrid8 { 72 | position: relative; 73 | bottom: 10em; 74 | } 75 | -------------------------------------------------------------------------------- /components/gameboard/MiscElements.module.css: -------------------------------------------------------------------------------- 1 | .roundCounter { 2 | display: grid; 3 | grid-auto-flow: column; 4 | margin: 0; 5 | column-gap: 0.3em; 6 | font-size: 1.8em; 7 | font-weight: 500; 8 | padding: 0.15em 0.3em; 9 | line-height: 1em; 10 | background-image: linear-gradient( 11 | var(--secondary-color), 12 | var(--secondary-color) 13 | ), 14 | linear-gradient(var(--secondary-color), var(--secondary-color)); 15 | background-size: 3px 58%, 78% 3px; 16 | background-repeat: no-repeat; 17 | background-position: right bottom, right bottom; 18 | } 19 | 20 | .roundCounter span { 21 | color: var(--secondary-color); 22 | } 23 | 24 | .statusbar { 25 | display: grid; 26 | margin: 0; 27 | grid-auto-flow: column; 28 | align-items: center; 29 | column-gap: 0.3em; 30 | font-size: 1.4em; 31 | font-weight: 500; 32 | color: var(--secondary-color); 33 | } 34 | 35 | .modalContainer { 36 | display: grid; 37 | justify-items: center; 38 | background-color: var(--primary-color); 39 | padding: 1em 2em; 40 | box-shadow: var(--shadow); 41 | border-radius: 15px; 42 | } 43 | 44 | .modalContainer p { 45 | font-size: 2.7em; 46 | font-weight: 600; 47 | margin: 0; 48 | } 49 | 50 | .scoreList { 51 | padding: 0; 52 | margin-top: 1.5em; 53 | margin-bottom: 1em; 54 | font-weight: 500; 55 | } 56 | 57 | .playerListItem { 58 | display: grid; 59 | grid-template-columns: 1fr 1fr; 60 | font-size: 1.8em; 61 | } 62 | 63 | .playerScore { 64 | color: var(--secondary-color); 65 | justify-self: end; 66 | } 67 | 68 | .playerName { 69 | justify-self: start; 70 | } 71 | -------------------------------------------------------------------------------- /components/button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import ConfirmBtn from "./ConfirmBtn"; 2 | import { Meta, Story } from "@storybook/react/types-6-0"; 3 | import BackBtn from "./BackBtn"; 4 | import RulesBtn from "./RulesBtn"; 5 | import StartGameBtn from "./StartGameBtn"; 6 | import { JoinBtnProps } from "./types"; 7 | import JoinBtn from "./JoinBtn"; 8 | import CreateBtn from "./CreateBtn"; 9 | import KeepBtn from "./KeepBtn"; 10 | import DiscardBtn from "./DiscardBtn"; 11 | import ReadyBtn from "./ReadyBtn"; 12 | import ExitBtn from "./ExitBtn"; 13 | import RestartBtn from "./RestartBtn"; 14 | import ContinueBtn from "./Continue"; 15 | 16 | export default { 17 | title: "Common/Button", 18 | } as Meta; 19 | 20 | const TemplateJoin: Story = (args) => ; 21 | 22 | export const Join = TemplateJoin.bind({}); 23 | 24 | Join.args = { 25 | onClick: () => alert("Hello"), 26 | lobbyIsFull: false, 27 | hasStarted: false, 28 | }; 29 | 30 | export const back = () => ; 31 | export const startgame = () => ; 32 | export const rules = () => ; 33 | export const confirm = () => ; 34 | export const create = () => alert("test")} />; 35 | export const keep = () => ; 36 | export const discard = () => ; 37 | export const ready = () => alert("test")} />; 38 | export const exit = () => alert("test")} />; 39 | export const restart = () => alert("test")} />; 40 | export const cont = () => alert("test")} />; 41 | -------------------------------------------------------------------------------- /components/gameboard/GameBoard.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react/types-6-0"; 2 | import CardGrid from "./CardGrid"; 3 | import DiscardPile from "./DiscardPile"; 4 | import DrawPile from "./DrawPile"; 5 | import OpponentCardGrid from "./OpponentCardGrid"; 6 | import RoundScoreModal from "./RoundScoreModal"; 7 | import RoundCounter from "./RoundCounter"; 8 | import Statusbar from "./Statusbar"; 9 | import TotalScore from "./TotalScore"; 10 | import { SocketContextProvider } from "../../contexts/SocketContext"; 11 | import DrawPilePrompt from "./DrawPilePrompt"; 12 | 13 | export default { 14 | title: "Common/Gameboard", 15 | } as Meta; 16 | 17 | export const drawpile = () => ; 18 | export const discardpile = () => ; 19 | export const totalscore = () => ; 20 | export const cardgrid = () => ( 21 | 28 | ); 29 | export const opponentcardgrid = () => ( 30 | 31 | ); 32 | export const roundscoremodal = () => ; 33 | export const roundcounter = () => ( 34 | 35 | 36 | 37 | ); 38 | export const statusbar = () => ( 39 | 40 | 41 | 42 | ); 43 | export const drawpileprompt = () => ( 44 | 45 | 46 | 47 | ); 48 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "http"; 2 | import { parse } from "url"; 3 | import next from "next"; 4 | import path from "path"; 5 | import fs from "fs/promises"; 6 | import { listenSocket } from "./socket"; 7 | 8 | const dev = process.env.NODE_ENV !== "production"; 9 | const port = process.env.PORT || 3000; 10 | 11 | const app = next({ dev }); 12 | 13 | const handle = app.getRequestHandler(); 14 | 15 | const readStorybookStatic = (filename) => { 16 | const resolvedBase = path.resolve("./storybook-static"); 17 | const fileLoc = path.join(resolvedBase, filename); 18 | return fs.readFile(fileLoc); 19 | }; 20 | 21 | app.prepare().then(() => { 22 | const httpServer = createServer(async (req, res) => { 23 | // Be sure to pass `true` as the second argument to `url.parse`. 24 | // This tells it to parse the query portion of the URL. 25 | const parsedUrl = parse(req.url, true); 26 | const { pathname } = parsedUrl; 27 | 28 | if (pathname.startsWith("/storybook")) { 29 | if (dev) { 30 | res.statusCode = 400; 31 | return res.end( 32 | "Forbidden: Please run `npm run storybook` in development" 33 | ); 34 | } 35 | if (pathname === "/storybook") { 36 | res.statusCode = 302; 37 | res.setHeader("Location", "/storybook/index.html"); 38 | return res.end(); 39 | } 40 | 41 | const filename = pathname.split("/storybook")[1]; 42 | const file = await readStorybookStatic(filename); 43 | res.statusCode = 200; 44 | res.write(file); 45 | return res.end(); 46 | } 47 | // Forward to next handler 48 | handle(req, res, parsedUrl); 49 | }); 50 | 51 | listenSocket(httpServer); 52 | 53 | httpServer.listen(port, () => { 54 | console.log(`> Ready on http://localhost:${port}`); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-template", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nodemon", 7 | "build": "npm run build-next && npm run build-server", 8 | "start": "NODE_ENV=production node build/index.js", 9 | "lint": "eslint .", 10 | "stylelint": "stylelint '**/*.css'", 11 | "prettify": "prettier --check .", 12 | "prepare": "husky install", 13 | "typecheck": "tsc", 14 | "test": "npm run lint && npm run stylelint && npm run prettify && npm run typecheck", 15 | "storybook": "start-storybook -s ./public -p 6006", 16 | "build-next": "next build", 17 | "build-storybook": "build-storybook -s public", 18 | "build-server": "tsc --project tsconfig.server.json" 19 | }, 20 | "dependencies": { 21 | "next": "10.0.8", 22 | "react": "17.0.1", 23 | "react-dom": "17.0.1", 24 | "socket.io": "^4.0.0", 25 | "socket.io-client": "^4.0.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.13.10", 29 | "@storybook/addon-actions": "^6.1.21", 30 | "@storybook/addon-essentials": "^6.1.21", 31 | "@storybook/addon-links": "^6.1.21", 32 | "@storybook/react": "^6.1.21", 33 | "@types/node": "^14.14.33", 34 | "@types/react": "^17.0.3", 35 | "@typescript-eslint/eslint-plugin": "^4.17.0", 36 | "@typescript-eslint/parser": "^4.17.0", 37 | "babel-loader": "^8.2.2", 38 | "eslint": "^7.21.0", 39 | "eslint-config-prettier": "^8.1.0", 40 | "eslint-plugin-react": "^7.22.0", 41 | "husky": "^5.1.3", 42 | "lint-staged": "^10.5.4", 43 | "nodemon": "^2.0.7", 44 | "prettier": "^2.2.1", 45 | "storybook-css-modules-preset": "^1.0.6", 46 | "stylelint": "^13.12.0", 47 | "stylelint-config-prettier": "^8.0.2", 48 | "stylelint-config-standard": "^21.0.0", 49 | "ts-node": "^9.1.1", 50 | "typescript": "^4.2.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pages/user.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import styles from "../styles/User.module.css"; 3 | import Link from "next/link"; 4 | import Logo from "../components/logo/Logo"; 5 | import BackBtn from "../components/button/BackBtn"; 6 | import ConfirmBtn from "../components/button/ConfirmBtn"; 7 | import NameInput from "../components/input/NameInput"; 8 | import { useRouter } from "next/dist/client/router"; 9 | import { useState } from "react"; 10 | 11 | export default function User() { 12 | const [isMaxLength, setIsMaxLength] = useState(false); 13 | const [playerName, setPlayerName] = useState(""); 14 | const router = useRouter(); 15 | 16 | const handleSubmit = (event) => { 17 | event.preventDefault(); 18 | localStorage.setItem("playerName", playerName); 19 | router.push("/lobbies"); 20 | }; 21 | 22 | const checkValueLength = (event) => { 23 | setIsMaxLength(event.target.value.length >= 11); 24 | }; 25 | 26 | const onHandleChange = (event) => { 27 | checkValueLength(event); 28 | setPlayerName(event.target.value); 29 | }; 30 | 31 | return ( 32 | <> 33 | 34 | Username 35 | 36 | 37 | 38 |
    39 |
    40 | 41 |
    42 | 43 | 44 | 45 | 46 | 47 |
    48 | 53 | 54 | 55 |
    56 |
    57 |
    58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /pages/rules.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import BackBtn from "../components/button/BackBtn"; 3 | import Logo from "../components/logo/Logo"; 4 | import styles from "../styles/Rules.module.css"; 5 | import Link from "next/link"; 6 | 7 | export default function Rules() { 8 | return ( 9 | <> 10 | 11 | How to play 12 | 13 | 14 | 15 |
    16 |
    17 | 18 |
    19 | 20 | 21 | 22 | 23 | 24 |
    25 |

    26 | Every player has 12 hidden cards (arranged in a 3x4 cardgrid). 27 | At the start of the game two cards are turned face up, revealing 28 | the card.
    29 | The player whose two cards have the highest sum (score) goes 30 | first.
    31 | On your turn you can take the top card from the discard or draw 32 | pile. You can exchange one card (hidden or open) from your 33 | cardgrid.
    34 | Round ends when one player has only open/revealed cards on his 35 | cardgrid.
    36 | When the round ends, every player has one more turn (except the 37 | player, who ends the round).
    38 | Afterwards all non-revealed cards will be revealed. When all 39 | cards are revealed the numbers of the cards each player has will 40 | be added for scoring.
    41 | Game ends after one player has 100 or more points. Whoever has 42 | the lowest number wins. 43 |

    44 |

    45 | Special rule: Whenever one row of 3 cards all have the same 46 | value, they will be discarded and no longer scored.
    47 | Cards are ranked from -2 up to 12. 48 |

    49 |
    50 |
    51 |
    52 |
    53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Roboto"; 3 | src: url("../public/fonts/Roboto-Black.ttf") format("truetype"); 4 | font-weight: 900; 5 | } 6 | 7 | @font-face { 8 | font-family: "Roboto"; 9 | src: url("../public/fonts/Roboto-Bold.ttf") format("truetype"); 10 | font-weight: 700; 11 | } 12 | 13 | @font-face { 14 | font-family: "Roboto"; 15 | src: url("../public/fonts/Roboto-Medium.ttf") format("truetype"); 16 | font-weight: 500; 17 | } 18 | 19 | @font-face { 20 | font-family: "Roboto"; 21 | src: url("../public/fonts/Roboto-Regular.ttf") format("truetype"); 22 | font-weight: 400; 23 | } 24 | 25 | @font-face { 26 | font-family: "Roboto"; 27 | src: url("../public/fonts/Roboto-Light.ttf") format("truetype"); 28 | font-weight: 300; 29 | } 30 | 31 | @font-face { 32 | font-family: "Roboto"; 33 | src: url("../public/fonts/Roboto-Thin.ttf") format("truetype"); 34 | font-weight: 100; 35 | } 36 | 37 | :root { 38 | --primary-color: #141225; 39 | --secondary-color: #ffce21; 40 | --text-color: #fff; 41 | --placeholder-color: #8a8a8a; 42 | --background-gradient-color: #62606d; 43 | --bottom-transition: background-size 250ms ease-out; 44 | --shadow: 0 3px 10px #000000c7; 45 | } 46 | 47 | html, 48 | body { 49 | padding: 0; 50 | margin: 0; 51 | font-family: "Roboto", sans-serif; 52 | font-size: 16px; 53 | color: var(--text-color); 54 | background: transparent 55 | radial-gradient( 56 | closest-side at 50% 50%, 57 | var(--primary-color) 0%, 58 | var(--background-gradient-color) 400% 59 | ) 60 | 0% 0% no-repeat padding-box; 61 | } 62 | 63 | button { 64 | font-family: inherit; 65 | font-weight: 500; 66 | color: var(--text-color); 67 | background: none; 68 | border: none; 69 | } 70 | 71 | a { 72 | color: inherit; 73 | text-decoration: none; 74 | } 75 | 76 | h2 { 77 | margin: 0; 78 | font-size: 2.7em; 79 | font-weight: bold; 80 | padding: 0.05em 0.2em; 81 | line-height: 1em; 82 | background-image: linear-gradient( 83 | var(--secondary-color), 84 | var(--secondary-color) 85 | ), 86 | linear-gradient(var(--secondary-color), var(--secondary-color)); 87 | background-size: 3px 58%, 52% 3px; 88 | background-repeat: no-repeat; 89 | background-position: right bottom, right bottom; 90 | } 91 | 92 | * { 93 | box-sizing: border-box; 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SISU · [![Build Status](https://img.shields.io/travis/npm/npm/latest.svg?style=flat-square)](https://travis-ci.org/npm/npm) [![npm](https://img.shields.io/npm/v/npm.svg?style=flat-square)](https://www.npmjs.com/package/npm) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/your/your-project/blob/master/LICENSE) 2 | 3 | > A multiplayer card game for up to 8 players 4 | 5 | This is my capstone project for the Neue Fische Web Development bootcamp I attended in spring of 2021. 6 | It's a web app multiplayer card game for up to 8 players, best played in a web browser. 7 | Reveal your cards and exchange them cleverly to reach the lowest score each round and beat your friends. 8 | The rules of this game are based on the fantastic card game **Skyjo** 9 | 10 | ## Installing / Getting started 11 | 12 | To run locally, install the dependencies and run the development server. 13 | 14 | ```shell 15 | npm install 16 | npm run dev 17 | ``` 18 | 19 | Now you should be able to run the game on port 3000. 20 | 21 | ## Developing 22 | 23 | ### Built With 24 | 25 | - React.js 26 | - Next.js 27 | - Socket.io 28 | - TypeScript 29 | 30 | ### Prerequisites 31 | 32 | None needed, just install the dependencies with `npm install` 33 | 34 | ### Setting up Dev 35 | 36 | If you want to add features, feel free to do so 🧙‍♂️ 37 | 38 | ```shell 39 | git clone git@github.com:fre-ben/sisu.git 40 | cd sisu 41 | npm install 42 | npm run dev 43 | ``` 44 | 45 | ## Tests 46 | 47 | No tests implemented yet. 48 | 49 | ## Project Dependencies 50 | 51 | - https://nextjs.org/learn/excel/typescript 52 | - https://prettier.io/docs/en/install.html 53 | - https://eslint.org/docs/user-guide/getting-started 54 | - https://github.com/prettier/eslint-config-prettier 55 | - https://stylelint.io/user-guide/get-started 56 | - https://github.com/prettier/stylelint-config-prettier 57 | - https://typicode.github.io/husky/ 58 | - https://github.com/okonet/lint-staged 59 | - https://storybook.js.org/docs/react/get-started/install 60 | - https://www.npmjs.com/package/storybook-css-modules-preset 61 | - [Custom server for next](https://nextjs.org/docs/advanced-features/custom-server) 62 | - [TypeScript for custom server + nodemon](https://github.com/vercel/next.js/tree/canary/examples/custom-server-typescript) 63 | - [socket.io](https://socket.io/) 64 | -------------------------------------------------------------------------------- /components/button/Button.module.css: -------------------------------------------------------------------------------- 1 | .btn { 2 | font-size: 2.7em; 3 | padding: 0.05em 0.2em; 4 | line-height: 1em; 5 | background-image: linear-gradient( 6 | var(--secondary-color), 7 | var(--secondary-color) 8 | ), 9 | linear-gradient(var(--secondary-color), var(--secondary-color)); 10 | background-size: 3px 58%, var(--bottom-border-length) 3px; 11 | background-repeat: no-repeat; 12 | background-position: right bottom, right bottom; 13 | cursor: pointer; 14 | transition: var(--bottom-transition); 15 | } 16 | 17 | .btn:disabled { 18 | background-image: linear-gradient( 19 | var(--placeholder-color), 20 | var(--placeholder-color) 21 | ), 22 | linear-gradient(var(--placeholder-color), var(--placeholder-color)); 23 | color: var(--placeholder-color); 24 | cursor: default; 25 | } 26 | 27 | .btn:hover { 28 | background-size: 3px 58%, calc(var(--bottom-border-length) + 10%) 3px; 29 | } 30 | 31 | .startBtn { 32 | --bottom-border-length: 38%; 33 | } 34 | 35 | .rulesBtn { 36 | --bottom-border-length: 25%; 37 | 38 | padding: 0.25em 0.2em; 39 | } 40 | 41 | .confirmBtn { 42 | --bottom-border-length: 77%; 43 | } 44 | 45 | .backContainer { 46 | display: grid; 47 | grid-auto-flow: column; 48 | align-items: center; 49 | column-gap: 0.2em; 50 | } 51 | 52 | .backBtn { 53 | --bottom-border-length: 51%; 54 | 55 | padding: 0.05em 0.22em; 56 | } 57 | 58 | .joinBtn { 59 | --bottom-border-length: 62%; 60 | 61 | font-size: 2.2em; 62 | padding: 0.25em 0.22em; 63 | } 64 | 65 | .btnBackground { 66 | background-color: var(--primary-color); 67 | padding: 0.2em 0.4em; 68 | box-shadow: var(--shadow); 69 | border-radius: 5px; 70 | } 71 | 72 | .btnBackground span { 73 | color: var(--secondary-color); 74 | } 75 | 76 | .gameBoardBtn { 77 | font-size: 1.8em; 78 | padding: 0.3em 0.27em; 79 | } 80 | 81 | .joinBackground { 82 | padding: 0.05em 0.23em; 83 | } 84 | 85 | .createBackground { 86 | padding: 0.2em 0.2em; 87 | } 88 | 89 | .createBtn { 90 | --bottom-border-length: 37%; 91 | 92 | font-size: 1.7em; 93 | padding: 0.3em 0.25em; 94 | } 95 | 96 | .keepBtn { 97 | --bottom-border-length: 62%; 98 | } 99 | 100 | .discardBtn { 101 | --bottom-border-length: 70.5%; 102 | } 103 | 104 | .readyBtn { 105 | --bottom-border-length: 26%; 106 | } 107 | 108 | .restartBtn { 109 | --bottom-border-length: 23%; 110 | } 111 | 112 | .exitBtn { 113 | --bottom-border-length: 41%; 114 | 115 | font-size: 1.8em; 116 | padding: 0.15em 0.27em; 117 | } 118 | 119 | .continueBtn { 120 | --bottom-border-length: 75%; 121 | 122 | font-size: 1.8em; 123 | padding: 0.32em 0.25em; 124 | } 125 | 126 | .continueBackground { 127 | padding: 0.25em 0.3em; 128 | } 129 | -------------------------------------------------------------------------------- /components/gameboard/CardGrid.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | grid-template-columns: 1fr 1fr; 4 | grid-template-areas: 5 | "score name" 6 | "cardgrid cardgrid"; 7 | } 8 | 9 | .container p { 10 | margin: 0; 11 | margin-bottom: 0.2em; 12 | font-size: 1.6em; 13 | font-weight: 400; 14 | padding: 0 1em; 15 | } 16 | 17 | .container p span { 18 | color: var(--secondary-color); 19 | } 20 | 21 | .opponentContainer p { 22 | margin: 0; 23 | margin-top: 0.5em; 24 | font-size: 1em; 25 | font-weight: 300; 26 | padding: 0 1em; 27 | } 28 | 29 | .container p:first-of-type { 30 | grid-area: score; 31 | } 32 | 33 | .container p:last-of-type { 34 | grid-area: name; 35 | justify-self: end; 36 | } 37 | 38 | .lines { 39 | grid-area: cardgrid; 40 | padding: 0.7em; 41 | background-image: linear-gradient( 42 | var(--secondary-color), 43 | var(--secondary-color) 44 | ), 45 | linear-gradient(var(--secondary-color), var(--secondary-color)), 46 | linear-gradient(var(--secondary-color), var(--secondary-color)), 47 | linear-gradient(var(--secondary-color), var(--secondary-color)); 48 | background-size: 3px 30%, 20% 3px; 49 | background-repeat: no-repeat; 50 | background-position: right bottom, right bottom, left top, left top; 51 | transition: var(--bottom-transition); 52 | } 53 | 54 | .lines:hover { 55 | background-size: 3px 30%, 30% 3px; 56 | } 57 | 58 | .cardGrid { 59 | display: grid; 60 | grid-template-columns: repeat(4, 1fr); 61 | grid-template-rows: repeat(3, 1fr); 62 | column-gap: 1em; 63 | row-gap: 0.5em; 64 | background-color: var(--primary-color); 65 | padding: 1.3em; 66 | box-shadow: var(--shadow); 67 | border-radius: 5px; 68 | min-width: 28em; 69 | } 70 | 71 | .card { 72 | width: 5.8em; 73 | cursor: pointer; 74 | } 75 | 76 | .notClickable { 77 | cursor: default; 78 | } 79 | 80 | .opponentContainer { 81 | display: grid; 82 | grid-template-columns: 1fr 1fr; 83 | grid-template-areas: 84 | "cardgrid cardgrid" 85 | "score name"; 86 | } 87 | 88 | .opponentContainer p span { 89 | color: var(--secondary-color); 90 | } 91 | 92 | .opponentContainer p:first-of-type { 93 | grid-area: score; 94 | } 95 | 96 | .opponentContainer p:last-of-type { 97 | grid-area: name; 98 | justify-self: end; 99 | } 100 | 101 | .oppCardGrid { 102 | display: grid; 103 | grid-area: cardgrid; 104 | grid-template-columns: repeat(4, 1fr); 105 | grid-template-rows: repeat(3, 1fr); 106 | column-gap: 0.5em; 107 | row-gap: 0.3em; 108 | background-color: var(--primary-color); 109 | padding: 0.6em; 110 | box-shadow: var(--shadow); 111 | border-radius: 5px; 112 | min-width: 180px; 113 | } 114 | 115 | .oppCardGrid img { 116 | width: 2.1em; 117 | cursor: default; 118 | } 119 | -------------------------------------------------------------------------------- /components/gameboard/DrawPilePrompt.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { SocketContext } from "../../contexts/SocketContext"; 3 | import { getLobbyNr } from "../../lib/functions"; 4 | import { Card } from "../../server/lib/gameTypes"; 5 | import { phase } from "../../server/lib/turnPhases"; 6 | import DiscardBtn from "../button/DiscardBtn"; 7 | import KeepBtn from "../button/KeepBtn"; 8 | import styles from "./DrawPilePrompt.module.css"; 9 | 10 | type DrawPilePrompt = { 11 | turnPhase: string; 12 | }; 13 | 14 | function DrawPilePrompt({ turnPhase }: DrawPilePrompt) { 15 | const { socket } = useContext(SocketContext); 16 | const lobbyNr = getLobbyNr(); 17 | const [drawPileCard, setDrawPileCard] = useState(null); 18 | 19 | useEffect(() => { 20 | if (!socket || !lobbyNr) { 21 | return; 22 | } 23 | 24 | switch (turnPhase) { 25 | case phase.DRAWPILEDECISION: 26 | socket.emit("get new drawpilecard", lobbyNr); 27 | break; 28 | default: 29 | socket.emit("get current drawpilecard", lobbyNr); 30 | break; 31 | } 32 | 33 | function handleDisplayDrawPileCard(card: Card) { 34 | setDrawPileCard(card); 35 | } 36 | 37 | socket.on("display new drawpilecard", (card) => { 38 | if (!card) { 39 | socket.emit("get new drawpilecard", lobbyNr); 40 | } else { 41 | handleDisplayDrawPileCard(card); 42 | } 43 | }); 44 | 45 | socket.on("display current drawpilecard", (card) => { 46 | handleDisplayDrawPileCard(card); 47 | }); 48 | 49 | return () => { 50 | socket.off("display new drawpilecard"); 51 | socket.off("display current drawpilecard"); 52 | }; 53 | }, [socket, lobbyNr]); 54 | 55 | function handleKeepClick() { 56 | switch (turnPhase) { 57 | case phase.DRAWPILEDECISION: 58 | socket.emit("DRAWPILEDECISION: click keep", socket.id, lobbyNr); 59 | break; 60 | case phase.WAITTURN: 61 | return; 62 | default: 63 | return; 64 | } 65 | } 66 | 67 | function handleDiscardClick() { 68 | switch (turnPhase) { 69 | case phase.DRAWPILEDECISION: 70 | socket.emit("DRAWPILEDECISION: click discard", socket.id, lobbyNr); 71 | break; 72 | case phase.WAITTURN: 73 | return; 74 | default: 75 | return; 76 | } 77 | } 78 | 79 | return ( 80 |
    81 | {turnPhase === phase.DRAWPILEDECISION && ( 82 |
    83 | 84 | 85 |
    86 | )} 87 | 91 |
    92 | ); 93 | } 94 | 95 | export default DrawPilePrompt; 96 | -------------------------------------------------------------------------------- /pages/lobbies.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import styles from "../styles/Lobbies.module.css"; 3 | import Link from "next/link"; 4 | import Logo from "../components/logo/Logo"; 5 | import BackBtn from "../components/button/BackBtn"; 6 | import CreateBtn from "../components/button/CreateBtn"; 7 | import LobbyListItem from "../components/misc/LobbyListItem"; 8 | import { useRouter } from "next/router"; 9 | import { useContext, useEffect, useState } from "react"; 10 | import { SocketContext } from "../contexts/SocketContext"; 11 | import { getPlayerName } from "../lib/functions"; 12 | import { GameForLobby } from "../server/lib/gameTypes"; 13 | 14 | export default function Lobbies() { 15 | const { socket } = useContext(SocketContext); 16 | const [lobbyItems, setLobbyItems] = useState([]); 17 | const router = useRouter(); 18 | 19 | useEffect(() => { 20 | if (!socket) { 21 | return; 22 | } 23 | function handleDisplayGames(games: GameForLobby[]) { 24 | setLobbyItems(games); 25 | } 26 | 27 | socket.on("display list of games", handleDisplayGames); 28 | socket.emit("get list of games"); 29 | }, [socket]); 30 | 31 | const goToLobby = (lobbyNr: number) => { 32 | router.push(`/game?lobby=${lobbyNr}`); 33 | }; 34 | 35 | const handleJoinBtnClick = (lobbyNr: number): void => { 36 | const playerName = getPlayerName(); 37 | socket.emit("join game", lobbyNr, playerName, socket.id); 38 | goToLobby(lobbyNr); 39 | }; 40 | 41 | const handleCreateBtnClick = (): void => { 42 | const playerName = getPlayerName(); 43 | socket.emit("create game", playerName, socket.id); 44 | socket.on("pass lobbynr", (lobbyNr: number) => { 45 | goToLobby(lobbyNr); 46 | }); 47 | }; 48 | 49 | return ( 50 | <> 51 | 52 | Lobbies 53 | 54 | 55 | 56 |
    57 |
    58 | 59 | 60 |
    61 | 62 | 63 | 64 | 65 | 66 |
      67 | {lobbyItems.map( 68 | ({ lobbyNr, playerCount, lobbyIsFull, hasStarted }) => { 69 | return ( 70 | handleJoinBtnClick(lobbyNr)} 75 | lobbyIsFull={lobbyIsFull} 76 | hasStarted={hasStarted} 77 | /> 78 | ); 79 | } 80 | )} 81 |
    82 |
    83 |
    84 |
    85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /server/lib/cards.ts: -------------------------------------------------------------------------------- 1 | import { Card, CardToGenerate } from "./gameTypes"; 2 | 3 | export function generateCards(): Card[] { 4 | const cardsToGenerate: CardToGenerate[] = [ 5 | [ 6 | { 7 | value: -2, 8 | imgSrc: "/cards/m2.png", 9 | hidden: true, 10 | }, 11 | 5, 12 | ], 13 | [ 14 | { 15 | value: -1, 16 | imgSrc: "/cards/m1.png", 17 | hidden: true, 18 | }, 19 | 10, 20 | ], 21 | [ 22 | { 23 | value: 0, 24 | imgSrc: "/cards/00.png", 25 | hidden: true, 26 | }, 27 | 15, 28 | ], 29 | [ 30 | { 31 | value: 1, 32 | imgSrc: "/cards/01.png", 33 | hidden: true, 34 | }, 35 | 10, 36 | ], 37 | [ 38 | { 39 | value: 2, 40 | imgSrc: "/cards/02.png", 41 | hidden: true, 42 | }, 43 | 10, 44 | ], 45 | [ 46 | { 47 | value: 3, 48 | imgSrc: "/cards/03.png", 49 | hidden: true, 50 | }, 51 | 10, 52 | ], 53 | [ 54 | { 55 | value: 4, 56 | imgSrc: "/cards/04.png", 57 | hidden: true, 58 | }, 59 | 10, 60 | ], 61 | [ 62 | { 63 | value: 5, 64 | imgSrc: "/cards/05.png", 65 | hidden: true, 66 | }, 67 | 10, 68 | ], 69 | [ 70 | { 71 | value: 6, 72 | imgSrc: "/cards/06.png", 73 | hidden: true, 74 | }, 75 | 10, 76 | ], 77 | [ 78 | { 79 | value: 7, 80 | imgSrc: "/cards/07.png", 81 | hidden: true, 82 | }, 83 | 10, 84 | ], 85 | [ 86 | { 87 | value: 8, 88 | imgSrc: "/cards/08.png", 89 | hidden: true, 90 | }, 91 | 10, 92 | ], 93 | [ 94 | { 95 | value: 9, 96 | imgSrc: "/cards/09.png", 97 | hidden: true, 98 | }, 99 | 10, 100 | ], 101 | [ 102 | { 103 | value: 10, 104 | imgSrc: "/cards/10.png", 105 | hidden: true, 106 | }, 107 | 10, 108 | ], 109 | [ 110 | { 111 | value: 11, 112 | imgSrc: "/cards/11.png", 113 | hidden: true, 114 | }, 115 | 10, 116 | ], 117 | [ 118 | { 119 | value: 12, 120 | imgSrc: "/cards/12.png", 121 | hidden: true, 122 | }, 123 | 10, 124 | ], 125 | ]; 126 | 127 | const cards: Card[] = []; 128 | 129 | cardsToGenerate.forEach(([value, quantity]) => { 130 | cards.push(...Array(quantity).fill(value)); 131 | }); 132 | 133 | return cards.map((card, index) => { 134 | return { 135 | id: index + 1, 136 | value: card.value, 137 | imgSrc: card.imgSrc, 138 | hidden: card.hidden, 139 | }; 140 | }); 141 | } 142 | 143 | export function generateBlankCards(): Card[] { 144 | const cardsToGenerate: CardToGenerate[] = [ 145 | [ 146 | { 147 | value: 0, 148 | imgSrc: "/cards/blank.png", 149 | hidden: false, 150 | }, 151 | 12, 152 | ], 153 | ]; 154 | 155 | const blankCards: Card[] = []; 156 | cardsToGenerate.forEach(([value, quantity]) => { 157 | blankCards.push(...Array(quantity).fill(value)); 158 | }); 159 | 160 | return blankCards; 161 | } 162 | -------------------------------------------------------------------------------- /components/gameboard/CardGrid.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | import { SocketContext } from "../../contexts/SocketContext"; 3 | import { getLobbyNr } from "../../lib/functions"; 4 | import { Card } from "../../server/lib/gameTypes"; 5 | import { phase } from "../../server/lib/turnPhases"; 6 | import styles from "./CardGrid.module.css"; 7 | import type { PlayerCardGridProps } from "./OpponentCardGrid"; 8 | 9 | function CardGrid({ 10 | cards, 11 | name, 12 | roundScore, 13 | gameHasStarted, 14 | turnPhase, 15 | }: PlayerCardGridProps) { 16 | const { socket } = useContext(SocketContext); 17 | const [roundStart, setRoundStart] = useState(false); 18 | const lobbyNr = getLobbyNr(); 19 | 20 | const notClickable = `${styles.card} ${styles.notClickable}`; 21 | 22 | const handleCardClick = (index: number, card: Card): void => { 23 | if (gameHasStarted && !roundStart) { 24 | socket.emit("cardgrid click", socket.id, lobbyNr, index); 25 | socket.emit( 26 | "check 2 cards revealed", 27 | socket.id, 28 | lobbyNr, 29 | (bothCardsRevealed) => { 30 | if (bothCardsRevealed) { 31 | setRoundStart(true); 32 | } 33 | } 34 | ); 35 | } 36 | if (roundStart) { 37 | switch (turnPhase) { 38 | case phase.DRAWDECISION: 39 | return; 40 | case phase.DISCARDPILEDECISION: 41 | socket.emit("DISCARDPILE: replace card", socket.id, lobbyNr, index); 42 | break; 43 | case phase.DRAWPILEKEEP: 44 | socket.emit("DRAWPILE: replace card", socket.id, lobbyNr, index); 45 | break; 46 | case phase.DRAWPILEDISCARD: 47 | if (card.hidden === false) { 48 | socket.emit("DRAWPILE: invalid reveal card", socket.id, lobbyNr); 49 | return; 50 | } 51 | socket.emit("DRAWPILE: reveal card", socket.id, lobbyNr, index); 52 | break; 53 | case phase.WAITTURN: 54 | return; 55 | default: 56 | return; 57 | } 58 | } 59 | }; 60 | 61 | function cardStyle() { 62 | switch (turnPhase) { 63 | case phase.DRAWDECISION: 64 | return notClickable; 65 | case phase.WAITTURN: 66 | return notClickable; 67 | case phase.DRAWPILEDECISION: 68 | return notClickable; 69 | case "discardPileDecision": 70 | return styles.card; 71 | default: 72 | return styles.card; 73 | } 74 | } 75 | 76 | const createPlayerCardGrid = cards.map((card, index) => ( 77 | handleCardClick(index, card) 86 | : () => { 87 | return; 88 | } 89 | } 90 | /> 91 | )); 92 | 93 | return ( 94 |
    95 |

    96 | Score: {roundScore} 97 |

    98 |

    {name}

    99 |
    100 |
    {createPlayerCardGrid}
    101 |
    102 |
    103 | ); 104 | } 105 | 106 | export default CardGrid; 107 | -------------------------------------------------------------------------------- /server/lib/broadcasts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getActivePlayer, 3 | getCurrentDrawPileCard, 4 | getDiscardPile, 5 | getFirstActivePlayer, 6 | getGameByLobby, 7 | getGamesForLobby, 8 | getNewDrawPileCard, 9 | getPlayerCount, 10 | getPlayersInLobby, 11 | getRoundNr, 12 | getTotalScores, 13 | } from "./games"; 14 | import { status } from "./statusMessages"; 15 | import { phase } from "./turnPhases"; 16 | 17 | export function broadcastListGamesUpdate(io): void { 18 | io.emit("display list of games", getGamesForLobby()); 19 | } 20 | 21 | export function broadcastPlayerCountToLobby(io, lobbyNr: number): void { 22 | io.to(`lobby${lobbyNr}`).emit( 23 | "display current playercount", 24 | getPlayerCount(lobbyNr) 25 | ); 26 | } 27 | 28 | export function broadcastTotalScoresToLobby(io, lobbyNr: number): void { 29 | io.to(`lobby${lobbyNr}`).emit("display scores", getTotalScores(lobbyNr)); 30 | } 31 | 32 | export function broadcastPlayersToLobby(io, lobbyNr: number): void { 33 | io.to(`lobby${lobbyNr}`).emit("display players", getPlayersInLobby(lobbyNr)); 34 | } 35 | 36 | export function broadcastGameStartToLobby(io, lobbyNr: number): void { 37 | io.to(`lobby${lobbyNr}`).emit("all players ready", getGameByLobby(lobbyNr)); 38 | } 39 | 40 | export function broadcastDiscardPileToLobby(io, lobbyNr: number): void { 41 | io.to(`lobby${lobbyNr}`).emit("display discardpile", getDiscardPile(lobbyNr)); 42 | } 43 | 44 | export function broadcastRoundNrToLobby(io, lobbyNr: number): void { 45 | io.to(`lobby${lobbyNr}`).emit("display rounds", getRoundNr(lobbyNr)); 46 | } 47 | 48 | export function broadcastNewDrawPileCardToLobby(io, lobbyNr: number): void { 49 | io.to(`lobby${lobbyNr}`).emit( 50 | "display new drawpilecard", 51 | getNewDrawPileCard(lobbyNr) 52 | ); 53 | } 54 | 55 | export function broadcastCurrentDrawPileCardToLobby(io, lobbyNr: number): void { 56 | io.to(`lobby${lobbyNr}`).emit( 57 | "display current drawpilecard", 58 | getCurrentDrawPileCard(lobbyNr) 59 | ); 60 | } 61 | 62 | export async function broadcastFirstActivePlayerToLobby( 63 | io, 64 | lobbyNr: number, 65 | socketID: string 66 | ): Promise { 67 | io.to(`lobby${lobbyNr}`).emit( 68 | "set first active player", 69 | await getFirstActivePlayer(lobbyNr, socketID) 70 | ); 71 | } 72 | 73 | export async function broadcastStatusToActivePlayer( 74 | io, 75 | socketID: string, 76 | lobbyNr: number, 77 | activePlayerStatus: string 78 | ) { 79 | const activePlayer = await getActivePlayer(socketID); 80 | io.to(`lobby${lobbyNr}`).emit( 81 | "display status", 82 | status.WAITTURN(activePlayer.name) 83 | ); 84 | io.to(activePlayer.socketID).emit("display status", activePlayerStatus); 85 | } 86 | 87 | export async function broadcastTurnPhaseToActivePlayer( 88 | io, 89 | socketID: string, 90 | lobbyNr: number, 91 | activePlayerTurnPhase: string 92 | ) { 93 | const activePlayer = await getActivePlayer(socketID); 94 | io.to(`lobby${lobbyNr}`).emit("set turn phase", phase.WAITTURN); 95 | io.to(activePlayer.socketID).emit("set turn phase", activePlayerTurnPhase); 96 | } 97 | 98 | export async function broadcastTurnStartToActivePlayer( 99 | io, 100 | socketID: string, 101 | lobbyNr: number 102 | ) { 103 | await broadcastStatusToActivePlayer( 104 | io, 105 | socketID, 106 | lobbyNr, 107 | status.DRAWDECISION 108 | ); 109 | await broadcastTurnPhaseToActivePlayer( 110 | io, 111 | socketID, 112 | lobbyNr, 113 | phase.DRAWDECISION 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /pages/game.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import styles from "../styles/Game.module.css"; 3 | import Logo from "../components/logo/Logo"; 4 | import ExitBtn from "../components/button/ExitBtn"; 5 | import RoundCounter from "../components/gameboard/RoundCounter"; 6 | import Statusbar from "../components/gameboard/Statusbar"; 7 | import ReadyBtn from "../components/button/ReadyBtn"; 8 | import TotalScore from "../components/gameboard/TotalScore"; 9 | import DrawPile from "../components/gameboard/DrawPile"; 10 | import DiscardPile from "../components/gameboard/DiscardPile"; 11 | import CardGrid from "../components/gameboard/CardGrid"; 12 | import OpponentCardGrid from "../components/gameboard/OpponentCardGrid"; 13 | import { SocketContext } from "../contexts/SocketContext"; 14 | import { useContext, useEffect, useState } from "react"; 15 | import { getLobbyNr } from "../lib/functions"; 16 | import { useRouter } from "next/router"; 17 | import type { 18 | ActivePlayer, 19 | Card, 20 | PlayerForCardGrid, 21 | } from "../server/lib/gameTypes"; 22 | import { generateBlankCards } from "../server/lib/cards"; 23 | import DrawPilePrompt from "../components/gameboard/DrawPilePrompt"; 24 | 25 | export default function Game() { 26 | const { socket } = useContext(SocketContext); 27 | const [playerCount, setPlayerCount] = useState(null); 28 | const [opponentPlayers, setOpponentPlayers] = useState( 29 | [] 30 | ); 31 | const [player, setPlayer] = useState(null); 32 | const [discardPileCard, setDiscardPileCard] = useState(null); 33 | const [gameHasStarted, setGameHasStarted] = useState(false); 34 | const [activePlayer, setActivePlayer] = useState(null); 35 | const [turnPhase, setTurnPhases] = useState(null); 36 | const router = useRouter(); 37 | const lobbyNr = getLobbyNr(); 38 | const blankCards = generateBlankCards(); 39 | 40 | useEffect(() => { 41 | if (!socket || !lobbyNr) { 42 | return; 43 | } 44 | 45 | function handleCurrentPlayerCount(count: number) { 46 | setPlayerCount(count); 47 | } 48 | 49 | function handleDisplayPlayers(players: PlayerForCardGrid[]) { 50 | const opponentPlayers = players.filter( 51 | (player) => player.socketID !== socket.id 52 | ); 53 | const player = players.find((player) => player.socketID === socket.id); 54 | setOpponentPlayers(opponentPlayers); 55 | setPlayer(player); 56 | } 57 | 58 | function handleDisplayDiscardPile(card: Card) { 59 | setDiscardPileCard(card); 60 | } 61 | 62 | socket.emit("get discardpile", lobbyNr); 63 | socket.on("display discardpile", handleDisplayDiscardPile); 64 | 65 | socket.emit("get playercount", lobbyNr); 66 | socket.on("display current playercount", handleCurrentPlayerCount); 67 | 68 | socket.emit("get players", lobbyNr, socket.id); 69 | socket.on("display players", (players) => { 70 | if (!players) { 71 | socket.emit("get players", lobbyNr, socket.id); 72 | } else { 73 | handleDisplayPlayers(players); 74 | } 75 | }); 76 | 77 | socket.on("set first active player", setActivePlayer); 78 | 79 | socket.on("set turn phase", setTurnPhases); 80 | 81 | return () => { 82 | socket.off("display discardpile", handleDisplayDiscardPile); 83 | socket.off("display current playercount", handleCurrentPlayerCount); 84 | socket.off("display players", handleDisplayPlayers); 85 | socket.off("set first active player", setActivePlayer); 86 | }; 87 | }, [socket, lobbyNr]); 88 | 89 | const handleExitBtnClick = (): void => { 90 | if ( 91 | confirm( 92 | "Do you really want to leave the game? (Reconnecting is not possible)" 93 | ) 94 | ) { 95 | socket.emit("leave game", socket.id, lobbyNr); 96 | router.push("/lobbies"); 97 | } 98 | }; 99 | 100 | const handleReadyBtnClick = (): void => { 101 | socket.emit("player is ready", socket.id, lobbyNr); 102 | socket.emit("check all players ready", lobbyNr); 103 | socket.on("all players ready", (game) => { 104 | setGameHasStarted(game.hasStarted); 105 | }); 106 | }; 107 | 108 | const opponentCardGrids = opponentPlayers.map( 109 | ({ name, cards, roundScore, socketID }) => { 110 | return ( 111 | 117 | ); 118 | } 119 | ); 120 | 121 | const opponentsLayout = { 122 | /* stylelint-disable */ 123 | gridTemplateColumns: `repeat(${Math.min(6, playerCount - 1)}, 1fr)`, 124 | /* stylelint-enable */ 125 | }; 126 | 127 | return ( 128 | <> 129 | 130 | Sisu - Lobby #{lobbyNr} 131 | 132 | 133 | 134 |
    135 | 136 |
    137 | 145 | 150 |
    151 |
    152 | {opponentCardGrids} 153 |
    154 |
    161 | {player && ( 162 | 169 | )} 170 |
    171 |
    172 | 173 |
    174 |
    175 |
    176 |
    177 | 178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /server/socket.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from "socket.io"; 2 | import { phase } from "./lib/turnPhases"; 3 | import { 4 | broadcastCurrentDrawPileCardToLobby, 5 | broadcastDiscardPileToLobby, 6 | broadcastFirstActivePlayerToLobby, 7 | broadcastGameStartToLobby, 8 | broadcastListGamesUpdate, 9 | broadcastNewDrawPileCardToLobby, 10 | broadcastPlayerCountToLobby, 11 | broadcastPlayersToLobby, 12 | broadcastStatusToActivePlayer, 13 | broadcastTotalScoresToLobby, 14 | broadcastTurnPhaseToActivePlayer, 15 | broadcastTurnStartToActivePlayer, 16 | broadcastRoundNrToLobby, 17 | } from "./lib/broadcasts"; 18 | import { 19 | calculateRoundScore, 20 | cardReplaceDiscardPileClick, 21 | cardReplaceDrawPileKeepClick, 22 | cardRevealClick, 23 | checkAllPlayersCardsRevealed, 24 | checkAllPlayersReady, 25 | checkCardsVerticalRow, 26 | checkCardsRevealed, 27 | checkIsLobbyFull, 28 | createGame, 29 | dealCardsToPlayers, 30 | discardCurrentDrawPileCard, 31 | getGame, 32 | getGameByLobby, 33 | getGamesForLobby, 34 | getPlayer, 35 | joinGame, 36 | leaveGame, 37 | setNextActivePlayer, 38 | } from "./lib/games"; 39 | import { status } from "./lib/statusMessages"; 40 | 41 | export function listenSocket(server): void { 42 | const io = new Server(server, {}); 43 | let lobbyNr = 1; 44 | 45 | io.on("connection", (socket: Socket) => { 46 | console.log(socket.id + " connected"); 47 | 48 | socket.on("disconnect", async () => { 49 | console.log(socket.id + " disconnected"); 50 | }); 51 | 52 | socket.on("disconnecting", async () => { 53 | for (let i = 1; i <= 30; i++) { 54 | if (socket.rooms.has(`lobby${i}`)) { 55 | const lobbyNr = (await getGame(socket.id)).lobbyNr; 56 | await leaveGame(socket.id); 57 | broadcastTotalScoresToLobby(io, lobbyNr); 58 | broadcastPlayerCountToLobby(io, lobbyNr); 59 | broadcastPlayersToLobby(io, lobbyNr); 60 | broadcastListGamesUpdate(io); 61 | return; 62 | } 63 | } 64 | }); 65 | 66 | socket.on("leave game", async (socketID: string, lobbyNr: number) => { 67 | socket.leave(`lobby${lobbyNr}`); 68 | await leaveGame(socketID); 69 | broadcastListGamesUpdate(io); 70 | broadcastTotalScoresToLobby(io, lobbyNr); 71 | broadcastPlayerCountToLobby(io, lobbyNr); 72 | broadcastPlayersToLobby(io, lobbyNr); 73 | }); 74 | 75 | socket.on("get list of games", () => { 76 | socket.emit("display list of games", getGamesForLobby()); 77 | }); 78 | 79 | socket.on("get scores to display", (lobbyNr: number) => { 80 | broadcastTotalScoresToLobby(io, lobbyNr); 81 | }); 82 | 83 | socket.on("get rounds to display", (lobbyNr: number) => { 84 | broadcastRoundNrToLobby(io, lobbyNr); 85 | }); 86 | 87 | socket.on("get playercount", (lobbyNr: number) => { 88 | broadcastPlayerCountToLobby(io, lobbyNr); 89 | }); 90 | 91 | socket.on("get players", (lobbyNr: number) => { 92 | broadcastPlayersToLobby(io, lobbyNr); 93 | }); 94 | 95 | socket.on("get discardpile", (lobbyNr: number) => { 96 | broadcastDiscardPileToLobby(io, lobbyNr); 97 | }); 98 | 99 | socket.on("get new drawpilecard", async (lobbyNr: number) => { 100 | broadcastNewDrawPileCardToLobby(io, lobbyNr); 101 | }); 102 | 103 | socket.on("get current drawpilecard", async (lobbyNr: number) => { 104 | broadcastCurrentDrawPileCardToLobby(io, lobbyNr); 105 | }); 106 | 107 | socket.on("create game", (playerName: string, socketID: string) => { 108 | socket.join(`lobby${lobbyNr}`); 109 | createGame(lobbyNr, playerName, socketID); 110 | socket.emit("pass lobbynr", lobbyNr); 111 | broadcastPlayerCountToLobby(io, lobbyNr); 112 | broadcastTotalScoresToLobby(io, lobbyNr); 113 | broadcastPlayersToLobby(io, lobbyNr); 114 | broadcastListGamesUpdate(io); 115 | lobbyNr++; 116 | }); 117 | 118 | socket.on( 119 | "join game", 120 | (lobbyNr: number, playerName: string, socketID: string) => { 121 | socket.join(`lobby${lobbyNr}`); 122 | joinGame(lobbyNr, playerName, socketID); 123 | checkIsLobbyFull(lobbyNr); 124 | broadcastPlayerCountToLobby(io, lobbyNr); 125 | broadcastTotalScoresToLobby(io, lobbyNr); 126 | broadcastPlayersToLobby(io, lobbyNr); 127 | broadcastListGamesUpdate(io); 128 | } 129 | ); 130 | 131 | socket.on("player is ready", async (socketID, lobbyNr) => { 132 | const player = await getPlayer(socketID); 133 | player.isReady = true; 134 | broadcastPlayersToLobby(io, lobbyNr); 135 | }); 136 | 137 | socket.on("check all players ready", (lobbyNr: number) => { 138 | if (checkAllPlayersReady(lobbyNr)) { 139 | getGameByLobby(lobbyNr).hasStarted = true; 140 | broadcastGameStartToLobby(io, lobbyNr); 141 | dealCardsToPlayers(12, lobbyNr); 142 | broadcastPlayersToLobby(io, lobbyNr); 143 | broadcastDiscardPileToLobby(io, lobbyNr); 144 | broadcastListGamesUpdate(io); 145 | io.to(`lobby${lobbyNr}`).emit("display status", status.PRESTART); 146 | } else { 147 | return; 148 | } 149 | }); 150 | 151 | socket.on( 152 | "cardgrid click", 153 | async (socketID: string, lobbyNr: number, index: number) => { 154 | await cardRevealClick(socketID, index); 155 | await calculateRoundScore(socketID, lobbyNr); 156 | broadcastPlayersToLobby(io, lobbyNr); 157 | } 158 | ); 159 | 160 | socket.on( 161 | "check 2 cards revealed", 162 | async (socketID: string, lobbyNr: number, callback) => { 163 | const bothCardsRevealed = await checkCardsRevealed( 164 | socketID, 165 | 2, 166 | lobbyNr 167 | ); 168 | 169 | if (bothCardsRevealed) { 170 | io.to(socketID).emit("display status", status.PRESTARTWAIT); 171 | } 172 | if (checkAllPlayersCardsRevealed(lobbyNr, 2)) { 173 | await broadcastFirstActivePlayerToLobby(io, lobbyNr, socketID); 174 | await broadcastStatusToActivePlayer( 175 | io, 176 | socketID, 177 | lobbyNr, 178 | status.DRAWDECISION 179 | ); 180 | await broadcastTurnPhaseToActivePlayer( 181 | io, 182 | socketID, 183 | lobbyNr, 184 | phase.DRAWDECISION 185 | ); 186 | } 187 | callback(bothCardsRevealed); 188 | } 189 | ); 190 | 191 | socket.on( 192 | "DRAWDECISION: click discardpile", 193 | async (socketID: string, lobbyNr: number) => { 194 | await broadcastStatusToActivePlayer( 195 | io, 196 | socketID, 197 | lobbyNr, 198 | status.DRAWDISCARDPILEKEEP 199 | ); 200 | await broadcastTurnPhaseToActivePlayer( 201 | io, 202 | socketID, 203 | lobbyNr, 204 | phase.DISCARDPILEDECISION 205 | ); 206 | } 207 | ); 208 | 209 | socket.on( 210 | "DRAWDECISION: click drawpile", 211 | async (socketID: string, lobbyNr: number) => { 212 | await broadcastStatusToActivePlayer( 213 | io, 214 | socketID, 215 | lobbyNr, 216 | status.DRAWPILEDECISION 217 | ); 218 | await broadcastTurnPhaseToActivePlayer( 219 | io, 220 | socketID, 221 | lobbyNr, 222 | phase.DRAWPILEDECISION 223 | ); 224 | } 225 | ); 226 | 227 | socket.on( 228 | "DISCARDPILE: replace card", 229 | async (socketID: string, lobbyNr: number, index: number) => { 230 | await cardReplaceDiscardPileClick(socketID, lobbyNr, index); 231 | await checkCardsVerticalRow(socketID, lobbyNr); 232 | await checkCardsRevealed(socketID, 12, lobbyNr); 233 | await calculateRoundScore(socketID, lobbyNr); 234 | await setNextActivePlayer(socketID, lobbyNr); 235 | broadcastPlayersToLobby(io, lobbyNr); 236 | broadcastDiscardPileToLobby(io, lobbyNr); 237 | broadcastTotalScoresToLobby(io, lobbyNr); 238 | broadcastRoundNrToLobby(io, lobbyNr); 239 | await broadcastTurnStartToActivePlayer(io, socketID, lobbyNr); 240 | //edit 241 | } 242 | ); 243 | 244 | socket.on( 245 | "DRAWPILEDECISION: click keep", 246 | async (socketID: string, lobbyNr: number) => { 247 | await broadcastStatusToActivePlayer( 248 | io, 249 | socketID, 250 | lobbyNr, 251 | status.DRAWDISCARDPILEKEEP 252 | ); 253 | await broadcastTurnPhaseToActivePlayer( 254 | io, 255 | socketID, 256 | lobbyNr, 257 | phase.DRAWPILEKEEP 258 | ); 259 | } 260 | ); 261 | 262 | socket.on( 263 | "DRAWPILEDECISION: click discard", 264 | async (socketID: string, lobbyNr: number) => { 265 | await broadcastStatusToActivePlayer( 266 | io, 267 | socketID, 268 | lobbyNr, 269 | status.DRAWPILEDISCARD 270 | ); 271 | await broadcastTurnPhaseToActivePlayer( 272 | io, 273 | socketID, 274 | lobbyNr, 275 | phase.DRAWPILEDISCARD 276 | ); 277 | discardCurrentDrawPileCard(lobbyNr); 278 | broadcastDiscardPileToLobby(io, lobbyNr); 279 | broadcastCurrentDrawPileCardToLobby(io, lobbyNr); 280 | } 281 | ); 282 | 283 | socket.on( 284 | "DRAWPILE: invalid reveal card", 285 | async (socketID: string, lobbyNr: number) => { 286 | await broadcastStatusToActivePlayer( 287 | io, 288 | socketID, 289 | lobbyNr, 290 | status.DRAWPILEDISCARDINVALID 291 | ); 292 | } 293 | ); 294 | 295 | socket.on( 296 | "DRAWPILE: replace card", 297 | async (socketID: string, lobbyNr: number, index: number) => { 298 | await cardReplaceDrawPileKeepClick(socketID, lobbyNr, index); 299 | await checkCardsVerticalRow(socketID, lobbyNr); 300 | await checkCardsRevealed(socketID, 12, lobbyNr); 301 | await calculateRoundScore(socketID, lobbyNr); 302 | await setNextActivePlayer(socketID, lobbyNr); 303 | broadcastCurrentDrawPileCardToLobby(io, lobbyNr); 304 | broadcastPlayersToLobby(io, lobbyNr); 305 | broadcastDiscardPileToLobby(io, lobbyNr); 306 | broadcastTotalScoresToLobby(io, lobbyNr); 307 | broadcastRoundNrToLobby(io, lobbyNr); 308 | await broadcastTurnStartToActivePlayer(io, socketID, lobbyNr); 309 | //edit 310 | } 311 | ); 312 | 313 | socket.on( 314 | "DRAWPILE: reveal card", 315 | async (socketID: string, lobbyNr: number, index: number) => { 316 | await cardRevealClick(socketID, index); 317 | await checkCardsVerticalRow(socketID, lobbyNr); 318 | await checkCardsRevealed(socketID, 12, lobbyNr); 319 | await calculateRoundScore(socketID, lobbyNr); 320 | await setNextActivePlayer(socketID, lobbyNr); 321 | broadcastPlayersToLobby(io, lobbyNr); 322 | broadcastTotalScoresToLobby(io, lobbyNr); 323 | broadcastRoundNrToLobby(io, lobbyNr); 324 | await broadcastTurnStartToActivePlayer(io, socketID, lobbyNr); 325 | //edit 326 | } 327 | ); 328 | }); 329 | } 330 | -------------------------------------------------------------------------------- /server/lib/games.ts: -------------------------------------------------------------------------------- 1 | import { generateCards } from "./cards"; 2 | import type { 3 | Game, 4 | GamesType, 5 | GameForLobby, 6 | Player, 7 | PlayerScoreList, 8 | PlayerForCardGrid, 9 | Card, 10 | ActivePlayer, 11 | } from "./gameTypes"; 12 | 13 | const games: GamesType = {}; 14 | 15 | export function createGame( 16 | lobbyNr: number, 17 | playerName: string, 18 | socketID: string 19 | ): void { 20 | games[lobbyNr] = { 21 | lobbyNr, 22 | roundNr: 1, 23 | playerCount: 1, 24 | lobbyIsFull: false, 25 | hasStarted: false, 26 | activePlayerIndex: null, 27 | players: [ 28 | { 29 | name: playerName, 30 | socketID: socketID, 31 | isReady: false, 32 | cards: [], 33 | totalScore: 0, 34 | roundScore: [], 35 | allCardsRevealed: false, 36 | }, 37 | ], 38 | drawPileCards: generateCards(), 39 | tempDrawPileCard: null, 40 | discardPileCards: [], 41 | currentRoundScores: [], 42 | }; 43 | //commented out for now 44 | // console.log(JSON.stringify(games, null, 4)); 45 | } 46 | 47 | export function checkIsLobbyFull(lobbyNr: number): void { 48 | if (games[lobbyNr].playerCount >= 8) { 49 | games[lobbyNr].lobbyIsFull = true; 50 | } 51 | } 52 | 53 | export function joinGame( 54 | lobbyNr: number, 55 | playerName: string, 56 | socketID: string 57 | ): void { 58 | console.log(playerName + ":" + socketID + " joined " + lobbyNr); 59 | const playersInGame = games[lobbyNr].players; 60 | playersInGame.push({ 61 | name: playerName, 62 | socketID: socketID, 63 | isReady: false, 64 | cards: [], 65 | totalScore: 0, 66 | roundScore: [], 67 | allCardsRevealed: false, 68 | }); 69 | games[lobbyNr].playerCount++; 70 | } 71 | 72 | export async function leaveGame(socketID: string): Promise { 73 | const currentGame = await getGame(socketID); 74 | 75 | const currentGamePlayers = currentGame.players.map( 76 | (player) => player.socketID 77 | ); 78 | const indexOfTargetPlayer = currentGamePlayers.indexOf(socketID); 79 | 80 | if (indexOfTargetPlayer > -1) { 81 | currentGame.players.splice(indexOfTargetPlayer, 1); 82 | currentGame.playerCount--; 83 | } 84 | } 85 | 86 | export function getGamesForLobby(): GameForLobby[] { 87 | return Object.values(games).map((game) => { 88 | return { 89 | lobbyNr: game.lobbyNr, 90 | playerCount: game.playerCount, 91 | lobbyIsFull: game.lobbyIsFull, 92 | hasStarted: game.hasStarted, 93 | }; 94 | }); 95 | } 96 | 97 | export async function getGame(socketID: string): Promise { 98 | return Object.values(games).find((game) => 99 | game.players.find((id) => id.socketID === socketID) 100 | ); 101 | } 102 | 103 | export async function getPlayer(socketID: string): Promise { 104 | const currentGame = await getGame(socketID); 105 | const player = currentGame.players.find((id) => id.socketID === socketID); 106 | 107 | return player; 108 | } 109 | 110 | export function getTotalScores(lobbyNr: number): PlayerScoreList[] { 111 | return games[lobbyNr].players.map((player) => { 112 | return { name: player.name, totalScore: player.totalScore }; 113 | }); 114 | } 115 | 116 | export function getRoundNr(lobbyNr: number): number { 117 | return games[lobbyNr].roundNr; 118 | } 119 | 120 | export function getPlayerCount(lobbyNr: number): number { 121 | return games[lobbyNr].playerCount; 122 | } 123 | 124 | export function getPlayersInLobby(lobbyNr: number): PlayerForCardGrid[] { 125 | return games[lobbyNr].players.map((player) => { 126 | return { 127 | name: player.name, 128 | cards: player.cards, 129 | roundScore: player.roundScore, 130 | socketID: player.socketID, 131 | isReady: player.isReady, 132 | }; 133 | }); 134 | } 135 | 136 | export function checkAllPlayersReady(lobbyNr: number): boolean { 137 | const players = games[lobbyNr].players; 138 | return players.every((player) => player.isReady); 139 | } 140 | 141 | export function getGameByLobby(lobbyNr: number): Game { 142 | return games[lobbyNr]; 143 | } 144 | 145 | export function getDiscardPile(lobbyNr: number): Card { 146 | const discardPile = getGameByLobby(lobbyNr).discardPileCards; 147 | return discardPile[discardPile.length - 1]; 148 | } 149 | 150 | // export function getNumberOfGames(): number { 151 | // return games[[0].length]; 152 | // } 153 | 154 | export function getRandomCard(lobbyNr: number): Card { 155 | const drawPile = getGameByLobby(lobbyNr).drawPileCards; 156 | const randomIndex = Math.floor(Math.random() * (drawPile.length + 1)); 157 | const randomCard = drawPile[randomIndex]; 158 | drawPile.splice(randomIndex, 1); 159 | if (randomCard === null) { 160 | getRandomCard(lobbyNr); 161 | } else { 162 | return randomCard; 163 | } 164 | } 165 | 166 | export function dealCardsToPlayers(amount: number, lobbyNr: number): void { 167 | const playerCount = games[lobbyNr].playerCount; 168 | 169 | for (let i = 0; i < playerCount; i++) { 170 | const player = games[lobbyNr].players[i]; 171 | for (let j = 1; j <= amount; j++) { 172 | const randomCard = getRandomCard(lobbyNr); 173 | player.cards.push(randomCard); 174 | } 175 | } 176 | 177 | const randomCardDiscardPile = getRandomCard(lobbyNr); 178 | randomCardDiscardPile.hidden = false; 179 | games[lobbyNr].discardPileCards.push(randomCardDiscardPile); 180 | } 181 | 182 | export async function cardRevealClick( 183 | socketID: string, 184 | index: number 185 | ): Promise { 186 | (await getPlayer(socketID)).cards[index].hidden = false; 187 | } 188 | 189 | export async function cardReplaceDiscardPileClick( 190 | socketID: string, 191 | lobbyNr: number, 192 | index: number 193 | ): Promise { 194 | const playerCards = (await getPlayer(socketID)).cards; 195 | const clickedCard = (await getPlayer(socketID)).cards[index]; 196 | const indexLastDiscardPileCard = games[lobbyNr].discardPileCards.length - 1; 197 | const discardPileCard = 198 | games[lobbyNr].discardPileCards[indexLastDiscardPileCard]; 199 | 200 | const replaceClickedCardWithDiscardPileCard = () => { 201 | clickedCard.hidden = false; 202 | discardPileCard.hidden = false; 203 | 204 | playerCards.splice(index, 0, discardPileCard); 205 | games[lobbyNr].discardPileCards.splice( 206 | indexLastDiscardPileCard, 207 | 1, 208 | clickedCard 209 | ); 210 | playerCards.splice(index + 1, 1); 211 | }; 212 | replaceClickedCardWithDiscardPileCard(); 213 | } 214 | 215 | export async function cardReplaceDrawPileKeepClick( 216 | socketID: string, 217 | lobbyNr: number, 218 | index: number 219 | ): Promise { 220 | const playerCards = (await getPlayer(socketID)).cards; 221 | const clickedCard = (await getPlayer(socketID)).cards[index]; 222 | const drawPileCard = games[lobbyNr].tempDrawPileCard; 223 | 224 | const replaceClickedCardWithDrawPileCard = () => { 225 | clickedCard.hidden = false; 226 | 227 | const replacedCard = playerCards.splice(index, 1, drawPileCard); 228 | games[lobbyNr].discardPileCards.push(replacedCard[0]); 229 | games[lobbyNr].tempDrawPileCard = null; 230 | }; 231 | replaceClickedCardWithDrawPileCard(); 232 | } 233 | 234 | export async function checkCardsVerticalRow( 235 | socketID: string, 236 | lobbyNr: number 237 | ): Promise { 238 | const activePlayer = await getPlayer(socketID); 239 | const cards = activePlayer.cards; 240 | const blankCard = { 241 | value: 0, 242 | imgSrc: "/cards/blank.png", 243 | hidden: false, 244 | }; 245 | 246 | function checkRow(cardOne: number, cardTwo: number, cardThree: number): void { 247 | if ( 248 | cards[cardOne].hidden === false && 249 | cards[cardTwo].hidden === false && 250 | cards[cardThree].hidden === false && 251 | cards[cardOne].value === cards[cardTwo].value && 252 | cards[cardOne].value === cards[cardThree].value 253 | ) { 254 | games[lobbyNr].discardPileCards.push( 255 | cards[cardOne], 256 | cards[cardTwo], 257 | cards[cardThree] 258 | ); 259 | cards.splice(cardOne, 1, blankCard); 260 | cards.splice(cardTwo, 1, blankCard); 261 | cards.splice(cardThree, 1, blankCard); 262 | } 263 | } 264 | 265 | checkRow(0, 4, 8); 266 | checkRow(1, 5, 9); 267 | checkRow(2, 6, 10); 268 | checkRow(3, 7, 11); 269 | } 270 | 271 | export async function calculateRoundScore(socketID: string, lobbyNr: number) { 272 | const player = await getPlayer(socketID); 273 | const roundNr = games[lobbyNr].roundNr; 274 | let roundScore = null; 275 | 276 | player.cards.forEach((card) => { 277 | if (card.hidden === false) { 278 | roundScore = roundScore + card.value; 279 | } 280 | }); 281 | 282 | player.roundScore[roundNr - 1] = roundScore; 283 | } 284 | 285 | export async function calculateTotalScores(socketID: string): Promise { 286 | const currentGame = await getGame(socketID); 287 | 288 | function checkFinishingScore(currentGame: Game) { 289 | const firstPlayerFinished = currentGame.players.find( 290 | (player) => player.socketID === currentGame.currentRoundScores[0].socketID 291 | ); 292 | 293 | const remainingPlayers = currentGame.players.filter( 294 | (player) => player.socketID !== firstPlayerFinished.socketID 295 | ); 296 | 297 | const firstPlayerScore = 298 | firstPlayerFinished.roundScore[firstPlayerFinished.roundScore.length - 1]; 299 | 300 | if ( 301 | remainingPlayers.some( 302 | (player) => 303 | firstPlayerScore >= player.roundScore[player.roundScore.length - 1] 304 | ) 305 | ) { 306 | firstPlayerFinished.roundScore[ 307 | firstPlayerFinished.roundScore.length - 1 308 | ] = firstPlayerScore * 2; 309 | } 310 | } 311 | 312 | function getPlayerRoundScores(index: number): number[] { 313 | return currentGame.players[index].roundScore; 314 | } 315 | 316 | function calculateTotalScore(roundScores: number[]): number { 317 | return roundScores.reduce((a, b) => { 318 | return a + b; 319 | }); 320 | } 321 | 322 | checkFinishingScore(currentGame); 323 | currentGame.players.forEach((player, index) => { 324 | const playerRoundScores = getPlayerRoundScores(index); 325 | const newTotalScore = calculateTotalScore(playerRoundScores); 326 | 327 | player.totalScore = newTotalScore; 328 | }); 329 | } 330 | 331 | export async function checkCardsRevealed( 332 | socketID: string, 333 | amount: number, 334 | lobbyNr: number 335 | ): Promise { 336 | const player = await getPlayer(socketID); 337 | const revealedCards = player.cards.filter((card) => card.hidden === false); 338 | 339 | if (revealedCards.length === amount) { 340 | if (revealedCards.length === 12) { 341 | player.allCardsRevealed = true; 342 | games[lobbyNr].currentRoundScores.push({ 343 | socketID: player.socketID, 344 | roundScore: player.roundScore, 345 | }); 346 | } 347 | return true; 348 | } else { 349 | return false; 350 | } 351 | } 352 | 353 | export function checkAllPlayersCardsRevealed( 354 | lobbyNr: number, 355 | amount: number 356 | ): boolean { 357 | const currentGame = getGameByLobby(lobbyNr); 358 | return currentGame.players.every((player) => { 359 | const revealedCards = player.cards.filter((card) => card.hidden === false); 360 | if (revealedCards.length === amount) { 361 | return true; 362 | } 363 | }); 364 | } 365 | 366 | export function getCurrentRoundscores(lobbyNr: number): number[] { 367 | const currentGame = getGameByLobby(lobbyNr); 368 | return currentGame.players.map((player) => { 369 | const lastScore = player.roundScore.length - 1; 370 | return player.roundScore[lastScore]; 371 | }); 372 | } 373 | 374 | export async function getFirstActivePlayer( 375 | lobbyNr: number, 376 | socketid: string 377 | ): Promise { 378 | const currentGame = await getGame(socketid); 379 | const roundScores = getCurrentRoundscores(lobbyNr); 380 | const indexHighestRoundScore = roundScores.indexOf(Math.max(...roundScores)); 381 | currentGame.activePlayerIndex = indexHighestRoundScore; 382 | const { name, socketID, roundScore } = currentGame.players[ 383 | indexHighestRoundScore 384 | ]; 385 | 386 | const activePlayer: ActivePlayer = { 387 | name: name, 388 | socketID: socketID, 389 | roundScore: roundScore, 390 | }; 391 | return activePlayer; 392 | } 393 | 394 | export async function getActivePlayer(socketID: string): Promise { 395 | const currentGame = await getGame(socketID); 396 | const indexActivePlayer = currentGame.activePlayerIndex; 397 | const activePlayer = currentGame.players[indexActivePlayer]; 398 | return activePlayer; 399 | } 400 | 401 | export async function setNextActivePlayer( 402 | socketID: string, 403 | lobbyNr: number 404 | ): Promise { 405 | const currentGame = await getGame(socketID); 406 | const indexCurrentActivePlayer = currentGame.activePlayerIndex; 407 | 408 | if (currentGame.players[indexCurrentActivePlayer + 1]) { 409 | const nextActivePlayer = currentGame.players[indexCurrentActivePlayer + 1]; 410 | const indexNextActivePlayer = currentGame.players.indexOf(nextActivePlayer); 411 | currentGame.activePlayerIndex = indexNextActivePlayer; 412 | await handleRoundEnd(socketID, lobbyNr); 413 | } else { 414 | currentGame.activePlayerIndex = 0; 415 | await handleRoundEnd(socketID, lobbyNr); 416 | } 417 | } 418 | 419 | export async function handleRoundEnd( 420 | socketID: string, 421 | lobbyNr: number 422 | ): Promise { 423 | const currentGame = await getGame(socketID); 424 | const activePlayer = currentGame.players[currentGame.activePlayerIndex]; 425 | 426 | if (activePlayer.allCardsRevealed === true) { 427 | await revealAllCards(socketID); 428 | await calculateRoundScore(socketID, lobbyNr); 429 | await calculateTotalScores(socketID); 430 | currentGame.roundNr = currentGame.roundNr + 1; 431 | 432 | // Maybe reset game cards and deal new cards on newRoundStart. 433 | // Reset and deal new cards when the roundScore Modal is continued by all players in frontend 434 | // reset game cards? 435 | // deal new cards? 436 | console.log("current Game", JSON.stringify(currentGame, null, 4)); 437 | } 438 | } 439 | 440 | export function getNewDrawPileCard(lobbyNr: number): Card { 441 | const randomCard = getRandomCard(lobbyNr); 442 | randomCard.hidden = false; 443 | games[lobbyNr].tempDrawPileCard = randomCard; 444 | return randomCard; 445 | } 446 | 447 | export function getCurrentDrawPileCard(lobbyNr: number): Card { 448 | return games[lobbyNr].tempDrawPileCard; 449 | } 450 | 451 | export function discardCurrentDrawPileCard(lobbyNr: number): void { 452 | const drawPileCard = games[lobbyNr].tempDrawPileCard; 453 | games[lobbyNr].discardPileCards.push(drawPileCard); 454 | games[lobbyNr].tempDrawPileCard = null; 455 | } 456 | 457 | export async function revealAllCards(socketID: string): Promise { 458 | const currentGame = await getGame(socketID); 459 | 460 | function revealCards(index: number): void { 461 | currentGame.players[index].cards.forEach((card) => { 462 | if (card.hidden) { 463 | card.hidden = false; 464 | } 465 | }); 466 | } 467 | 468 | for (let i = 0; i < currentGame.playerCount; i++) { 469 | revealCards(i); 470 | } 471 | } 472 | 473 | // Funktion für letzte TURNS. 474 | // Wenn alle 12 cards revealed true is, soll die Funktion ausgeführt werden. 475 | // Bei setNextActivePlayer() checken ob der nächste Player allCardsRevealed === true hat. 476 | // Wenn ja, dann soll das Spielende eingeleitet werden: Alle übrigen Karten sollen aufgedeckt werden und die 477 | // Round Scores ermittelt und gespeichert werden. Popup soll die Runde zusammenfassen. 478 | // An Frontend gameHasStarted / roundstart auf false setzen, damit dort nich geklickt werden kann. 479 | // Bzw. kann es nach wegklicken des Popups ja direkt weitergehen mit Karten aufdecken etc. 480 | // Der Spieler der allCardsRevealed===true hatte, sollte in der nächsten Runde anfangen nach aufdecken der Karten. 481 | --------------------------------------------------------------------------------