├── server
├── .gitignore
├── requirements.txt
├── tests
│ └── test_uno.py
├── lib
│ ├── env.py
│ ├── events.py
│ ├── notification.py
│ ├── parser.py
│ └── state.py
├── Makefile
├── app.py
└── core
│ └── uno.py
├── web
├── .prettierignore
├── public
│ ├── robots.txt
│ ├── 192x192.png
│ ├── 512x512.png
│ ├── favicon.ico
│ ├── apple-touch-icon.png
│ ├── manifest.json
│ └── worker.js
├── src
│ ├── vite-env.d.ts
│ ├── types
│ │ ├── api.ts
│ │ ├── routes.ts
│ │ ├── game.ts
│ │ └── ws.ts
│ ├── config
│ │ ├── web.ts
│ │ ├── server.ts
│ │ └── game.ts
│ ├── lib
│ │ ├── socket.ts
│ │ ├── state.ts
│ │ ├── api.ts
│ │ └── image.ts
│ ├── components
│ │ ├── footer.tsx
│ │ ├── loader.tsx
│ │ ├── errors
│ │ │ └── root.tsx
│ │ ├── cards
│ │ │ ├── blank.tsx
│ │ │ ├── stack.tsx
│ │ │ └── uno.tsx
│ │ ├── input.tsx
│ │ ├── modals
│ │ │ ├── confirm.tsx
│ │ │ └── start.tsx
│ │ ├── avatar.tsx
│ │ ├── header.tsx
│ │ ├── menus
│ │ │ └── info.tsx
│ │ └── game.tsx
│ ├── assets
│ │ └── images
│ │ │ ├── cards
│ │ │ ├── blank.svg
│ │ │ ├── red
│ │ │ │ ├── 1.svg
│ │ │ │ ├── 7.svg
│ │ │ │ ├── 4.svg
│ │ │ │ ├── 5.svg
│ │ │ │ ├── 0.svg
│ │ │ │ ├── 3.svg
│ │ │ │ ├── 6.svg
│ │ │ │ ├── 9.svg
│ │ │ │ ├── reverse.svg
│ │ │ │ ├── skip.svg
│ │ │ │ ├── 2.svg
│ │ │ │ ├── 8.svg
│ │ │ │ └── draw-two.svg
│ │ │ ├── blue
│ │ │ │ ├── 1.svg
│ │ │ │ ├── 7.svg
│ │ │ │ ├── 4.svg
│ │ │ │ ├── 5.svg
│ │ │ │ ├── 0.svg
│ │ │ │ ├── 3.svg
│ │ │ │ ├── 6.svg
│ │ │ │ ├── 9.svg
│ │ │ │ ├── reverse.svg
│ │ │ │ ├── skip.svg
│ │ │ │ ├── 2.svg
│ │ │ │ ├── 8.svg
│ │ │ │ └── draw-two.svg
│ │ │ ├── green
│ │ │ │ ├── 1.svg
│ │ │ │ ├── 7.svg
│ │ │ │ ├── 4.svg
│ │ │ │ ├── 5.svg
│ │ │ │ ├── 0.svg
│ │ │ │ ├── 3.svg
│ │ │ │ ├── 6.svg
│ │ │ │ ├── 9.svg
│ │ │ │ ├── reverse.svg
│ │ │ │ ├── skip.svg
│ │ │ │ ├── 2.svg
│ │ │ │ ├── 8.svg
│ │ │ │ └── draw-two.svg
│ │ │ ├── yellow
│ │ │ │ ├── 1.svg
│ │ │ │ ├── 7.svg
│ │ │ │ ├── 4.svg
│ │ │ │ ├── 5.svg
│ │ │ │ ├── 0.svg
│ │ │ │ ├── 3.svg
│ │ │ │ ├── 6.svg
│ │ │ │ ├── 9.svg
│ │ │ │ ├── reverse.svg
│ │ │ │ ├── skip.svg
│ │ │ │ ├── 2.svg
│ │ │ │ ├── 8.svg
│ │ │ │ └── draw-two.svg
│ │ │ ├── black
│ │ │ │ ├── draw-four.svg
│ │ │ │ └── wild.svg
│ │ │ └── back.svg
│ │ │ └── logo.svg
│ ├── pages
│ │ ├── won.tsx
│ │ ├── home.tsx
│ │ └── play.tsx
│ ├── styles
│ │ └── global.css
│ └── app.tsx
├── postcss.config.cjs
├── vite.config.ts
├── tsconfig.node.json
├── .prettierrc
├── tailwind.config.cjs
├── .gitignore
├── tsconfig.json
├── package.json
└── index.html
├── docs
└── images
│ ├── game.png
│ ├── home.png
│ ├── host.png
│ └── room.png
├── LICENSE
└── README.md
/server/.gitignore:
--------------------------------------------------------------------------------
1 | **/__pycache__/**
--------------------------------------------------------------------------------
/web/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: AdsBot-Google
2 | Disallow: /
--------------------------------------------------------------------------------
/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/docs/images/game.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/game.png
--------------------------------------------------------------------------------
/docs/images/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/home.png
--------------------------------------------------------------------------------
/docs/images/host.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/host.png
--------------------------------------------------------------------------------
/docs/images/room.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/room.png
--------------------------------------------------------------------------------
/web/public/192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/192x192.png
--------------------------------------------------------------------------------
/web/public/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/512x512.png
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/favicon.ico
--------------------------------------------------------------------------------
/web/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/web/src/types/api.ts:
--------------------------------------------------------------------------------
1 | export type AllowPlayerResponse = {
2 | allow: boolean;
3 | reason: string | null;
4 | };
5 |
--------------------------------------------------------------------------------
/web/src/types/routes.ts:
--------------------------------------------------------------------------------
1 | export enum Routes {
2 | Home = '/',
3 | Play = '/play',
4 | Won = '/won',
5 | }
6 |
--------------------------------------------------------------------------------
/web/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/server/requirements.txt:
--------------------------------------------------------------------------------
1 | flask==2.2.3
2 | Flask-SocketIO==5.3.3
3 | Flask-Cors==3.0.10
4 | redis==4.5.2
5 | gunicorn==20.1.0
6 | eventlet==0.30.2
--------------------------------------------------------------------------------
/web/src/config/web.ts:
--------------------------------------------------------------------------------
1 | const protocol = import.meta.env.PROD ? 'https' : 'http';
2 | export const WEB_HTTP_URL = `${protocol}://${window.location.host}`;
3 |
--------------------------------------------------------------------------------
/server/tests/test_uno.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.path.append('..')
3 | from uno import Game, Player
4 |
5 |
6 | def test_uno():
7 | # TODO: write unit tests
8 | return
9 |
--------------------------------------------------------------------------------
/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------
/server/lib/env.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | ENVIRONMENT = os.getenv("ENVIRONMENT") or 'development'
4 | REDIS_URL = os.getenv("REDIS_URL") or 'redis://localhost:6379'
5 | WEB_URL = os.getenv("WEB_URL") or 'http://localhost:3000'
6 |
--------------------------------------------------------------------------------
/web/src/config/server.ts:
--------------------------------------------------------------------------------
1 | export const SERVER_HTTP_URL =
2 | import.meta.env.VITE_SERVER_HTTP_URL || 'http://localhost:5000';
3 | export const SERVER_WS_URL =
4 | import.meta.env.VITE_SERVER_WS_URL || 'ws://localhost:5000';
5 |
--------------------------------------------------------------------------------
/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/web/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "prettier-plugin-tailwindcss",
4 | "prettier-plugin-organize-imports"
5 | ],
6 | "singleQuote": true,
7 | "tabWidth": 2,
8 | "jsxSingleQuote": true,
9 | "arrowParens": "avoid"
10 | }
11 |
--------------------------------------------------------------------------------
/web/src/lib/socket.ts:
--------------------------------------------------------------------------------
1 | import { io } from 'socket.io-client';
2 | import { SERVER_WS_URL } from '../config/server';
3 |
4 | const socket = io(SERVER_WS_URL, {
5 | transports: ['websocket'],
6 | autoConnect: true,
7 | });
8 |
9 | export default socket;
10 |
--------------------------------------------------------------------------------
/web/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: 'class',
4 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
5 | theme: {
6 | extend: {},
7 | },
8 | plugins: [require('daisyui'), require('prettier-plugin-tailwindcss')],
9 | };
10 |
--------------------------------------------------------------------------------
/server/lib/events.py:
--------------------------------------------------------------------------------
1 | GAME_ROOM = "game::room"
2 | GAME_START = "game::start"
3 | GAME_STATE = "game::state"
4 | GAME_NOTIFY = "game::notify"
5 | GAME_PLAY = "game::play"
6 | GAME_DRAW = "game::draw"
7 | GAME_OVER = "game::over"
8 |
9 | PLAYER_JOIN = "player::join"
10 | PLAYER_LEAVE = "player::leave"
11 |
--------------------------------------------------------------------------------
/web/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { version } from '../../package.json';
2 |
3 | function Footer(): React.ReactElement {
4 | return (
5 |
8 | );
9 | }
10 |
11 | export default Footer;
12 |
--------------------------------------------------------------------------------
/server/Makefile:
--------------------------------------------------------------------------------
1 | start-redis:
2 | docker pull redis:7.0
3 | docker run -d -p 6379:6379 --name redis redis:7.0
4 |
5 | stop-redis:
6 | docker container rm redis -f
7 |
8 | install:
9 | pip3.10 install -r requirements.txt
10 |
11 | dev:
12 | flask --app app --debug run
13 |
14 | debug:
15 | ENVIRONMENT=debug flask --app app --debug run
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/web/src/config/game.ts:
--------------------------------------------------------------------------------
1 | import { defaultHandSize } from '../lib/state';
2 | import { GameAction, GameConfig } from '../types/game';
3 |
4 | export const GAME_STATE_REFETCH_INTERVAL =
5 | import.meta.env.VITE_GAME_STATE_REFETCH_INTERVAL || 10_000;
6 |
7 | export const defaultConfig: GameConfig = {
8 | action: GameAction.Join,
9 | name: '',
10 | room: '',
11 | hand_size: defaultHandSize,
12 | };
13 |
--------------------------------------------------------------------------------
/web/src/lib/state.ts:
--------------------------------------------------------------------------------
1 | import { GameConfig } from '../types/game';
2 |
3 | export const minHandSize = 3;
4 | export const defaultHandSize = 7;
5 | export const maxHandSize = 15;
6 |
7 | export function validateGameConfig(config: GameConfig): boolean {
8 | for (const key of ['action', 'name', 'room', 'hand_size']) {
9 | if (!Object.hasOwn(config, key)) {
10 | return false;
11 | }
12 | }
13 | return true;
14 | }
15 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blank.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/web/src/components/loader.tsx:
--------------------------------------------------------------------------------
1 | interface LoaderProps {
2 | label?: string;
3 | }
4 |
5 | function Loader(props: LoaderProps): React.ReactElement {
6 | const { label } = props;
7 | return (
8 |
9 |
10 |
11 | {label}
12 |
13 |
14 | );
15 | }
16 |
17 | export default Loader;
18 |
--------------------------------------------------------------------------------
/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/web/src/lib/api.ts:
--------------------------------------------------------------------------------
1 | import { SERVER_HTTP_URL } from '../config/server';
2 | import { AllowPlayerResponse } from '../types/api';
3 |
4 | function defaultHeaders(): Headers {
5 | const headers = new Headers();
6 | headers.append('Content-Type', 'application/json');
7 | return headers;
8 | }
9 |
10 | export async function allowPlayer(
11 | action: string,
12 | name: string,
13 | room: string
14 | ): Promise {
15 | const res = await fetch(`${SERVER_HTTP_URL}/api/game/allow`, {
16 | method: 'POST',
17 | body: JSON.stringify({ action, name, room }),
18 | headers: defaultHeaders(),
19 | });
20 | return res.json();
21 | }
22 |
--------------------------------------------------------------------------------
/web/src/components/errors/root.tsx:
--------------------------------------------------------------------------------
1 | import { VscError } from 'react-icons/vsc';
2 | import { Link, useRouteError } from 'react-router-dom';
3 | import { Routes } from '../../types/routes';
4 |
5 | function RootErrorBoundary(): React.ReactElement {
6 | const error = useRouteError() as Error;
7 |
8 | return (
9 |
10 |
11 | Error
12 | {error?.message}
13 |
14 | Reload
15 |
16 |
17 | );
18 | }
19 |
20 | export default RootErrorBoundary;
21 |
--------------------------------------------------------------------------------
/web/src/components/cards/blank.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useMemo } from 'react';
3 | import { getBlankCardURL } from '../../lib/image';
4 | import { cardSizes, UnoCardSizes } from './uno';
5 |
6 | interface BlankCardProps {
7 | size?: UnoCardSizes;
8 | hidden?: boolean;
9 | }
10 |
11 | function BlankCard(props: BlankCardProps): React.ReactElement {
12 | const { size = 'default', hidden } = props;
13 | const imageSrc = useMemo(() => getBlankCardURL(hidden), []);
14 |
15 | return (
16 |
21 | );
22 | }
23 |
24 | export default BlankCard;
25 |
--------------------------------------------------------------------------------
/web/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "/?source=pwa",
3 | "start_url": "/?source=pwa",
4 | "name": "UNO",
5 | "short_name": "UNO",
6 | "description": "UNO multiplyer game",
7 | "theme_color": "#ffffff",
8 | "background_color": "#ffffff",
9 | "display": "standalone",
10 | "icons": [
11 | {
12 | "src": "/favicon.ico",
13 | "sizes": "64x64 32x32 24x24 16x16",
14 | "type": "image/x-icon",
15 | "purpose": "any"
16 | },
17 | {
18 | "src": "/192x192.png",
19 | "sizes": "192x192",
20 | "type": "image/png",
21 | "purpose": "any"
22 | },
23 | {
24 | "src": "/512x512.png",
25 | "sizes": "512x512",
26 | "type": "image/png",
27 | "purpose": "maskable"
28 | }
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/web/src/types/game.ts:
--------------------------------------------------------------------------------
1 | export type Hands = Record;
2 |
3 | export enum Events {
4 | GAME_ROOM = 'game::room',
5 | GAME_START = 'game::start',
6 | GAME_STATE = 'game::state',
7 | GAME_NOTIFY = 'game::notify',
8 | GAME_PLAY = 'game::play',
9 | GAME_DRAW = 'game::draw',
10 | GAME_OVER = 'game::over',
11 | PLAYER_JOIN = 'player::join',
12 | PLAYER_LEAVE = 'player::leave',
13 | }
14 |
15 | export type GameConfig = {
16 | action: GameAction;
17 | name: string;
18 | room: string;
19 | hand_size: number;
20 | };
21 |
22 | export enum GameAction {
23 | Host = 'Host',
24 | Join = 'Join',
25 | }
26 |
27 | export type Card = {
28 | id: string;
29 | color: string;
30 | value: string;
31 | };
32 |
33 | export type Player = {
34 | id: string;
35 | name: string;
36 | };
37 |
--------------------------------------------------------------------------------
/web/src/pages/won.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import Confetti from 'react-confetti';
3 | import { Link, useLocation, useNavigate } from 'react-router-dom';
4 | import { Routes } from '../types/routes';
5 |
6 | function Won(): React.ReactElement {
7 | const { state } = useLocation();
8 | const navigate = useNavigate();
9 |
10 | useEffect(() => {
11 | if (!state || !('winner' in state)) {
12 | navigate(Routes.Home);
13 | }
14 | }, [state]);
15 |
16 | return (
17 |
18 |
19 |
{state?.winner} won! 🎉
20 |
21 | Play again
22 |
23 |
24 | );
25 | }
26 |
27 | export default Won;
28 |
--------------------------------------------------------------------------------
/web/src/components/input.tsx:
--------------------------------------------------------------------------------
1 | interface InputProps {
2 | label: string;
3 | value: string | number;
4 | placeholder?: string;
5 | disabled?: boolean;
6 | onChange?(event: React.ChangeEvent): void;
7 | }
8 |
9 | function Input(props: InputProps): React.ReactElement {
10 | const { label, value, placeholder, disabled = false, onChange } = props;
11 |
12 | return (
13 |
14 |
17 |
25 |
26 | );
27 | }
28 | export default Input;
29 |
--------------------------------------------------------------------------------
/server/lib/notification.py:
--------------------------------------------------------------------------------
1 | from flask_socketio import emit
2 | import lib.events as events
3 |
4 |
5 | class Notification:
6 | def __init__(self, room: str):
7 | self.room = room
8 |
9 | def info(self, message: str) -> None:
10 | emit(events.GAME_NOTIFY, self.format('info', message), to=self.room)
11 |
12 | def success(self, message: str) -> None:
13 | emit(events.GAME_NOTIFY, self.format('success', message), to=self.room)
14 |
15 | def warn(self, message: str) -> None:
16 | emit(events.GAME_NOTIFY, self.format('warn', message), to=self.room)
17 |
18 | def error(self, message: str) -> None:
19 | emit(events.GAME_NOTIFY, self.format('error', message), to=self.room)
20 |
21 | def format(self, notification_type: str, message: str) -> None:
22 | return {'type': notification_type, 'message': str(message)}
23 |
--------------------------------------------------------------------------------
/web/src/types/ws.ts:
--------------------------------------------------------------------------------
1 | import { Card, Hands, Player } from './game';
2 |
3 | export type GameStateResponse = {
4 | hands: Hands;
5 | top_card: Card;
6 | };
7 |
8 | export type GameNotifyResponse = {
9 | type: string;
10 | message: string;
11 | };
12 |
13 | export type GameRoomResponse = {
14 | players: Player[];
15 | };
16 |
17 | export enum GameOverReason {
18 | Won = 'won',
19 | Error = 'error',
20 | InsufficientPlayers = 'insufficient-players',
21 | }
22 |
23 | export type GameOverResponse =
24 | | GameOverWonResponse
25 | | GameOverErrorResponse
26 | | GameOverInsufficientPlayersResponse;
27 |
28 | type GameOverWonResponse = {
29 | reason: GameOverReason.Won;
30 | winner: string;
31 | };
32 |
33 | type GameOverErrorResponse = {
34 | reason: GameOverReason.Error;
35 | };
36 |
37 | type GameOverInsufficientPlayersResponse = {
38 | reason: GameOverReason.InsufficientPlayers;
39 | };
40 |
--------------------------------------------------------------------------------
/web/src/components/cards/stack.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { Card } from '../../types/game';
3 | import BlankCard from './blank';
4 | import UnoCard, { UnoCardSizes } from './uno';
5 |
6 | interface CardStackProps {
7 | className?: string;
8 | card?: Card;
9 | size?: UnoCardSizes;
10 | hidden?: boolean;
11 | onClick?(): void;
12 | }
13 |
14 | function CardStack(props: CardStackProps): React.ReactElement {
15 | const { className, card, size = 'default', hidden, onClick } = props;
16 |
17 | return (
18 | onClick && onClick()}
21 | >
22 | {card && }
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | export default CardStack;
31 |
--------------------------------------------------------------------------------
/web/src/lib/image.ts:
--------------------------------------------------------------------------------
1 | import { Card } from '../types/game';
2 |
3 | /**
4 | * Uno card assets are Public Domain. Free for editorial, educational, commercial,
5 | * and/or personal projects. No attribution required. More info.
6 | *
7 | * Ref: https://creazilla.com/pages/4-license-information
8 | */
9 | export function getCardImageURL(card: Card, hidden?: boolean): string {
10 | if (hidden) {
11 | return new URL('../assets/images/cards/back.svg', import.meta.url).href;
12 | }
13 |
14 | return new URL(
15 | `../assets/images/cards/${card.color}/${card.value}.svg`,
16 | import.meta.url
17 | ).href;
18 | }
19 |
20 | export function getBlankCardURL(hidden?: boolean): string {
21 | if (hidden) {
22 | return new URL('../assets/images/cards/back.svg', import.meta.url).href;
23 | }
24 |
25 | return new URL('../assets/images/cards/blank.svg', import.meta.url).href;
26 | }
27 |
28 | export const logoURL = new URL('../assets/images/logo.svg', import.meta.url)
29 | .href;
30 |
--------------------------------------------------------------------------------
/web/src/styles/global.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | html {
8 | font-family: 'Roboto', sans-serif;
9 | }
10 |
11 | body {
12 | margin: 0;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | }
16 |
17 | #root {
18 | display: flex;
19 | height: 100dvh;
20 | width: 100vw;
21 | @apply p-4;
22 | }
23 |
24 | .no-pointer {
25 | cursor: default;
26 | }
27 |
28 | .loader {
29 | @apply border-4;
30 | @apply border-gray-100;
31 | @apply border-t-4;
32 | @apply border-t-black;
33 | @apply h-10;
34 | @apply w-10;
35 | border-radius: 50%;
36 | animation: spin 0.8s linear infinite;
37 | }
38 |
39 | .btn {
40 | font-weight: 500;
41 | }
42 |
43 | @keyframes spin {
44 | 0% {
45 | transform: rotate(0deg);
46 | }
47 | 100% {
48 | transform: rotate(360deg);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/server/lib/parser.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Any, List, Tuple
2 |
3 |
4 | def parse_data_args(data: Dict[str, Any], args: List[str]) -> List[Any]:
5 | missing_args = []
6 | values = []
7 |
8 | for arg in args:
9 | if arg not in data:
10 | missing_args.append(arg)
11 | else:
12 | values.append(data[arg])
13 |
14 | if missing_args != []:
15 | raise Exception(f'missing args: {", ".join(missing_args)}')
16 |
17 | return values
18 |
19 |
20 | def parse_object(obj) -> Any:
21 | return obj.__dict__
22 |
23 |
24 | def parse_object_list(objects) -> List[Any]:
25 | return [obj.__dict__ for obj in list(objects)]
26 |
27 |
28 | def parse_game_state(state) -> Dict[str, Any]:
29 | (hands, top_card) = state
30 | parsed_hands = {key.id: parse_object_list(
31 | value) for key, value in hands.items()}
32 | parsed_top_card = parse_object(top_card)
33 |
34 | return {
35 | 'hands': parsed_hands,
36 | 'top_card': parsed_top_card
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2023 Karan Pratap Singh
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/web/src/components/modals/confirm.tsx:
--------------------------------------------------------------------------------
1 | interface ConfirmModalProps {
2 | onConfirm(): void;
3 | }
4 |
5 | function ConfirmModal(props: ConfirmModalProps): React.ReactElement {
6 | const { onConfirm } = props;
7 |
8 | return (
9 | <>
10 |
15 |
16 |
17 |
Leave Game
18 |
Are you sure you want to leave the game?
19 |
20 |
26 |
29 |
30 |
31 |
32 | >
33 | );
34 | }
35 |
36 | export default ConfirmModal;
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [UNO](https://uno-web-4m6k.onrender.com)
2 |
3 | Classic UNO card game implemented with React and Python using [Socket.IO](https://socket.io/) for realtime multiplayer functionality.
4 |
5 | _Note: I made this for fun and personal use, please don't use this code in production._
6 |
7 | ## 📷 Screenshots
8 |
9 | 
10 | 
11 |
12 | ## 💻 Development
13 |
14 | **Server**
15 |
16 | ```
17 | $ cd server
18 | $ make start-redis
19 | $ make dev
20 | ```
21 |
22 | _Note: Make sure docker is running._
23 |
24 | **Web**
25 |
26 | ```
27 | $ cd web
28 | $ pnpm install
29 | $ pnpm run dev
30 | ```
31 |
32 | ## 📖 TODO
33 |
34 | - [ ] Better game validation rules
35 | - [ ] Implement game log
36 | - [ ] Host can kick player from room
37 | - [ ] Place chance logic
38 | - [ ] Generate random player name
39 | - [ ] Player can remove a card from hand
40 | - [ ] Migrate to a message broker like NATS?
41 |
--------------------------------------------------------------------------------
/web/src/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | interface AvatarProps {
4 | className?: string;
5 | name: string;
6 | size?: 'small' | 'default';
7 | type?: 'row' | 'col';
8 | }
9 |
10 | function Avatar(props: AvatarProps): React.ReactElement {
11 | const { className, name, size = 'default', type = 'col' } = props;
12 |
13 | return (
14 |
21 |
28 |

31 |
32 |
35 | {name}
36 |
37 |
38 | );
39 | }
40 |
41 | export default Avatar;
42 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/1.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/1.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/1.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/1.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/7.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { Socket } from 'socket.io-client';
2 | import { logoURL } from '../lib/image';
3 | import { GameConfig } from '../types/game';
4 | import InfoMenu from './menus/info';
5 | import ConfirmModal from './modals/confirm';
6 |
7 | interface HeaderProps {
8 | socket: Socket;
9 | isConnected: boolean;
10 | config: GameConfig;
11 | onLeave(): void;
12 | }
13 |
14 | function Header(props: HeaderProps): React.ReactElement {
15 | const { isConnected, config, onLeave } = props;
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |

24 |
25 |
26 |
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | export default Header;
39 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/7.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/7.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/7.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "uno",
3 | "private": true,
4 | "version": "1.0.2",
5 | "type": "module",
6 | "license": "MIT",
7 | "scripts": {
8 | "start": "vite",
9 | "dev": "vite --port 3000",
10 | "build": "tsc && vite build",
11 | "preview": "pnpm run build && vite preview --port 3000",
12 | "lint": "prettier --write ."
13 | },
14 | "dependencies": {
15 | "clsx": "1.2.1",
16 | "daisyui": "2.51.4",
17 | "react": "18.2.0",
18 | "react-confetti": "6.1.0",
19 | "react-dom": "18.2.0",
20 | "react-icons": "4.8.0",
21 | "react-router-dom": "6.9.0",
22 | "react-toastify": "9.1.1",
23 | "shortid": "2.2.16",
24 | "socket.io-client": "4.6.1"
25 | },
26 | "devDependencies": {
27 | "@types/react": "18.0.28",
28 | "@types/react-dom": "18.0.11",
29 | "@types/shortid": "0.0.29",
30 | "@vitejs/plugin-react": "3.1.0",
31 | "autoprefixer": "10.4.14",
32 | "postcss": "8.4.21",
33 | "prettier": "2.8.4",
34 | "prettier-plugin-organize-imports": "^3.2.2",
35 | "prettier-plugin-tailwindcss": "0.2.5",
36 | "tailwindcss": "3.2.7",
37 | "typescript": "4.9.3",
38 | "vite": "4.2.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/web/public/worker.js:
--------------------------------------------------------------------------------
1 | const CACHE_NAME = 'uno';
2 |
3 | // Add the routes you want to cache in an offline PWA
4 | const urlsToCache = ['/'];
5 |
6 | // Install a service worker
7 | self.addEventListener('install', event => {
8 | // Perform install steps
9 | event.waitUntil(
10 | caches.open(CACHE_NAME).then(cache => {
11 | console.log('Opened cache');
12 | return cache.addAll(urlsToCache);
13 | })
14 | );
15 | });
16 |
17 | // Cache and return requests
18 | self.addEventListener('fetch', event => {
19 | event.respondWith(
20 | caches.match(event.request).then(response => {
21 | // Cache hit - return response
22 | if (response) {
23 | return response;
24 | }
25 | return fetch(event.request);
26 | })
27 | );
28 | });
29 |
30 | // Update service worker
31 | self.addEventListener('activate', event => {
32 | const cacheWhitelist = [CACHE_NAME];
33 | event.waitUntil(
34 | caches.keys().then(cacheNames => {
35 | return Promise.all(
36 | cacheNames.map(cacheName => {
37 | if (cacheWhitelist.indexOf(cacheName) === -1) {
38 | return caches.delete(cacheName);
39 | }
40 | })
41 | );
42 | })
43 | );
44 | });
45 |
--------------------------------------------------------------------------------
/web/src/app.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { createBrowserRouter, RouterProvider } from 'react-router-dom';
4 | import { Slide, ToastContainer } from 'react-toastify';
5 | import 'react-toastify/dist/ReactToastify.css';
6 | import RootErrorBoundary from './components/errors/root';
7 | import Home from './pages/home';
8 | import Play from './pages/play';
9 | import Won from './pages/won';
10 | import './styles/global.css';
11 | import { Routes } from './types/routes';
12 |
13 | const router = createBrowserRouter([
14 | {
15 | path: Routes.Home,
16 | element: ,
17 | errorElement: ,
18 | },
19 | {
20 | path: Routes.Play,
21 | element: ,
22 | errorElement: ,
23 | },
24 | {
25 | path: Routes.Won,
26 | element: ,
27 | errorElement: ,
28 | },
29 | ]);
30 |
31 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
32 |
33 |
34 |
40 |
41 | );
42 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/4.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/4.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/4.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/4.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/5.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/5.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/5.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/5.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/components/menus/info.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { AiOutlineInfoCircle } from 'react-icons/ai';
3 | import { GameConfig } from '../../types/game';
4 |
5 | interface InfoMenuProps {
6 | isConnected: boolean;
7 | config: GameConfig;
8 | }
9 |
10 | function InfoMenu(props: InfoMenuProps): React.ReactElement {
11 | const { isConnected, config } = props;
12 |
13 | return (
14 |
15 |
16 |
20 |
21 |
22 |
status
23 |
30 | {isConnected ? 'online' : 'offline'}
31 |
32 |
33 |
34 | player {config.name}
35 |
36 |
37 | room {config.room}
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export default InfoMenu;
46 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/0.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/0.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/0.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/0.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | UNO
11 |
12 |
13 |
14 |
15 |
16 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/web/src/components/cards/uno.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useMemo } from 'react';
3 | import { getCardImageURL } from '../../lib/image';
4 | import { Card, Player } from '../../types/game';
5 |
6 | export type UnoCardSizes = 'large' | 'default';
7 |
8 | interface UnoCardProps {
9 | card: Card;
10 | currentPlayer?: Player;
11 | hidden?: boolean;
12 | size?: UnoCardSizes;
13 | onClick?(playerId: string, cardId: string): void;
14 | }
15 |
16 | export const cardSizes: Record = {
17 | default: 'h-36 w-20 md:h-44 md:w-28',
18 | large: 'h-44 w-28 md:h-48 md:w-32',
19 | };
20 |
21 | function UnoCard(props: UnoCardProps): React.ReactElement {
22 | const {
23 | currentPlayer,
24 | size = 'default',
25 | card,
26 | hidden = false,
27 | onClick,
28 | } = props;
29 |
30 | const allowPlay = onClick && currentPlayer;
31 | const imageSrc = useMemo(
32 | () => getCardImageURL(card, hidden),
33 | [card.id, hidden]
34 | );
35 |
36 | return (
37 |
48 | );
49 | }
50 |
51 | export default UnoCard;
52 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/3.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/3.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/3.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/3.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/6.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/9.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/reverse.svg:
--------------------------------------------------------------------------------
1 |
31 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/6.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/6.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/6.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/9.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/9.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/9.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/reverse.svg:
--------------------------------------------------------------------------------
1 |
31 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/reverse.svg:
--------------------------------------------------------------------------------
1 |
31 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/reverse.svg:
--------------------------------------------------------------------------------
1 |
31 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/skip.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/skip.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/skip.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/skip.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/2.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/2.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/2.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/8.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/2.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/8.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/8.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/8.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/src/pages/home.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from 'react-router-dom';
2 | import { toast } from 'react-toastify';
3 | import Footer from '../components/footer';
4 | import StartModal from '../components/modals/start';
5 | import { allowPlayer } from '../lib/api';
6 | import { logoURL } from '../lib/image';
7 | import { maxHandSize, minHandSize } from '../lib/state';
8 | import { GameAction, GameConfig } from '../types/game';
9 | import { Routes } from '../types/routes';
10 |
11 | function Home(): React.ReactElement {
12 | const navigate = useNavigate();
13 |
14 | async function onStart(
15 | action: GameAction,
16 | name: string,
17 | room: string,
18 | hand_size: number
19 | ): Promise {
20 | try {
21 | const { allow, reason } = await allowPlayer(action, name, room);
22 | if (!allow) {
23 | toast.error(reason);
24 | return;
25 | }
26 |
27 | if (action === GameAction.Host) {
28 | if (hand_size > maxHandSize) {
29 | toast.error(`hand size should not be greater than ${maxHandSize}`);
30 | return;
31 | }
32 |
33 | if (hand_size < minHandSize) {
34 | toast.error(`hand size should not be less than ${minHandSize}`);
35 | return;
36 | }
37 | }
38 |
39 | navigate(Routes.Play, {
40 | state: { action, name, room, hand_size } satisfies GameConfig,
41 | });
42 | } catch (error) {
43 | console.error(error);
44 | toast.error('encountered error while joining the game');
45 | }
46 | }
47 |
48 | return (
49 |
50 |
51 |

52 |
53 |
59 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
74 | export default Home;
75 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/red/draw-two.svg:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/blue/draw-two.svg:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/green/draw-two.svg:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/yellow/draw-two.svg:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/black/draw-four.svg:
--------------------------------------------------------------------------------
1 |
64 |
--------------------------------------------------------------------------------
/web/src/components/modals/start.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useSearchParams } from 'react-router-dom';
3 | import shortid from 'shortid';
4 | import { defaultHandSize } from '../../lib/state';
5 | import { GameAction } from '../../types/game';
6 | import Input from '../input';
7 |
8 | interface StartModalProps {
9 | action: GameAction;
10 | onStart(
11 | action: GameAction,
12 | name: string,
13 | room: string,
14 | hand_size: number
15 | ): void;
16 | }
17 |
18 | function StartModal(props: StartModalProps): React.ReactElement {
19 | const { action, onStart } = props;
20 | const [name, setName] = useState('');
21 | const [room, setRoom] = useState('');
22 | const [handSize, setHandSize] = useState(defaultHandSize);
23 |
24 | const [queryParams, setQueryParams] = useSearchParams();
25 |
26 | useEffect(() => {
27 | const room = queryParams.get('join');
28 | if (room) {
29 | setRoom(room);
30 | const modal = document.getElementById(
31 | `${GameAction.Join}-modal`
32 | ) as HTMLInputElement;
33 | if (modal) {
34 | modal.checked = true;
35 | setQueryParams();
36 | }
37 | }
38 | }, [queryParams]);
39 |
40 | useEffect(() => {
41 | if (action === GameAction.Host) {
42 | setRoom(shortid.generate());
43 | }
44 | }, [action]);
45 |
46 | function onNameChange(event: React.ChangeEvent): void {
47 | const { value } = event.target;
48 | setName(value);
49 | }
50 |
51 | function onRoomChange(event: React.ChangeEvent): void {
52 | const { value } = event.target;
53 | setRoom(value);
54 | }
55 |
56 | function onHandSizeChange(event: React.ChangeEvent): void {
57 | const value = Number.parseInt(event.target.value);
58 |
59 | if (Number.isNaN(value)) {
60 | setHandSize(0);
61 | } else {
62 | setHandSize(value);
63 | }
64 | }
65 |
66 | return (
67 | <>
68 |
69 |
70 |
71 |
{action}
72 |
{action} a game by entering some details
73 |
79 |
86 | {action === GameAction.Host && (
87 |
92 | )}
93 |
94 |
100 |
106 |
107 |
108 |
109 | >
110 | );
111 | }
112 |
113 | export default StartModal;
114 |
--------------------------------------------------------------------------------
/server/lib/state.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import pickle
3 | from typing import Optional, Set
4 |
5 | from core.uno import Game, Player
6 | from lib.env import REDIS_URL
7 | from redis import Redis
8 |
9 | log = logging.getLogger('state')
10 | log.setLevel(logging.INFO)
11 |
12 | GAME_EXPIRATION_TIME = 86_400 # 1 day
13 | ROOM_EXPIRATION_TIME = 86_400 # 1 day
14 |
15 |
16 | class State:
17 | def __init__(self):
18 | self.redis = Redis.from_url(REDIS_URL)
19 |
20 | def allow_player(self, action: str, room: str, player: Player) -> (bool, Optional[str]):
21 | # Validate player
22 | if not player.name or player.name == '':
23 | return (False, f'name cannot be blank')
24 |
25 | if ' ' in player.name:
26 | return (False, f'name should not contain white spaces')
27 |
28 | # Validate room
29 | if room == '':
30 | return (False, f'room should not be empty')
31 |
32 | if action == "Join": # Check if room exists
33 | exists = bool(self.redis.exists(f'players_{room}'))
34 | if not exists:
35 | return (False, f'cannot join game, room {room} does not exist')
36 |
37 | # Validate game
38 | started = bool(self.get_game_by_room(room))
39 | players = self.get_players_by_room(room)
40 |
41 | if len(players) == Game.MAX_PLAYERS_ALLOWED:
42 | return (False, f"room is full, max {Game.MAX_PLAYERS_ALLOWED} players are supported")
43 |
44 | if started:
45 | if player not in players:
46 | return (False, f'cannot join, game in the room {room} has already started')
47 | else:
48 | if player in players:
49 | return (False, f"name {player.name} is already taken for this room, try a different name")
50 |
51 | return (True, None)
52 |
53 | def get_game_by_room(self, room: str) -> Optional[Game]:
54 | obj = self.redis.get(f'game_{room}')
55 | if not obj:
56 | return None
57 |
58 | return pickle.loads(obj)
59 |
60 | def add_game_to_room(self, room: str, game: Game) -> None:
61 | obj = pickle.dumps(game)
62 | self.redis.set(f'game_{room}', obj, ex=GAME_EXPIRATION_TIME)
63 |
64 | def update_game_in_room(self, room: str, game: Game) -> None:
65 | obj = pickle.dumps(game)
66 | self.redis.set(f'game_{room}', obj, ex=GAME_EXPIRATION_TIME)
67 |
68 | def get_players_by_room(self, room: str) -> Set[Player]:
69 | obj = self.redis.get(f'players_{room}')
70 | if not obj:
71 | return set()
72 |
73 | return pickle.loads(obj)
74 |
75 | def add_player_to_room(self, room: str, player: Player) -> None:
76 | log.info(f"adding player {player} to room {room}")
77 |
78 | players = self.get_players_by_room(room)
79 | players.add(player)
80 |
81 | obj = pickle.dumps(players)
82 | self.redis.set(f'players_{room}', obj, ex=ROOM_EXPIRATION_TIME)
83 |
84 | def remove_player_from_room(self, room: str, player: Player) -> None:
85 | log.info(f"removing player {player} from room {room}")
86 |
87 | players = self.get_players_by_room(room)
88 | players.remove(player)
89 |
90 | obj = pickle.dumps(players)
91 | self.redis.set(f'players_{room}', obj, ex=ROOM_EXPIRATION_TIME)
92 |
93 | def delete_all(self, room: str) -> None:
94 | self.delete_room(room)
95 | self.delete_game(room)
96 |
97 | def delete_room(self, room: str) -> None:
98 | self.redis.delete(f'players_{room}')
99 | log.info(f"deleted {room}")
100 |
101 | def delete_game(self, room: str) -> None:
102 | self.redis.delete(f'game_{room}')
103 | log.info(f"deleted game for room {room}")
104 |
--------------------------------------------------------------------------------
/web/src/components/game.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { Socket } from 'socket.io-client';
4 | import { GAME_STATE_REFETCH_INTERVAL } from '../config/game';
5 | import { Card, Events, Hands, Player } from '../types/game';
6 | import { Routes } from '../types/routes';
7 | import {
8 | GameOverReason,
9 | GameOverResponse,
10 | GameStateResponse,
11 | } from '../types/ws';
12 | import Avatar from './avatar';
13 | import CardStack from './cards/stack';
14 | import UnoCard from './cards/uno';
15 | import Loader from './loader';
16 |
17 | interface GameProps {
18 | currentPlayer: Player;
19 | players: Player[];
20 | socket: Socket;
21 | started: boolean;
22 | room: string;
23 | }
24 |
25 | function Game(props: GameProps): React.ReactElement {
26 | const { socket, currentPlayer, players, started, room } = props;
27 | const navigate = useNavigate();
28 |
29 | const [hands, setHands] = useState(null);
30 | const [topCard, setTopCard] = useState(null);
31 |
32 | useEffect(() => {
33 | const intervalId = setInterval(() => {
34 | socket.emit(Events.GAME_STATE, { room });
35 | }, GAME_STATE_REFETCH_INTERVAL);
36 |
37 | return () => {
38 | clearInterval(intervalId);
39 | };
40 | }, []);
41 |
42 | useEffect(() => {
43 | function onGameState(data: GameStateResponse): void {
44 | setHands(data.hands);
45 | setTopCard(data.top_card);
46 | }
47 | socket.on(Events.GAME_STATE, onGameState);
48 |
49 | function onGameOver(data: GameOverResponse): void {
50 | const { reason } = data;
51 |
52 | switch (reason) {
53 | case GameOverReason.Won:
54 | const { winner } = data;
55 | navigate(Routes.Won, { state: { winner } });
56 | break;
57 | case GameOverReason.InsufficientPlayers:
58 | setTimeout(() => {
59 | navigate(0); // Refresh
60 | }, 5000);
61 | break;
62 | }
63 | }
64 | socket.on(Events.GAME_OVER, onGameOver);
65 |
66 | return () => {
67 | socket.off(Events.GAME_STATE, onGameState);
68 | socket.off(Events.GAME_OVER, onGameState);
69 | };
70 | }, []);
71 |
72 | function playCard(playerId: string, cardId: string): void {
73 | socket.emit(Events.GAME_PLAY, {
74 | player_id: playerId,
75 | card_id: cardId,
76 | room,
77 | });
78 | }
79 |
80 | function drawCard(): void {
81 | socket.emit(Events.GAME_DRAW, { player_id: currentPlayer.id, room });
82 | }
83 |
84 | const gameLoaded = started && hands && topCard && players.length > 1;
85 |
86 | if (!gameLoaded) {
87 | return ;
88 | }
89 |
90 | const [otherPlayer] = players.filter(p => p.id !== currentPlayer.id);
91 | const otherCards = hands[otherPlayer.id];
92 |
93 | const ownCards = hands[currentPlayer.id];
94 |
95 | return (
96 |
97 | {/* Other player */}
98 |
99 |
105 |
106 |
107 |
108 | {otherCards.map((card: Card, index: number) => (
109 |
115 | ))}
116 |
117 |
118 |
119 |
120 | {/* Card space */}
121 |
122 |
128 |
133 |
134 |
135 | {/* Current Player */}
136 |
137 |
138 |
139 | {ownCards.map((card: Card, index: number) => (
140 |
146 | ))}
147 |
148 |
149 |
155 |
156 |
157 | );
158 | }
159 |
160 | export default Game;
161 |
--------------------------------------------------------------------------------
/web/src/pages/play.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useState } from 'react';
2 | import { RiShareForwardFill } from 'react-icons/ri';
3 | import { useLocation, useNavigate } from 'react-router-dom';
4 | import { toast } from 'react-toastify';
5 | import Avatar from '../components/avatar';
6 | import Game from '../components/game';
7 | import Header from '../components/header';
8 | import Loader from '../components/loader';
9 | import { defaultConfig } from '../config/game';
10 | import { WEB_HTTP_URL } from '../config/web';
11 | import socket from '../lib/socket';
12 | import { validateGameConfig } from '../lib/state';
13 | import { Events, GameConfig, Player } from '../types/game';
14 | import { Routes } from '../types/routes';
15 | import { GameNotifyResponse, GameRoomResponse } from '../types/ws';
16 |
17 | function Play(): React.ReactElement {
18 | const navigate = useNavigate();
19 | const { state } = useLocation();
20 | const isValidState = state && validateGameConfig(state);
21 |
22 | useEffect(() => {
23 | if (!isValidState) {
24 | navigate(Routes.Home);
25 | toast.warn('no active game found, please host or join a game');
26 | }
27 | }, [isValidState]);
28 |
29 | const [isConnected, setIsConnected] = useState(socket.connected);
30 | const [started, setStarted] = useState(false);
31 |
32 | const [players, setPlayers] = useState([]);
33 | const config = useMemo(
34 | () => ({
35 | action: state?.action || defaultConfig.action,
36 | name: state?.name || defaultConfig.name,
37 | room: state?.room || defaultConfig.room,
38 | hand_size: state?.hand_size || defaultConfig.hand_size,
39 | }),
40 | [state]
41 | );
42 |
43 | useEffect(() => {
44 | const { name, room } = config;
45 | if (name !== defaultConfig.name && room !== defaultConfig.room) {
46 | socket.emit(Events.PLAYER_JOIN, { name, room });
47 | }
48 | }, [config]);
49 |
50 | useEffect(() => {
51 | function onConnect(): void {
52 | setIsConnected(true);
53 | }
54 | socket.on('connect', onConnect);
55 |
56 | function onDisconnect(): void {
57 | setIsConnected(false);
58 | }
59 | socket.on('disconnect', onDisconnect);
60 |
61 | function onGameNotify(data: GameNotifyResponse): void {
62 | const { type, message } = data;
63 | switch (type) {
64 | case 'info':
65 | toast.info(message);
66 | break;
67 | case 'success':
68 | toast.success(message);
69 | break;
70 | case 'warn':
71 | toast.warn(message);
72 | break;
73 | case 'error':
74 | toast.error(message);
75 | break;
76 | }
77 | }
78 | socket.on(Events.GAME_NOTIFY, onGameNotify);
79 |
80 | function onGameRoom(data: GameRoomResponse): void {
81 | setPlayers(data.players);
82 | }
83 | socket.on(Events.GAME_ROOM, onGameRoom);
84 |
85 | function onGameStart(): void {
86 | setStarted(true);
87 | }
88 | socket.on(Events.GAME_START, onGameStart);
89 |
90 | return () => {
91 | socket.off('connect', onConnect);
92 | socket.off('disconnect', onDisconnect);
93 | socket.off(Events.GAME_NOTIFY, onGameNotify);
94 | socket.off(Events.GAME_ROOM, onGameRoom);
95 | socket.off(Events.GAME_START, onGameStart);
96 | };
97 | }, []);
98 |
99 | useEffect(() => {
100 | function onReconnect() {
101 | const { name, room, hand_size } = config;
102 | // Re-join on reconnect
103 | socket.emit(Events.PLAYER_JOIN, { name, room });
104 |
105 | if (started) {
106 | // Restart the game if game was already started
107 | socket.emit(Events.GAME_START, { room, hand_size });
108 | }
109 | }
110 | socket.io.on('reconnect', onReconnect);
111 |
112 | return () => {
113 | socket.io.off('reconnect', onReconnect);
114 | };
115 | }, [started, config]);
116 |
117 | function onGameStart(): void {
118 | const { room, hand_size } = config;
119 | socket.emit(Events.GAME_START, { room, hand_size });
120 | }
121 |
122 | function onLeave(): void {
123 | const { name, room } = config;
124 | socket.emit(Events.PLAYER_LEAVE, { name, room });
125 | navigate(Routes.Home);
126 | }
127 |
128 | async function onCopyLink(): Promise {
129 | const url = `${WEB_HTTP_URL}?join=${config.room}`;
130 |
131 | if ('clipboard' in navigator) {
132 | await navigator.clipboard.writeText(url);
133 | toast.success('copied url to the clipboard');
134 | } else {
135 | toast.error(`cannot copy to the clipboard, use url ${url}`, {
136 | draggable: false,
137 | closeOnClick: false,
138 | });
139 | }
140 | }
141 |
142 | let content: React.ReactNode = null;
143 | const currentPlayer = players.find(player => player.name == config.name);
144 |
145 | if (started && currentPlayer) {
146 | content = (
147 |
154 | );
155 | } else {
156 | let status = 'Waiting for more players to the join...';
157 |
158 | if (players.length > 1) {
159 | status = 'Waiting for the game to start...';
160 | }
161 |
162 | content = (
163 |
164 |
165 | {players.map((player: Player) => (
166 |
167 | ))}
168 |
169 |
{status}
170 |
173 |
177 |
178 | );
179 | }
180 |
181 | return (
182 |
183 |
189 | {isConnected ? content : }
190 |
191 | );
192 | }
193 |
194 | export default Play;
195 |
--------------------------------------------------------------------------------
/server/app.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Any
3 |
4 | from flask import Flask, Response, request
5 | from flask_cors import CORS
6 | from flask_socketio import SocketIO, emit, join_room, leave_room
7 |
8 | from lib import env
9 | from lib import events
10 | from core.uno import Game, GameOverReason, Player
11 | from lib.notification import Notification
12 | from lib.parser import parse_data_args, parse_game_state, parse_object_list
13 | from lib.state import State
14 |
15 | logging.basicConfig(format='%(levelname)s[%(name)s]: %(message)s')
16 | log = logging.getLogger(__name__)
17 | log.setLevel(logging.INFO)
18 |
19 | # Server config
20 | app = Flask(__name__)
21 | CORS(app, resources={r"/*": {"origins": env.WEB_URL}})
22 | socketio = SocketIO(app, cors_allowed_origins=env.WEB_URL)
23 |
24 | state = State()
25 |
26 |
27 | @app.get('/healthz')
28 | def healthcheck():
29 | return Response(status=200)
30 |
31 |
32 | @app.post('/api/game/allow')
33 | def allow_player():
34 | try:
35 | action, name, room = parse_data_args(request.json, ['action', 'name', 'room'])
36 | player = Player(name)
37 | allow, reason = state.allow_player(action, room, player)
38 |
39 | return {'allow': allow, 'reason': reason}
40 | except Exception as ex:
41 | log.error(ex)
42 | return {'allow': False, 'reason': str(ex)}
43 |
44 |
45 | @socketio.on(events.PLAYER_JOIN)
46 | def on_join(data):
47 | try:
48 | name, room = parse_data_args(data, ['name', 'room'])
49 |
50 | player = Player(name)
51 | state.add_player_to_room(room, player)
52 | join_room(room)
53 | log.info(f"{player} has joined the room {room}")
54 |
55 | # Start game if game already exists
56 | game = state.get_game_by_room(room)
57 | if game:
58 | emit(events.GAME_START, to=room)
59 |
60 | # Add extra player for debugging
61 | if env.ENVIRONMENT == "debug":
62 | dev_player = Player("developer")
63 | state.add_player_to_room(room, dev_player)
64 |
65 | players = state.get_players_by_room(room)
66 | emit(events.GAME_ROOM, {'players': parse_object_list(players)}, to=room)
67 | except Exception as ex:
68 | log.error(ex)
69 |
70 |
71 | @socketio.on(events.PLAYER_LEAVE)
72 | def on_leave(data):
73 | try:
74 | name, room = parse_data_args(data, ['name', 'room'])
75 |
76 | player = Player(name)
77 |
78 | # Remove player from game
79 | game = state.get_game_by_room(room)
80 | if game:
81 | game.remove_player(player)
82 |
83 | # Remove player from room
84 | state.remove_player_from_room(room, player)
85 | state.update_game_in_room(room, game)
86 |
87 | # Leave the room
88 | leave_room(room)
89 |
90 | log.info(f"{player} has left the room {room}")
91 | Notification(room).error(f'{player.name} has left the game')
92 |
93 | # Delete game if insufficient players
94 | players = state.get_players_by_room(room)
95 | if len(players) < 2:
96 | state.delete_game(room)
97 | emit(events.GAME_OVER, {'reason': GameOverReason.INSUFFICIENT_PLAYERS.value}, to=room)
98 |
99 | emit(events.GAME_ROOM, {'players': parse_object_list(players)}, to=room)
100 | except Exception as ex:
101 | log.error(ex)
102 |
103 |
104 | @socketio.on(events.GAME_START)
105 | def on_game_start(data):
106 | try:
107 | room, hand_size = parse_data_args(data, ['room', 'hand_size'])
108 |
109 | game = state.get_game_by_room(room)
110 | players = state.get_players_by_room(room)
111 |
112 | if not game: # Start a new game
113 | try:
114 | game = Game(room, players, hand_size)
115 | state.add_game_to_room(room, game)
116 | except Exception as ex:
117 | Notification(room).error(ex)
118 | raise Exception("failed to start the game") from ex
119 |
120 | log.info(f"starting a new game in room {room} with players {players}")
121 |
122 | try:
123 | game_state = game.get_state()
124 | emit(events.GAME_START, to=room)
125 | emit(events.GAME_STATE, parse_game_state(game_state), to=room)
126 | except Exception as ex:
127 | Notification(room).error(ex)
128 | raise Exception("failed to get game state") from ex
129 |
130 | except Exception as ex:
131 | log.error(ex)
132 |
133 |
134 | @socketio.on(events.GAME_DRAW)
135 | def on_draw_card(data):
136 | try:
137 | room, player_id = parse_data_args(data, ['room', 'player_id'])
138 |
139 | game = state.get_game_by_room(room)
140 | game.draw(player_id)
141 | game_state = game.get_state()
142 | emit(events.GAME_STATE, parse_game_state(game_state), to=room)
143 | state.update_game_in_room(room, game)
144 | except Exception as ex:
145 | log.error(ex)
146 |
147 |
148 | @socketio.on(events.GAME_PLAY)
149 | def on_play_game(data):
150 | try:
151 | room, player_id, card_id = parse_data_args(data, ['room', 'player_id', 'card_id'])
152 |
153 | game = state.get_game_by_room(room)
154 |
155 | def on_game_over(reason: GameOverReason, data: Any):
156 | nonlocal room
157 |
158 | if reason == GameOverReason.WON:
159 | log.info(f'player {data} won game {room}')
160 | emit(events.GAME_OVER, {'reason': reason.value, 'winner': data.name}, to=room)
161 | state.delete_all(room)
162 |
163 | try:
164 | game.play(player_id, card_id, on_game_over)
165 | except ValueError as e:
166 | log.error(e)
167 |
168 | game_state = game.get_state()
169 | emit(events.GAME_STATE, parse_game_state(game_state), to=room)
170 | state.update_game_in_room(room, game)
171 | except Exception as ex:
172 | log.error(ex)
173 |
174 |
175 | @socketio.on(events.GAME_STATE)
176 | def on_game_state(data):
177 | try:
178 | room = parse_data_args(data, ['room'])[0]
179 |
180 | game = state.get_game_by_room(room)
181 | if game:
182 | game_state = game.get_state()
183 | emit(events.GAME_STATE, parse_game_state(game_state), to=room)
184 | except Exception as ex:
185 | log.error(ex)
186 |
187 |
188 | if __name__ == '__main__':
189 | socketio.run(app, host="0.0.0.0", port=5000)
190 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/black/wild.svg:
--------------------------------------------------------------------------------
1 |
98 |
--------------------------------------------------------------------------------
/server/core/uno.py:
--------------------------------------------------------------------------------
1 | import collections
2 | import random
3 | from enum import Enum
4 | from typing import Any, Callable, DefaultDict, List, Set, Tuple
5 |
6 | from lib.notification import Notification
7 |
8 |
9 | class Player:
10 | def __init__(self, name):
11 | self.id: str = f'player-{name}'
12 | self.name: str = name
13 |
14 | def __repr__(self) -> str:
15 | return f"Player(id={self.id}, name={self.name})"
16 |
17 | def __hash__(self) -> int:
18 | return hash(self.id)
19 |
20 | def __eq__(self, obj) -> bool:
21 | return isinstance(obj, type(self)) and self.id == obj.id
22 |
23 |
24 | class Card:
25 | def __init__(self, color, value):
26 | self.id: str = f'{value}-{color}'
27 | self.color: str = color
28 | self.value: str = value
29 |
30 | def is_special(self) -> bool:
31 | special_cards = set(Deck.DRAW_TWO_CARDS + Deck.REVERSE_CARDS +
32 | Deck.SKIP_CARDS + Deck.DRAW_FOUR_CARDS + Deck.WILD_CARDS)
33 | return self.value in special_cards or self.color == 'black'
34 |
35 | def is_color_special(self) -> bool:
36 | special_cards = set(Deck.DRAW_TWO_CARDS + Deck.REVERSE_CARDS + Deck.SKIP_CARDS)
37 | return self.value in special_cards or self.color != 'black'
38 |
39 | def is_black(self) -> bool:
40 | return self.color == 'black'
41 |
42 | def is_draw_four(self) -> bool:
43 | return self.value == 'draw-four'
44 |
45 | def is_wild(self) -> bool:
46 | return self.value == 'wild'
47 |
48 | def __repr__(self) -> str:
49 | return f'Card(color={self.color}, value={self.value})'
50 |
51 |
52 | class Deck:
53 | SHUFFLE_FREQ = 50
54 | COLORS = ['red', 'blue', 'green', 'yellow']
55 | NUMBER_CARDS = [str(i) for i in (list(range(0, 10)) + list(range(1, 10)))]
56 | DRAW_TWO_CARDS = ['draw-two'] * 2
57 | REVERSE_CARDS = ['reverse'] * 2
58 | SKIP_CARDS = ['skip'] * 2
59 |
60 | DRAW_FOUR_CARDS = ['draw-four'] * 4
61 | WILD_CARDS = ['wild'] * 4
62 | COLOR_CARDS = NUMBER_CARDS + DRAW_TWO_CARDS + REVERSE_CARDS + SKIP_CARDS
63 |
64 | def __init__(self):
65 | color_cards = [Card(color, value) for color in self.COLORS for value in self.COLOR_CARDS]
66 | black_cards = [Card('black', value) for value in (self.DRAW_FOUR_CARDS + self.WILD_CARDS)]
67 |
68 | self.cards: List[Card] = color_cards + black_cards
69 | self.shuffle()
70 |
71 | def get_cards(self) -> List[Card]:
72 | return self.cards
73 |
74 | def shuffle(self):
75 | for _ in range(self.SHUFFLE_FREQ):
76 | random.shuffle(self.cards)
77 |
78 |
79 | class GameOverReason(Enum):
80 | WON = 'won'
81 | ERROR = 'error'
82 | INSUFFICIENT_PLAYERS = 'insufficient-players'
83 |
84 |
85 | class Game:
86 | MIN_PLAYERS_ALLOWED = 2
87 | MAX_PLAYERS_ALLOWED = 2
88 |
89 | def __init__(self, room: str, players: Set[Player], hand_size: int):
90 | self.hands: DefaultDict[Player, List[Card]] = collections.defaultdict(list)
91 | self.players: Set[Player] = players
92 | self.notify = Notification(room)
93 | self.deck = Deck()
94 |
95 | self.validate_players()
96 |
97 | cards = self.deck.get_cards()
98 |
99 | TOTAL_PLAYERS = len(players)
100 | self.remaining_cards: List[Card] = cards[TOTAL_PLAYERS * hand_size:]
101 | player_cards = cards[:TOTAL_PLAYERS * hand_size]
102 |
103 | # Distribute cards (alternatively)
104 | i = 0
105 | while i < len(player_cards):
106 | for player in players:
107 | self.hands[player].append(player_cards[i])
108 | i += 1
109 |
110 | # Pick a top card (skip special cards)
111 | top_card = random.choice(self.remaining_cards)
112 | while top_card.is_special():
113 | top_card = random.choice(self.remaining_cards)
114 |
115 | self.game_stack: List[Card] = [top_card]
116 |
117 | def remove_player(self, player) -> None:
118 | self.players.remove(player)
119 |
120 | def validate_players(self) -> None:
121 | if len(self.players) < self.MIN_PLAYERS_ALLOWED:
122 | raise Exception(f"need at least {self.MIN_PLAYERS_ALLOWED} players to start the game")
123 | return
124 |
125 | def get_state(self) -> Tuple[DefaultDict[Player, List[Card]], Card]:
126 | self.validate_players()
127 | top_card = self.get_top_card()
128 |
129 | return (self.hands, top_card)
130 |
131 | def get_top_card(self) -> Card:
132 | return self.game_stack[-1]
133 |
134 | def transfer_played_cards(self) -> None:
135 | played_cards = self.game_stack[::]
136 | played_cards.pop() # Remove the top card
137 |
138 | random.shuffle(played_cards) # Shuffle played card
139 | self.remaining_cards = played_cards[::]
140 | self.game_stack = [self.get_top_card()]
141 |
142 | def draw(self, player_id: str) -> None:
143 | self.validate_players()
144 |
145 | player = self.find_object(self.players, player_id)
146 | player_cards = self.hands[player]
147 |
148 | if not self.remaining_cards:
149 | self.transfer_played_cards()
150 |
151 | if not self.remaining_cards:
152 | self.notify.warn('deck is empty!')
153 | return
154 |
155 | new_card = self.remaining_cards.pop()
156 | player_cards.append(new_card)
157 |
158 | def play(self, player_id: str, card_id: str, on_game_over: Callable[[GameOverReason, Any], None]) -> None:
159 | self.validate_players()
160 |
161 | player = self.find_object(self.players, player_id)
162 | player_cards = self.hands[player]
163 | card = self.find_object(player_cards, card_id)
164 | top_card = self.get_top_card()
165 |
166 | def execute_hand():
167 | nonlocal player_cards, card
168 |
169 | # Find and remove card from the current player's hand
170 | idx = self.find_object_idx(player_cards, card.id)
171 | player_cards.pop(idx)
172 |
173 | # Insert played card top of the game stack
174 | self.game_stack.append(card)
175 |
176 | if len(player_cards) == 1:
177 | self.notify.success(f"UNO! by {player.name}")
178 |
179 | if len(player_cards) == 0:
180 | on_game_over(GameOverReason.WON, player)
181 |
182 | # Can play any card on top of black cards
183 | if not card.is_black() and top_card.is_black():
184 | execute_hand()
185 | return
186 |
187 | if card.is_black() and top_card.is_black():
188 | # Cannot play wild card on top of draw four and vice-versa
189 | if ((card.is_draw_four() and top_card.is_wild()) or
190 | (card.is_wild() and top_card.is_draw_four())):
191 | self.notify.error(
192 | f"cannot play a {card.value} card on top of a {top_card.value} card")
193 | return
194 | execute_hand()
195 | return
196 |
197 | same_color = card.color == top_card.color
198 | same_value = card.value == top_card.value
199 |
200 | if same_color or same_value:
201 | execute_hand()
202 | return
203 |
204 | if card.is_black():
205 | execute_hand()
206 | return
207 |
208 | def find_object(self, objects, obj_id: str):
209 | objects = list(objects)
210 | idx = self.find_object_idx(objects, obj_id)
211 |
212 | return objects[idx]
213 |
214 | def find_object_idx(self, objects, obj_id: str):
215 | return [obj.id for obj in objects].index(obj_id)
216 |
--------------------------------------------------------------------------------
/web/src/assets/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/web/src/assets/images/cards/back.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------