Merge pull request 'developmaunt -> mauster' (#3) from developmaunt into mauster
All checks were successful
Build Mau & Deploy Mau / build (push) Successful in 2m11s
Build Mau & Deploy Mau / deploy (push) Successful in 4m53s

Reviewed-on: https://git.mau-mau.nl/MauMau/MauMau-Client/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2024-04-24 18:05:40 +00:00
23 changed files with 359 additions and 116 deletions

View File

@@ -1,3 +1,5 @@
.env.example
.env
.idea
README.md
.gitea

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ yarn-debug.log*
yarn-error.log*
.idea
.env

View File

@@ -1,13 +1,23 @@
import React, {FunctionComponent} from "react";
import React, {ButtonHTMLAttributes, DetailedHTMLProps, FunctionComponent} from "react";
interface ButtonProps extends DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
interface Props {
className?: string
onClick?: () => void
children?: React.ReactNode
}
export const GHButton: FunctionComponent<Props> = ({className, onClick, children}) => {
export const GHButton: FunctionComponent<ButtonProps> = (props) => {
const {className, ...rest} = props;
return (
<span className={`gh-button ${className}`} onClick={onClick}>{children}</span>
<button className={`gh-button ${className}`} {...rest}></button>
);
}
export const NoButton: FunctionComponent<ButtonProps> = (props) => {
const {className, ...rest} = props;
return (
<button className={`no-button ${className}`} {...rest}></button>
);
}

View File

@@ -11,9 +11,17 @@ const Card: FunctionComponent<Props> = ({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<Props> = ({cardString, handleClick, isHidden, isCl
return (
<div className={`card ${isClickable && 'card-clickable'}`}>
<img className="card__texture" src={cardSource} alt={cardName} onClick={handleCardClick}/>
<img className="card__texture" src={cardSource()} alt={cardName} onClick={handleCardClick}/>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import {GHButton} from "./Button";
import React, {FormEvent, FunctionComponent, RefObject} from "react";
interface Props{
chatRef: RefObject<HTMLOListElement>;
handleSend: (message: string) => void;
}
const Chat: FunctionComponent<Props> = ({chatRef, handleSend}) => {
const handleChat = (form: FormEvent<HTMLFormElement>) => {
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 (
<>
<ol className={"chat__list"} ref={chatRef}/>
<form className={"chat-form"} onSubmit={handleChat}>
<input className={"chat-form__input"} type="text" placeholder={"Chat here..."} id={"chat-input"} name={"chat-input"}/>
<GHButton type={"submit"}>Send</GHButton>
</form>
</>
)
}
export default Chat;

View File

@@ -20,7 +20,7 @@ const Deck: FunctionComponent<Props> = ({currentCard, actionOnClick}) => {
</div>
<div className="deck">
<p></p>
<Card cardString={"AA BB"} handleClick={handleClick} isHidden={true}/>
<Card cardString={"AA BB"} handleClick={handleClick} isHidden isClickable/>
</div>
</div>
);

View File

@@ -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<Props> = ({gameState, handleCardSend, handleDraw}) => {
const CHOICES = ['SPADES', 'HEARTS', 'DIAMONDS', 'CLUBS'];
const Game: FunctionComponent<Props> = ({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 (
<div className="game">
<ol className={"game__players"}>
{
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 <li key={player.Id} style={style}>
<div className={"game__players__info"}>
<h3>{player.Name} {isMe && '(You)'}</h3>
<p>{player.CardsLeft} left</p>
</div>
</li>
})
}
</ol>
{
gameState.CurrentCard &&
<Deck currentCard={gameState.CurrentCard} actionOnClick={handleDraw}/>
@@ -27,18 +69,10 @@ const Game:FunctionComponent<Props> = ({gameState, handleCardSend, handleDraw})
gameState.Hand &&
<Hand hand={gameState.Hand} actionOnClick={handleCardSend}/>
}
<ul>
{
gameState.Players &&
gameState.Players.map((player, index) => {
const isCurrentPlayer = player === gameState.CurrentPlayer;
const isMe = player === gameState.PlayerName;
return <li key={index} style={{fontWeight: isCurrentPlayer ? 'bold' : 'normal'}}>
{player} {isMe && '(You)'}
</li>
})
}
</ul>
{
gameState.CurrentState === 'CHOOSE' &&
CHOICES.map(choice => <GHButton key={choice} onClick={() => handleChoice(choice)}>{choice}</GHButton>)
}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import {FunctionComponent} from "react";
import {GHButton} from "./Button";
interface Props {
winner: string | undefined;
handleLobbyAction: (action: string) => void;
}
const Lobby: FunctionComponent<Props> = ({winner, handleLobbyAction}) => {
const onStartClick = () => {
console.log('Start Game');
handleLobbyAction("START");
}
// @ts-ignore
return (
<div>
<h1>Lobby</h1>
{
winner &&
<h2>{winner} has won the game!</h2>
}
<GHButton onClick={onStartClick}>Start Game</GHButton>
</div>
);
}
export default Lobby;

View File

@@ -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 (
<div className={"main-lobby"}>
<h1 className={"main-lobby__title mau"}>Mau-Mau</h1>
<div className={"main-lobby__name mau"}>
<label htmlFor={"name-input"}>Enter your name</label>
<input id={"name-input"} type="text" placeholder="Enter your name"
defaultValue={playerName} onBlur={(e) => changePlayerName(e.target.value)}/>
</div>
<div className={"main-lobby__container"}>
<div className={"main-lobby__container-button clickable"} onClick={handleCreateRoom}>
<NoButton className={"main-lobby__container-button"} onClick={handleCreateRoom}>
<h2 className={"mau"}>Host Game</h2>
<Card cardString={'SPADES ACE'} isClickable/>
</div>
<div className={"main-lobby__container-button clickable"} onClick={() => navigateTo('/rooms')}>
</NoButton>
<NoButton className={"main-lobby__container-button"} onClick={() => navigateTo('/rooms')}>
<h2 className={"mau"}>Join Game</h2>
<Card cardString={'SPADES ACE'} isHidden isClickable/>
</div>
</NoButton>
</div>
</div>
);

View File

@@ -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<GameState>({
PlayerName: '',
Hand: [],
CurrentCard: '',
CurrentPlayer: '',
Players: []
});
const [chatMessages, setChatMessages] = React.useState<ChatMessage[]>([]);
const [chatInput, setChatInput] = React.useState<string>('');
const [gameState, setGameState] = React.useState<GameState | undefined>(undefined);
const [lobbyState, setLobbyState] = React.useState<string>("");
const [winner, setWinner] = React.useState<string | undefined>(undefined);
const chatRef = React.useRef<HTMLOListElement>(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 = `<strong>${message.Sender}:</strong> ${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 (
<div>
<h1>Room {roomId}</h1>
<GHButton onClick={handleLeaveRoom}>Leave Room</GHButton>
<Game gameState={gameState} handleCardSend={handleCardSend} handleDraw={handleDraw}/>
<input type="text" placeholder={"Chat"} value={chatInput} onChange={(e) => setChatInput(e.target.value)} />
<button onClick={() => handleChat(chatInput)}>Send</button>
<ul>
{chatMessages.map((message, index) => (
<li key={index}>{message.PlayerName}: {message.Message}</li>
))}
</ul>
<div className={"room"}>
<aside className={"room-aside"}>
<header>
<hgroup>
<h1>Mau-Mau</h1>
<h2>Room {roomId?.split("-")[0]}</h2>
</hgroup>
<GHButton onClick={handleLeaveRoom}>Leave Room</GHButton>
</header>
<Chat chatRef={chatRef} handleSend={handleChatMessage}/>
</aside>
<main className={"room-main"}>
{
gameState ?
<Game gameState={gameState} handleGameAction={handleGameAction}/>
: lobbyState === 'LOBBY' &&
<Lobby winner={winner} handleLobbyAction={handleLobbyAction}/>
}
</main>
</div>
)
}

View File

@@ -19,11 +19,11 @@ const Rooms = () => {
return (
<div>
<h1>Rooms</h1>
<h1 className={"mau"}>Rooms</h1>
<ul>
{
rooms.map((room, index) => {
return <li key={index} className={"clickable"}
return <li key={index} className={"mouse-pointer mau"}
onClick={() => navigateTo(`/room/${room}`)}>{room}</li>
})
}

View File

@@ -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 {

View File

@@ -1,4 +1,6 @@
@import "button";
@import "card";
@import "chat";
@import "hand";
@import "game";
@import "deck";

View File

@@ -53,4 +53,11 @@
::-webkit-details-marker {
display: none;
}
}
.no-button {
background-color: transparent;
border: none;
color: inherit;
cursor: pointer;
}

View File

@@ -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;
}
}
}

View File

@@ -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%;
}
}
}

View File

@@ -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;

View File

@@ -1 +1,2 @@
@import "mainlobby";
@import "mainlobby";
@import "room";

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,2 +1 @@
@import "pointer";
@import "theme";
@import "pointer";

View File

@@ -1,4 +1,9 @@
.clickable {
.mouse-pointer {
cursor: pointer;
user-select: none;
}
.mouse-default {
cursor: default;
user-select: none;
}

View File

@@ -1,8 +0,0 @@
.dark {
background-color: #333;
color: #fff;
}
.light {
background-color: #fff;
color: #333;
}