developmaunt -> mauster #3

Merged
DTieman merged 7 commits from developmaunt into mauster 2024-04-24 18:05:41 +00:00
23 changed files with 359 additions and 116 deletions

View File

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

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.idea .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 ( 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 cardType = cardString.split(' ')[0].toLowerCase();
const cardValue = cardString.split(' ')[1].toLowerCase(); const cardValue = cardString.split(' ')[1].toLowerCase();
const cardSource = isHidden ? const cardSource = (): string => {
require(`../../assets/cards/back.png`) : if (isHidden) return require("../../assets/cards/back.png");
require(`../../assets/cards/${cardType}_${cardValue}.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 cardName = isHidden ? 'back' : `${cardType} ${cardValue}`;
const handleCardClick = () => { const handleCardClick = () => {
@@ -24,7 +32,7 @@ const Card: FunctionComponent<Props> = ({cardString, handleClick, isHidden, isCl
return ( return (
<div className={`card ${isClickable && 'card-clickable'}`}> <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> </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>
<div className="deck"> <div className="deck">
<p></p> <p></p>
<Card cardString={"AA BB"} handleClick={handleClick} isHidden={true}/> <Card cardString={"AA BB"} handleClick={handleClick} isHidden isClickable/>
</div> </div>
</div> </div>
); );

View File

@@ -1,24 +1,66 @@
import React, {FunctionComponent} from "react"; import React, {FunctionComponent} from "react";
import Deck from "./Deck"; import Deck from "./Deck";
import Hand from "./Hand"; import Hand from "./Hand";
import {GHButton} from "./Button";
import {GameAction, Player} from "../pages/Room";
interface GameState { export interface GameState {
PlayerName: string; Me: Player;
CurrentState: string;
Hand: string[]; Hand: string[];
CurrentCard: string; CurrentCard: string;
CurrentPlayer: string; CurrentPlayer: Player;
Players: string[]; Players: Player[];
} }
interface Props { interface Props {
gameState: GameState gameState: GameState
handleCardSend: (cardString: string) => void; handleGameAction: (action: GameAction) => void;
handleDraw: () => 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 ( return (
<div className="game"> <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 && gameState.CurrentCard &&
<Deck currentCard={gameState.CurrentCard} actionOnClick={handleDraw}/> <Deck currentCard={gameState.CurrentCard} actionOnClick={handleDraw}/>
@@ -27,18 +69,10 @@ const Game:FunctionComponent<Props> = ({gameState, handleCardSend, handleDraw})
gameState.Hand && gameState.Hand &&
<Hand hand={gameState.Hand} actionOnClick={handleCardSend}/> <Hand hand={gameState.Hand} actionOnClick={handleCardSend}/>
} }
<ul> {
{ gameState.CurrentState === 'CHOOSE' &&
gameState.Players && CHOICES.map(choice => <GHButton key={choice} onClick={() => handleChoice(choice)}>{choice}</GHButton>)
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>
</div> </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 useTitle from "../../utils/hooks/TitleHook";
import Card from "../components/Card"; import Card from "../components/Card";
import {useNavigate} from "react-router"; import {useNavigate} from "react-router";
import {NoButton} from "../components/Button";
const ROOM_URL = `${process.env.REACT_APP_API_URL}/room`; const ROOM_URL = `${process.env.REACT_APP_API_URL}/room`;
@@ -10,6 +11,7 @@ const MainLobby = () => {
useTitle('Mau-Mau Lobby'); useTitle('Mau-Mau Lobby');
const navigateTo = useNavigate(); const navigateTo = useNavigate();
const playerName = localStorage.getItem('playerName') ?? "";
const handleCreateRoom = () => { const handleCreateRoom = () => {
fetch(ROOM_URL, { fetch(ROOM_URL, {
@@ -19,18 +21,28 @@ const MainLobby = () => {
}); });
} }
const changePlayerName = (name: string) => {
if (!name) return;
localStorage.setItem('playerName', name);
}
return ( return (
<div className={"main-lobby"}> <div className={"main-lobby"}>
<h1 className={"main-lobby__title mau"}>Mau-Mau</h1> <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"}>
<div className={"main-lobby__container-button clickable"} onClick={handleCreateRoom}> <NoButton className={"main-lobby__container-button"} onClick={handleCreateRoom}>
<h2 className={"mau"}>Host Game</h2> <h2 className={"mau"}>Host Game</h2>
<Card cardString={'SPADES ACE'} isClickable/> <Card cardString={'SPADES ACE'} isClickable/>
</div> </NoButton>
<div className={"main-lobby__container-button clickable"} onClick={() => navigateTo('/rooms')}> <NoButton className={"main-lobby__container-button"} onClick={() => navigateTo('/rooms')}>
<h2 className={"mau"}>Join Game</h2> <h2 className={"mau"}>Join Game</h2>
<Card cardString={'SPADES ACE'} isHidden isClickable/> <Card cardString={'SPADES ACE'} isHidden isClickable/>
</div> </NoButton>
</div> </div>
</div> </div>
); );

View File

@@ -1,21 +1,16 @@
import useWebSocket from "react-use-websocket"; import useWebSocket from "react-use-websocket";
import React from "react"; import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router"; import {useNavigate, useParams} from "react-router";
import {GHButton} from "../components/Button"; import {GHButton} from "../components/Button";
import useTitle from "../../utils/hooks/TitleHook"; 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 { export interface Player {
PlayerName: string; Name: string;
Hand: string[]; Id: string;
CurrentCard: string; CardsLeft: number;
CurrentPlayer: string;
Players: string[];
}
interface ChatMessage {
PlayerName: string;
Message: string;
} }
interface SocketMessage { interface SocketMessage {
@@ -23,6 +18,11 @@ interface SocketMessage {
Payload: any; Payload: any;
} }
export interface GameAction {
Action: string;
Data: string;
}
const Room = () => { const Room = () => {
useTitle('Mau!'); useTitle('Mau!');
@@ -31,17 +31,29 @@ const Room = () => {
const {roomId} = useParams(); 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>({ const [gameState, setGameState] = React.useState<GameState | undefined>(undefined);
PlayerName: '', const [lobbyState, setLobbyState] = React.useState<string>("");
Hand: [], const [winner, setWinner] = React.useState<string | undefined>(undefined);
CurrentCard: '',
CurrentPlayer: '', const chatRef = React.useRef<HTMLOListElement>(null);
Players: []
}); const addChatMessage = (message: { Sender: string, Message: string }) => {
const [chatMessages, setChatMessages] = React.useState<ChatMessage[]>([]); if (!chatRef.current) return;
const [chatInput, setChatInput] = React.useState<string>('');
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, { const websocket = useWebSocket(WS_URL, {
onOpen: () => { onOpen: () => {
@@ -50,14 +62,28 @@ const Room = () => {
onMessage: (event) => { onMessage: (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
const payload = JSON.parse(data.Payload); const payload = JSON.parse(data.Payload);
if (data.Type === 'GAME') setGameState(payload); switch (data.Type) {
if (data.Type === 'CHAT') setChatMessages(prev => [...prev, payload]); 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 handleLeaveRoom = () => {
const socket = websocket.getWebSocket();
if (socket) socket.close();
navigateTo('/'); navigateTo('/');
} }
@@ -65,48 +91,50 @@ const Room = () => {
websocket.sendMessage(JSON.stringify(message)); websocket.sendMessage(JSON.stringify(message));
} }
const handleCardSend = (card: string) => { const handleGameAction = (action: GameAction) => {
handleSend({ handleSend({Type: "GAME", Payload: JSON.stringify(action)});
Type: "GAME",
Payload: JSON.stringify({
Action: "PLAYCARD",
Data: JSON.stringify({
CardType: card.split(' ')[0],
CardValue: card.split(' ')[1]
})
})
})
} }
const handleDraw = () => { const handleLobbyAction = (action: string) => {
handleSend({ handleSend({Type: "LOBBY", Payload: JSON.stringify(action)});
Type: "GAME",
Payload: JSON.stringify({
Action: "DRAW",
Data: ""
})
});
} }
const handleChat = (message: string) => { const handleChatMessage = (chatMessage: string) => {
handleSend({ handleSend({
Type: "CHAT", 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 ( return (
<div> <div className={"room"}>
<h1>Room {roomId}</h1> <aside className={"room-aside"}>
<GHButton onClick={handleLeaveRoom}>Leave Room</GHButton> <header>
<Game gameState={gameState} handleCardSend={handleCardSend} handleDraw={handleDraw}/> <hgroup>
<input type="text" placeholder={"Chat"} value={chatInput} onChange={(e) => setChatInput(e.target.value)} /> <h1>Mau-Mau</h1>
<button onClick={() => handleChat(chatInput)}>Send</button> <h2>Room {roomId?.split("-")[0]}</h2>
<ul> </hgroup>
{chatMessages.map((message, index) => ( <GHButton onClick={handleLeaveRoom}>Leave Room</GHButton>
<li key={index}>{message.PlayerName}: {message.Message}</li> </header>
))} <Chat chatRef={chatRef} handleSend={handleChatMessage}/>
</ul> </aside>
<main className={"room-main"}>
{
gameState ?
<Game gameState={gameState} handleGameAction={handleGameAction}/>
: lobbyState === 'LOBBY' &&
<Lobby winner={winner} handleLobbyAction={handleLobbyAction}/>
}
</main>
</div> </div>
) )
} }

View File

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

View File

@@ -7,9 +7,22 @@
src: local('Mau'), url(../assets/fonts/OrientalCatsLight.otf) format('opentype'); src: local('Mau'), url(../assets/fonts/OrientalCatsLight.otf) format('opentype');
} }
.app { * {
width: 100vw; box-sizing: border-box;
height: 100vh; }
body {
margin: 0;
height: 100dvh;
width: 100dvw;
overflow: hidden;
}
#root, .app {
background-color: #131313;
color: #ffffff;
padding: 1rem;
height: 100%;
} }
.mau { .mau {

View File

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

View File

@@ -53,4 +53,11 @@
::-webkit-details-marker { ::-webkit-details-marker {
display: none; 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 { .hand {
display: flex; display: flex;
flex-direction: row; max-width: 100%;
//justify-content: center; flex-flow: row nowrap !important;
flex-flow: nowrap !important;
overflow-x: scroll; overflow-x: scroll;
padding: 0.5rem; padding: 0.5rem;
gap: 0.5rem; gap: 0.5rem;

View File

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

View File

@@ -1,11 +1,16 @@
.main-lobby { .main-lobby {
width: 100vw;
height: 75vh; height: 75vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-evenly; justify-content: space-evenly;
align-items: center; align-items: center;
&__name {
display: flex;
flex-direction: column;
font-size: 1.5rem;
}
&__container { &__container {
display: flex; display: flex;
gap: 2rem; gap: 2rem;
@@ -19,6 +24,6 @@
} }
&__title { &__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 "pointer";
@import "theme";

View File

@@ -1,4 +1,9 @@
.clickable { .mouse-pointer {
cursor: pointer; cursor: pointer;
user-select: none; 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;
}