diff --git a/.dockerignore b/.dockerignore index 6f692d5..e01e7e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ .env.example +.env .idea README.md +.gitea \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2a1832d..96f1b63 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ yarn-debug.log* yarn-error.log* .idea +.env \ No newline at end of file diff --git a/src/layout/components/Button.tsx b/src/layout/components/Button.tsx index f8fc6e8..370533f 100644 --- a/src/layout/components/Button.tsx +++ b/src/layout/components/Button.tsx @@ -1,13 +1,23 @@ -import React, {FunctionComponent} from "react"; +import React, {ButtonHTMLAttributes, DetailedHTMLProps, FunctionComponent} from "react"; + +interface ButtonProps extends DetailedHTMLProps, HTMLButtonElement> { -interface Props { - className?: string - onClick?: () => void - children?: React.ReactNode } -export const GHButton: FunctionComponent = ({className, onClick, children}) => { +export const GHButton: FunctionComponent = (props) => { + + const {className, ...rest} = props; + return ( - {children} + + ); +} + +export const NoButton: FunctionComponent = (props) => { + + const {className, ...rest} = props; + + return ( + ); } \ No newline at end of file diff --git a/src/layout/components/Card.tsx b/src/layout/components/Card.tsx index 1de719d..28001ef 100644 --- a/src/layout/components/Card.tsx +++ b/src/layout/components/Card.tsx @@ -11,9 +11,17 @@ const Card: FunctionComponent = ({cardString, handleClick, isHidden, isCl const cardType = cardString.split(' ')[0].toLowerCase(); const cardValue = cardString.split(' ')[1].toLowerCase(); - const cardSource = isHidden ? - require(`../../assets/cards/back.png`) : - require(`../../assets/cards/${cardType}_${cardValue}.png`); + const cardSource = (): string => { + if (isHidden) return require("../../assets/cards/back.png"); + if (cardType === "JOKER") { + if (cardValue === "RED") { + return require("../../assets/cards/joker_red.png") + } + return require("../../assets/cards/joker_black.png") + } + return require(`../../assets/cards/${cardType}_${cardValue}.png`); + } + const cardName = isHidden ? 'back' : `${cardType} ${cardValue}`; const handleCardClick = () => { @@ -24,7 +32,7 @@ const Card: FunctionComponent = ({cardString, handleClick, isHidden, isCl return (
- {cardName} + {cardName}
) } diff --git a/src/layout/components/Chat.tsx b/src/layout/components/Chat.tsx new file mode 100644 index 0000000..bc5182c --- /dev/null +++ b/src/layout/components/Chat.tsx @@ -0,0 +1,31 @@ +import {GHButton} from "./Button"; +import React, {FormEvent, FunctionComponent, RefObject} from "react"; + +interface Props{ + chatRef: RefObject; + handleSend: (message: string) => void; +} + +const Chat: FunctionComponent = ({chatRef, handleSend}) => { + + const handleChat = (form: FormEvent) => { + form.preventDefault(); + const data = new FormData(form.currentTarget); + const chatInput = data.get("chat-input") as string; + if(!chatInput) return; + handleSend(chatInput); + form.currentTarget.reset(); + } + + return ( + <> +
    +
    + + Send +
    + + ) +} + +export default Chat; \ No newline at end of file diff --git a/src/layout/components/Deck.tsx b/src/layout/components/Deck.tsx index c43221e..a7419ac 100644 --- a/src/layout/components/Deck.tsx +++ b/src/layout/components/Deck.tsx @@ -20,7 +20,7 @@ const Deck: FunctionComponent = ({currentCard, actionOnClick}) => {

    - +
    ); diff --git a/src/layout/components/Game.tsx b/src/layout/components/Game.tsx index 7822622..206dcb1 100644 --- a/src/layout/components/Game.tsx +++ b/src/layout/components/Game.tsx @@ -1,24 +1,66 @@ import React, {FunctionComponent} from "react"; import Deck from "./Deck"; import Hand from "./Hand"; +import {GHButton} from "./Button"; +import {GameAction, Player} from "../pages/Room"; -interface GameState { - PlayerName: string; +export interface GameState { + Me: Player; + CurrentState: string; Hand: string[]; CurrentCard: string; - CurrentPlayer: string; - Players: string[]; + CurrentPlayer: Player; + Players: Player[]; } interface Props { gameState: GameState - handleCardSend: (cardString: string) => void; - handleDraw: () => void; + handleGameAction: (action: GameAction) => void; } -const Game:FunctionComponent = ({gameState, handleCardSend, handleDraw}) => { +const CHOICES = ['SPADES', 'HEARTS', 'DIAMONDS', 'CLUBS']; + +const Game: FunctionComponent = ({gameState, handleGameAction}) => { + + const handleChoice = (choice: string) => { + handleGameAction({Action: 'CHOOSE', Data: choice}); + } + + const handleDraw = () => { + handleGameAction({Action: 'DRAW', Data: ""}); + } + + const handleCardSend = (cardString: string) => { + handleGameAction({Action: "PLAYCARD", Data: JSON.stringify({ + CardType: cardString.split(' ')[0], + CardValue: cardString.split(' ')[1] + })}) + } + return (
    +
      + { + gameState?.Me?.Id && + gameState.Players.map((player) => { + const isCurrentPlayer = player.Id === gameState.CurrentPlayer.Id; + const isMe = player.Id === gameState.Me.Id; + + const style = + { + fontWeight: isMe ? 'bold' : 'normal', + outline: isCurrentPlayer ? '2px solid red' : 'none' + } + + return
    1. +
      +

      {player.Name} {isMe && '(You)'}

      +

      {player.CardsLeft} left

      +
      +
    2. + }) + } +
    { gameState.CurrentCard && @@ -27,18 +69,10 @@ const Game:FunctionComponent = ({gameState, handleCardSend, handleDraw}) gameState.Hand && } -
      - { - gameState.Players && - gameState.Players.map((player, index) => { - const isCurrentPlayer = player === gameState.CurrentPlayer; - const isMe = player === gameState.PlayerName; - return
    • - {player} {isMe && '(You)'} -
    • - }) - } -
    + { + gameState.CurrentState === 'CHOOSE' && + CHOICES.map(choice => handleChoice(choice)}>{choice}) + }
    ) } diff --git a/src/layout/components/Lobby.tsx b/src/layout/components/Lobby.tsx new file mode 100644 index 0000000..3dfd788 --- /dev/null +++ b/src/layout/components/Lobby.tsx @@ -0,0 +1,29 @@ +import {FunctionComponent} from "react"; +import {GHButton} from "./Button"; + +interface Props { + winner: string | undefined; + handleLobbyAction: (action: string) => void; +} + +const Lobby: FunctionComponent = ({winner, handleLobbyAction}) => { + + const onStartClick = () => { + console.log('Start Game'); + handleLobbyAction("START"); + } + + // @ts-ignore + return ( +
    +

    Lobby

    + { + winner && +

    {winner} has won the game!

    + } + Start Game +
    + ); +} + +export default Lobby; diff --git a/src/layout/pages/MainLobby.tsx b/src/layout/pages/MainLobby.tsx index 37e8454..006e532 100644 --- a/src/layout/pages/MainLobby.tsx +++ b/src/layout/pages/MainLobby.tsx @@ -2,6 +2,7 @@ import React from "react"; import useTitle from "../../utils/hooks/TitleHook"; import Card from "../components/Card"; import {useNavigate} from "react-router"; +import {NoButton} from "../components/Button"; const ROOM_URL = `${process.env.REACT_APP_API_URL}/room`; @@ -10,6 +11,7 @@ const MainLobby = () => { useTitle('Mau-Mau Lobby'); const navigateTo = useNavigate(); + const playerName = localStorage.getItem('playerName') ?? ""; const handleCreateRoom = () => { fetch(ROOM_URL, { @@ -19,18 +21,28 @@ const MainLobby = () => { }); } + const changePlayerName = (name: string) => { + if (!name) return; + localStorage.setItem('playerName', name); + } + return (

    Mau-Mau

    +
    + + changePlayerName(e.target.value)}/> +
    -
    +

    Host Game

    -
    -
    navigateTo('/rooms')}> + + navigateTo('/rooms')}>

    Join Game

    -
    +
    ); diff --git a/src/layout/pages/Room.tsx b/src/layout/pages/Room.tsx index 6c9f3cd..7b4a7f3 100644 --- a/src/layout/pages/Room.tsx +++ b/src/layout/pages/Room.tsx @@ -1,21 +1,16 @@ import useWebSocket from "react-use-websocket"; -import React from "react"; +import React, {useEffect} from "react"; import {useNavigate, useParams} from "react-router"; import {GHButton} from "../components/Button"; import useTitle from "../../utils/hooks/TitleHook"; -import Game from "../components/Game"; +import Game, {GameState} from "../components/Game"; +import Lobby from "../components/Lobby"; +import Chat from "../components/Chat"; -interface GameState { - PlayerName: string; - Hand: string[]; - CurrentCard: string; - CurrentPlayer: string; - Players: string[]; -} - -interface ChatMessage { - PlayerName: string; - Message: string; +export interface Player { + Name: string; + Id: string; + CardsLeft: number; } interface SocketMessage { @@ -23,6 +18,11 @@ interface SocketMessage { Payload: any; } +export interface GameAction { + Action: string; + Data: string; +} + const Room = () => { useTitle('Mau!'); @@ -31,17 +31,29 @@ const Room = () => { const {roomId} = useParams(); - const WS_URL = `${process.env.REACT_APP_WEBSOCKET_URL}/room/${roomId}`; + const playerName = localStorage.getItem('playerName') ?? Math.random().toString(36).substring(7); + const WS_URL = `${process.env.REACT_APP_WEBSOCKET_URL}/room/${roomId}/${playerName}`; - const [gameState, setGameState] = React.useState({ - PlayerName: '', - Hand: [], - CurrentCard: '', - CurrentPlayer: '', - Players: [] - }); - const [chatMessages, setChatMessages] = React.useState([]); - const [chatInput, setChatInput] = React.useState(''); + const [gameState, setGameState] = React.useState(undefined); + const [lobbyState, setLobbyState] = React.useState(""); + const [winner, setWinner] = React.useState(undefined); + + const chatRef = React.useRef(null); + + const addChatMessage = (message: { Sender: string, Message: string }) => { + if (!chatRef.current) return; + + const {scrollTop, scrollHeight, clientHeight} = chatRef.current; + + const newChatElement = document.createElement('li'); + newChatElement.innerHTML = `${message.Sender}: ${message.Message}`; + chatRef.current.appendChild(newChatElement); + + const isNearBottom = scrollTop + clientHeight + newChatElement.scrollHeight * 2 >= scrollHeight; + if (isNearBottom) { + newChatElement.scrollIntoView({behavior: 'smooth'}); + } + } const websocket = useWebSocket(WS_URL, { onOpen: () => { @@ -50,14 +62,28 @@ const Room = () => { onMessage: (event) => { const data = JSON.parse(event.data); const payload = JSON.parse(data.Payload); - if (data.Type === 'GAME') setGameState(payload); - if (data.Type === 'CHAT') setChatMessages(prev => [...prev, payload]); + switch (data.Type) { + case 'LOBBY': + setLobbyState(data.Type); + break; + case 'GAME': + setLobbyState(data.Type); + setGameState(payload); + break; + case 'CHAT': + addChatMessage(payload); + break; + case "END": + setWinner(payload.Name) + setGameState(undefined); + break; + default: + console.log('Unknown message type:', data.Type); + } } }); const handleLeaveRoom = () => { - const socket = websocket.getWebSocket(); - if (socket) socket.close(); navigateTo('/'); } @@ -65,48 +91,50 @@ const Room = () => { websocket.sendMessage(JSON.stringify(message)); } - const handleCardSend = (card: string) => { - handleSend({ - Type: "GAME", - Payload: JSON.stringify({ - Action: "PLAYCARD", - Data: JSON.stringify({ - CardType: card.split(' ')[0], - CardValue: card.split(' ')[1] - }) - }) - }) + const handleGameAction = (action: GameAction) => { + handleSend({Type: "GAME", Payload: JSON.stringify(action)}); } - const handleDraw = () => { - handleSend({ - Type: "GAME", - Payload: JSON.stringify({ - Action: "DRAW", - Data: "" - }) - }); + const handleLobbyAction = (action: string) => { + handleSend({Type: "LOBBY", Payload: JSON.stringify(action)}); } - const handleChat = (message: string) => { + const handleChatMessage = (chatMessage: string) => { handleSend({ Type: "CHAT", - Payload: message + Payload: chatMessage }); } + useEffect(() => { + return () => { + const socket = websocket.getWebSocket(); + if (socket) socket.close(); + } + // this is a cleanup function and should not include any dependencies that would cause it to run more than once + // eslint-disable-next-line + }, []); + return ( -
    -

    Room {roomId}

    - Leave Room - - setChatInput(e.target.value)} /> - -
      - {chatMessages.map((message, index) => ( -
    • {message.PlayerName}: {message.Message}
    • - ))} -
    +
    + +
    + { + gameState ? + + : lobbyState === 'LOBBY' && + + } +
    ) } diff --git a/src/layout/pages/Rooms.tsx b/src/layout/pages/Rooms.tsx index 394a661..b5bdbc3 100644 --- a/src/layout/pages/Rooms.tsx +++ b/src/layout/pages/Rooms.tsx @@ -19,11 +19,11 @@ const Rooms = () => { return (
    -

    Rooms

    +

    Rooms

      { rooms.map((room, index) => { - return
    • navigateTo(`/room/${room}`)}>{room}
    • }) } diff --git a/src/styles/App.scss b/src/styles/App.scss index 9bd1017..91b4b2e 100644 --- a/src/styles/App.scss +++ b/src/styles/App.scss @@ -7,9 +7,22 @@ src: local('Mau'), url(../assets/fonts/OrientalCatsLight.otf) format('opentype'); } -.app { - width: 100vw; - height: 100vh; +* { + box-sizing: border-box; +} + +body { + margin: 0; + height: 100dvh; + width: 100dvw; + overflow: hidden; +} + +#root, .app { + background-color: #131313; + color: #ffffff; + padding: 1rem; + height: 100%; } .mau { diff --git a/src/styles/layout/components/_components.scss b/src/styles/layout/components/_components.scss index b053990..55c56f4 100644 --- a/src/styles/layout/components/_components.scss +++ b/src/styles/layout/components/_components.scss @@ -1,4 +1,6 @@ @import "button"; @import "card"; +@import "chat"; @import "hand"; +@import "game"; @import "deck"; \ No newline at end of file diff --git a/src/styles/layout/components/button.scss b/src/styles/layout/components/button.scss index d60cc65..d3fc8c8 100644 --- a/src/styles/layout/components/button.scss +++ b/src/styles/layout/components/button.scss @@ -53,4 +53,11 @@ ::-webkit-details-marker { display: none; } +} + +.no-button { + background-color: transparent; + border: none; + color: inherit; + cursor: pointer; } \ No newline at end of file diff --git a/src/styles/layout/components/chat.scss b/src/styles/layout/components/chat.scss new file mode 100644 index 0000000..732b436 --- /dev/null +++ b/src/styles/layout/components/chat.scss @@ -0,0 +1,28 @@ +.chat { + + &__list { + overflow-y: hidden; + word-wrap: break-word; + list-style-type: none; + padding: 0; + margin: 1rem 0; + + &:hover { + overflow-y: scroll; + } + } + + &-form { + display: flex; + flex-direction: column; + + &__input { + background-color: #2e2e2e; + border: none; + color: #fff; + font-size: 1rem; + padding: 0.5rem; + border-radius: 0.5rem; + } + } +} \ No newline at end of file diff --git a/src/styles/layout/components/game.scss b/src/styles/layout/components/game.scss new file mode 100644 index 0000000..3737ee5 --- /dev/null +++ b/src/styles/layout/components/game.scss @@ -0,0 +1,25 @@ +.game { + height: 100%; + display: grid; + grid-template-rows: 1fr 1fr 1fr; + place-items: center; + + &__players { + list-style-type: none; + padding: 0; + margin: 0; + display: flex; + justify-content: space-around; + flex-direction: row; + width: 100%; + + &__info { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + } +} \ No newline at end of file diff --git a/src/styles/layout/components/hand.scss b/src/styles/layout/components/hand.scss index 6eed4cf..1651805 100644 --- a/src/styles/layout/components/hand.scss +++ b/src/styles/layout/components/hand.scss @@ -1,8 +1,7 @@ .hand { display: flex; - flex-direction: row; - //justify-content: center; - flex-flow: nowrap !important; + max-width: 100%; + flex-flow: row nowrap !important; overflow-x: scroll; padding: 0.5rem; gap: 0.5rem; diff --git a/src/styles/layout/pages/_pages.scss b/src/styles/layout/pages/_pages.scss index 00335b4..ebfd0f7 100644 --- a/src/styles/layout/pages/_pages.scss +++ b/src/styles/layout/pages/_pages.scss @@ -1 +1,2 @@ -@import "mainlobby"; \ No newline at end of file +@import "mainlobby"; +@import "room"; \ No newline at end of file diff --git a/src/styles/layout/pages/mainlobby.scss b/src/styles/layout/pages/mainlobby.scss index 4effd35..3688271 100644 --- a/src/styles/layout/pages/mainlobby.scss +++ b/src/styles/layout/pages/mainlobby.scss @@ -1,11 +1,16 @@ .main-lobby { - width: 100vw; height: 75vh; display: flex; flex-direction: column; justify-content: space-evenly; align-items: center; + &__name { + display: flex; + flex-direction: column; + font-size: 1.5rem; + } + &__container { display: flex; gap: 2rem; @@ -19,6 +24,6 @@ } &__title { - font-size: 5rem; + font-size: 7.5rem; } } \ No newline at end of file diff --git a/src/styles/layout/pages/room.scss b/src/styles/layout/pages/room.scss new file mode 100644 index 0000000..5c11822 --- /dev/null +++ b/src/styles/layout/pages/room.scss @@ -0,0 +1,12 @@ +.room { + display: grid; + grid-template-columns: auto 1fr; + height: 100%; + + &-aside { + display: grid; + min-width: 20%; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden auto; + } +} \ No newline at end of file diff --git a/src/styles/utils/_utils.scss b/src/styles/utils/_utils.scss index 647d032..04972e8 100644 --- a/src/styles/utils/_utils.scss +++ b/src/styles/utils/_utils.scss @@ -1,2 +1 @@ -@import "pointer"; -@import "theme"; \ No newline at end of file +@import "pointer"; \ No newline at end of file diff --git a/src/styles/utils/pointer.scss b/src/styles/utils/pointer.scss index 57cca58..0a90b81 100644 --- a/src/styles/utils/pointer.scss +++ b/src/styles/utils/pointer.scss @@ -1,4 +1,9 @@ -.clickable { +.mouse-pointer { cursor: pointer; user-select: none; +} + +.mouse-default { + cursor: default; + user-select: none; } \ No newline at end of file diff --git a/src/styles/utils/theme.scss b/src/styles/utils/theme.scss deleted file mode 100644 index 8895a7e..0000000 --- a/src/styles/utils/theme.scss +++ /dev/null @@ -1,8 +0,0 @@ -.dark { - background-color: #333; - color: #fff; -} -.light { - background-color: #fff; - color: #333; -} \ No newline at end of file