8 Commits

Author SHA1 Message Date
DTieman
6ba1523853 Updated front-end to match back-end and better styling
Some checks failed
Build Mau & Deploy Mau / build (push) Successful in 1m48s
Build Mau & Deploy Mau / deploy (push) Has been cancelled
Build Mau & Deploy Mau / build (pull_request) Successful in 1m37s
Build Mau & Deploy Mau / deploy (pull_request) Failing after 2m3s
2024-05-19 23:50:47 +02:00
DTieman
4ae1538552 Updated front-end to match back-end rewrite
All checks were successful
Build Mau & Deploy Mau / build (push) Successful in 1m25s
Build Mau & Deploy Mau / deploy (push) Successful in 3m49s
2024-05-05 17:08:20 +02:00
DTieman
b738263726 Fixed eslint
Some checks failed
Build Mau & Deploy Mau / build (push) Successful in 1m22s
Build Mau & Deploy Mau / build (pull_request) Successful in 1m32s
Build Mau & Deploy Mau / deploy (push) Has been cancelled
Build Mau & Deploy Mau / deploy (pull_request) Has been cancelled
2024-04-22 22:52:24 +02:00
DTieman
d5df884ae8 Better lobby system
Some checks failed
Build Mau & Deploy Mau / build (push) Failing after 1m17s
Build Mau & Deploy Mau / deploy (push) Has been skipped
Build Mau & Deploy Mau / build (pull_request) Failing after 1m24s
Build Mau & Deploy Mau / deploy (pull_request) Has been skipped
2024-04-22 21:52:12 +02:00
DTieman
4959e197bb Mostly design update +
Some checks failed
Build Mau & Deploy Mau / build (push) Failing after 1m23s
Build Mau & Deploy Mau / deploy (push) Has been skipped
- Chat cleaning
- Player hand size visibility
2024-04-21 16:28:55 +02:00
DTieman
d907a56483 Custom names and time in chat
All checks were successful
Build Mau & Deploy Mau / build (push) Successful in 1m25s
Build Mau & Deploy Mau / deploy (push) Successful in 3m27s
2024-04-20 23:44:23 +02:00
DTieman
d547db430c Better layout
Some checks failed
Build Mau & Deploy Mau / build (push) Has been cancelled
Build Mau & Deploy Mau / deploy (push) Has been cancelled
2024-04-20 00:23:18 +02:00
DTieman
e78d0d46f7 Slightly better chat (send with enter)
All checks were successful
Build Mau & Deploy Mau / build (push) Successful in 1m26s
Build Mau & Deploy Mau / deploy (push) Successful in 7m9s
2024-04-19 23:28:29 +02:00
36 changed files with 784 additions and 237 deletions

82
package-lock.json generated
View File

@@ -15,6 +15,8 @@
"@types/node": "^16.18.14", "@types/node": "^16.18.14",
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"bootstrap-icons": "^1.11.3",
"framer-motion": "^11.2.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-query": "^3.39.3", "react-query": "^3.39.3",
@@ -1808,16 +1810,21 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.21.0", "version": "7.24.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz",
"integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.14.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/runtime/node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.20.7", "version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
@@ -5274,6 +5281,21 @@
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
}, },
"node_modules/bootstrap-icons": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz",
"integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
]
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -8149,6 +8171,30 @@
"url": "https://www.patreon.com/infusion" "url": "https://www.patreon.com/infusion"
} }
}, },
"node_modules/framer-motion": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.2.4.tgz",
"integrity": "sha512-D+EXd0lspaZijv3BJhAcSsyGz+gnvoEdnf+QWkPZdhoFzbeX/2skrH9XSVFb0osgUnCajW8x1frjhLuKwa/Reg==",
"dependencies": {
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -18369,11 +18415,18 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
}, },
"@babel/runtime": { "@babel/runtime": {
"version": "7.21.0", "version": "7.24.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz",
"integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==",
"requires": { "requires": {
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.14.0"
},
"dependencies": {
"regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
}
} }
}, },
"@babel/template": { "@babel/template": {
@@ -20871,6 +20924,11 @@
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
}, },
"bootstrap-icons": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz",
"integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww=="
},
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -22961,6 +23019,14 @@
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
"integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==" "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA=="
}, },
"framer-motion": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.2.4.tgz",
"integrity": "sha512-D+EXd0lspaZijv3BJhAcSsyGz+gnvoEdnf+QWkPZdhoFzbeX/2skrH9XSVFb0osgUnCajW8x1frjhLuKwa/Reg==",
"requires": {
"tslib": "^2.4.0"
}
},
"fresh": { "fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",

View File

@@ -10,6 +10,8 @@
"@types/node": "^16.18.14", "@types/node": "^16.18.14",
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"bootstrap-icons": "^1.11.3",
"framer-motion": "^11.2.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-query": "^3.39.3", "react-query": "^3.39.3",

View File

@@ -1,14 +1,11 @@
import React from 'react'; import React from 'react';
import '../src/styles/App.scss'; import '../src/styles/App.scss';
import Router from "./config/Router"; import Router from "./config/Router";
import {useTheme} from "./utils/contexts/ThemeContext";
function App() { function App() {
const {isDarkMode} = useTheme();
return ( return (
<div className={`app ${isDarkMode ? 'dark' : 'light'}`}> <div className={"app"}>
<Router/> <Router/>
</div> </div>
); );

View File

@@ -0,0 +1,38 @@
import {FunctionComponent} from "react";
import {motion} from "framer-motion";
interface Props {
animation?: any;
className?: string;
children: any;
}
const defaultAnimation = {
initial: {
opacity: 0
},
animate: {
opacity: 1
},
exit: {
opacity: 0,
transition: {
duration: .1,
ease: 'easeInOut'
}
}
}
const AnimatedDiv: FunctionComponent<Props> = ({animation, className, children}) => {
return (
<motion.div
variants={animation ?? defaultAnimation}
initial={"initial"}
animate={"animate"}
exit={"exit"}
className={className}>
{children}
</motion.div>
)
}
export default AnimatedDiv;

View File

@@ -1,13 +1,26 @@
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 NoButton: FunctionComponent<ButtonProps> = (props) => {
const {className, ...rest} = props;
return ( return (
<span className={`gh-button ${className}`} onClick={onClick}>{children}</span> <button className={getClassName("no-button", className)} {...rest}/>
); );
}
export const Button: FunctionComponent<ButtonProps> = (props) => {
const {className, ...rest} = props;
return (
<button className={getClassName("button", className)} {...rest}/>
);
}
const getClassName = (templateClass: string, otherClass?: string) => {
return templateClass + (otherClass ? ` ${otherClass}` : "");
} }

View File

@@ -1,13 +1,13 @@
import React, {FunctionComponent} from "react"; import React, {FunctionComponent} from "react";
import {NoButton} from "./Button";
interface Props { interface Props {
cardString: string; cardString: string;
handleClick?: (cardString: string) => void; handleClick?: (cardString: string) => void;
isHidden?: boolean; isHidden?: boolean;
isClickable?: boolean;
} }
const Card: FunctionComponent<Props> = ({cardString, handleClick, isHidden, isClickable}) => { const Card: FunctionComponent<Props> = ({cardString, handleClick, isHidden}) => {
const cardType = cardString.split(' ')[0].toLowerCase(); const cardType = cardString.split(' ')[0].toLowerCase();
const cardValue = cardString.split(' ')[1].toLowerCase(); const cardValue = cardString.split(' ')[1].toLowerCase();
@@ -30,9 +30,17 @@ const Card: FunctionComponent<Props> = ({cardString, handleClick, isHidden, isCl
} }
} }
if (handleClick) {
return (
<NoButton className={"card"} onClick={handleCardClick}>
<img className="card__texture" src={cardSource()} alt={cardName}/>
</NoButton>
)
}
return ( return (
<div className={`card ${isClickable && 'card-clickable'}`}> <div className={"card"}>
<img className="card__texture" src={cardSource()} alt={cardName} onClick={handleCardClick}/> <img className="card__texture" src={cardSource()} alt={cardName}/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,34 @@
import React, {FormEvent, FunctionComponent, RefObject} from "react";
import {NoButton} from "./Button";
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}>
<label className={"chat-form__input"}>
<input type={"text"} placeholder={"Chat here..."} id={"chat-input"} name={"chat-input"}
autoComplete={"off"}/>
<NoButton type={"submit"} className={"bi bi-send"}/>
</label>
</form>
</>
)
}
export default Chat;

View File

@@ -15,12 +15,10 @@ const Deck: FunctionComponent<Props> = ({currentCard, actionOnClick}) => {
return ( return (
<div className="deck-container"> <div className="deck-container">
<div className="used-cards"> <div className="used-cards">
<p></p>
<Card cardString={currentCard}/> <Card cardString={currentCard}/>
</div> </div>
<div className="deck"> <div className="deck">
<p></p> <Card cardString={"AA BB"} handleClick={handleClick} isHidden/>
<Card cardString={"AA BB"} handleClick={handleClick} isHidden={true}/>
</div> </div>
</div> </div>
); );

View File

@@ -1,55 +0,0 @@
import React, {FunctionComponent} from "react";
import Deck from "./Deck";
import Hand from "./Hand";
import {GHButton} from "./Button";
export interface GameState {
PlayerName: string;
CurrentState: string;
Hand: string[];
CurrentCard: string;
CurrentPlayer: string;
Players: string[];
}
interface Props {
gameState: GameState
handleCardSend: (cardString: string) => void;
handleDraw: () => void;
handleChoice: (choice: string) => void;
}
const CHOICES = ['SPADES', 'HEARTS', 'DIAMONDS', 'CLUBS'];
const Game: FunctionComponent<Props> = ({gameState, handleCardSend, handleDraw, handleChoice}) => {
return (
<div className="game">
{
gameState.CurrentCard &&
<Deck currentCard={gameState.CurrentCard} actionOnClick={handleDraw}/>
}
{
gameState.Hand &&
<Hand hand={gameState.Hand} actionOnClick={handleCardSend}/>
}
{
gameState.CurrentState === 'CHOOSE' &&
CHOICES.map(choice => <GHButton key={choice} onClick={() => handleChoice(choice)}>{choice}</GHButton>)
}
<ul>
{
gameState?.Players &&
gameState.Players.map((player) => {
const isCurrentPlayer = player === gameState.CurrentPlayer;
const isMe = player === gameState.PlayerName;
return <li key={player} style={{fontWeight: isCurrentPlayer ? 'bold' : 'normal'}}>
{player} {isMe && '(You)'}
</li>
})
}
</ul>
</div>
)
}
export default Game;

View File

@@ -3,25 +3,24 @@ import Card from "./Card";
interface Props { interface Props {
hand: string[]; hand: string[];
actionOnClick: (cardString: string) => void; actionOnClick?: (cardString: string) => void;
isHidden?: boolean; isHidden?: boolean;
} }
const Hand: FunctionComponent<Props> = ({hand, actionOnClick, isHidden}) => { const Hand: FunctionComponent<Props> = ({hand, actionOnClick, isHidden}) => {
const isMyHand = !isHidden;
return ( return (
<div className="hand"> <ul className="hand">
{ {
hand.map((card, index) => { hand.map((card, index) => {
return ( return (
<Card key={index} cardString={card} handleClick={actionOnClick} isHidden={!isMyHand} <li key={card} className={"hand__item"}>
isClickable={isMyHand}/> <Card cardString={card} handleClick={actionOnClick} isHidden={isHidden}/>
</li>
) )
}) })
} }
</div> </ul>
) )
} }

View File

@@ -0,0 +1,25 @@
import {FunctionComponent} from "react";
import {createPortal} from "react-dom";
import AnimatedDiv from "./AnimatedDiv";
interface Props {
isOpen: boolean
children: JSX.Element | JSX.Element[];
modalRoot: HTMLElement | null;
}
const Modal: FunctionComponent<Props> = (props) => {
return (
<>
{
props.isOpen &&
createPortal(<AnimatedDiv className={"modal-root"}>
<div className={"modal-container"}>{props.children}</div>
</AnimatedDiv>, props.modalRoot ?? document.body)
}
</>
)
}
export default Modal;

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'}/>
</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/>
</div> </NoButton>
</div> </div>
</div> </div>
); );

View File

@@ -1,18 +1,24 @@
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 useTitle from "../../utils/hooks/TitleHook"; import useTitle from "../../utils/hooks/TitleHook";
import Game, {GameState} from "../components/Game"; import Game, {GameState} from "../views/Game/Game";
import Lobby from "../views/Lobby/Lobby";
import Chat from "../components/Chat";
import {Button} from "../components/Button";
interface ChatMessage { export interface Connection {
PlayerName: string; Id: string;
Message: string; Name: string;
} }
interface SocketMessage { export interface Player extends Connection {
CardsLeft: number;
}
export interface SocketMessage {
Type: string; Type: string;
Payload: any; Data: string;
} }
const Room = () => { const Room = () => {
@@ -23,34 +29,75 @@ const Room = () => {
const {roomId} = useParams(); const {roomId} = useParams();
const WS_URL = `${process.env.REACT_APP_WEBSOCKET_URL}/room/${roomId}`; const playerName = localStorage.getItem('playerName') ?? "";
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 [winner, setWinner] = React.useState<string | undefined>(undefined);
CurrentState: '', const [playerList, setPlayerList] = React.useState<Connection[]>([]);
Hand: [],
CurrentCard: '', const chatRef = React.useRef<HTMLOListElement>(null);
CurrentPlayer: '',
Players: [] const addChatMessage = (message: { Sender: string, Message: string }) => {
}); if (!chatRef.current) return;
const [chatMessages, setChatMessages] = React.useState<ChatMessage[]>([]);
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: () => {
console.log('WebSocket connection established.'); console.log('WebSocket connection established.');
}, },
onMessage: (event) => { onMessage: (messageEvent) => {
const data = JSON.parse(event.data); const data: SocketMessage = JSON.parse(messageEvent.data);
const payload = JSON.parse(data.Payload); const payload = JSON.parse(data.Data);
if (data.Type === 'GAME') setGameState(payload); switch (data.Type) {
if (data.Type === 'CHAT') setChatMessages(prev => [...prev, payload]); case 'GAME':
setGameState(payload);
break;
case 'CHAT':
addChatMessage(payload);
break;
case "END":
setWinner(payload.Name)
setGameState(undefined);
break;
case "JOIN":
onJoin(payload);
break;
case "LEAVE":
onLeave(payload.Connection);
break;
default:
console.log('Unknown message type:', data.Type);
}
},
onClose: (closeEvent) => {
navigateTo('/');
} }
}); });
const onJoin = (payload: { Connections: Connection[], NewConnection: Connection }) => {
setPlayerList(payload.Connections);
addChatMessage({Sender: "System", Message: payload.NewConnection.Name + " has joined the room."});
}
const onLeave = (payload: Connection) => {
const connection = payload;
setPlayerList(playerList.filter(player => player.Id !== connection.Id));
addChatMessage({Sender: "System", Message: connection.Name + " has left the room."});
}
const handleLeaveRoom = () => { const handleLeaveRoom = () => {
const socket = websocket.getWebSocket();
if (socket) socket.close();
navigateTo('/'); navigateTo('/');
} }
@@ -58,58 +105,51 @@ const Room = () => {
websocket.sendMessage(JSON.stringify(message)); websocket.sendMessage(JSON.stringify(message));
} }
const handleCardSend = (card: string) => { const handleLobbyAction = (action: string) => {
handleSend({ handleSend({Type: "LOBBY", Data: JSON.stringify(action)});
Type: "GAME",
Payload: JSON.stringify({
Action: "PLAYCARD",
Data: JSON.stringify({
CardType: card.split(' ')[0],
CardValue: card.split(' ')[1]
})
})
})
} }
const handleChoice = (choice: string) => { const handleKick = (playerId: string) => {
handleSend({ handleSend({Type: "KICK", Data: playerId});
Type: "GAME",
Payload: JSON.stringify({
Action: "CHOOSE",
Data: choice
})
});
} }
const handleDraw = () => { const handleChatMessage = (chatMessage: string) => {
handleSend({
Type: "GAME",
Payload: JSON.stringify({
Action: "DRAW",
Data: ""
})
});
}
const handleChat = (message: string) => {
handleSend({ handleSend({
Type: "CHAT", Type: "CHAT",
Payload: message Data: 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 className={"aside-header"}>
<Game gameState={gameState} handleCardSend={handleCardSend} handleDraw={handleDraw} handleChoice={handleChoice}/> <hgroup className={"aside-header__titles"}>
<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) => ( <Button onClick={handleLeaveRoom} className={"icon-start"}>
<li key={index}>{message.PlayerName}: {message.Message}</li> <i className="bi bi-arrow-bar-left"></i>
))} Leave Room
</ul> </Button>
</header>
<Chat chatRef={chatRef} handleSend={handleChatMessage}/>
</aside>
{
gameState ?
<Game gameState={gameState} handleGameAction={handleSend}/>
:
<Lobby playerList={playerList} winner={winner} handleLobbyAction={handleLobbyAction} onKickClick={handleKick}/>
}
</div> </div>
) )
} }

View File

@@ -1,6 +1,7 @@
import useTitle from "../../utils/hooks/TitleHook"; import useTitle from "../../utils/hooks/TitleHook";
import React from "react"; import React from "react";
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`;
@@ -11,6 +12,13 @@ const Rooms = () => {
const [rooms, setRooms] = React.useState<string[]>([]); const [rooms, setRooms] = React.useState<string[]>([]);
const handleRoomClick = (room: string) => {
if (!localStorage.getItem('playerName')){
localStorage.setItem("playerName", "Mau");
}
navigateTo(`/room/${room}`);
}
React.useEffect(() => { React.useEffect(() => {
fetch(ROOM_URL) fetch(ROOM_URL)
.then(r => r.json()) .then(r => r.json())
@@ -19,12 +27,13 @@ const Rooms = () => {
return ( return (
<div> <div>
<h1>Rooms</h1> <h1 className={"mau"}>Rooms</h1>
<ul> <ul>
{ {
rooms.map((room, index) => { rooms.map((room) => {
return <li key={index} className={"clickable"} return <li key={room}>
onClick={() => navigateTo(`/room/${room}`)}>{room}</li> <NoButton onClick={() => handleRoomClick(room)}>{room}</NoButton>
</li>
}) })
} }
</ul> </ul>

View File

@@ -0,0 +1,84 @@
import React, {FunctionComponent} from "react";
import {Player, SocketMessage} from "../../pages/Room";
import Deck from "../../components/Deck";
import Hand from "../../components/Hand";
import CardTypeChoiceModal from "./components/CardTypeChoiceModal";
export interface GameState {
Me: Player;
MyState: string;
Hand: string[];
CurrentCard: string;
NextAllowedCardType?: string
CurrentPlayer: Player;
Players: Player[];
}
interface Props {
gameState: GameState
handleGameAction: (message: SocketMessage) => void;
}
const Game: FunctionComponent<Props> = ({gameState, handleGameAction}) => {
const handleChoice = (choice: string) => {
handleGameAction({Type: 'CHOOSE', Data: choice});
}
const handleDraw = () => {
handleGameAction({Type: 'DRAW', Data: ""});
}
const handleCardSend = (cardString: string) => {
handleGameAction({
Type: "PLAY", Data: JSON.stringify({
CardType: cardString.split(' ')[0],
CardValue: cardString.split(' ')[1]
})
})
}
return (
<main id={"game"} 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.NextAllowedCardType &&
<p>Next card type: {gameState.NextAllowedCardType}</p>
}
{
gameState.CurrentCard &&
<Deck currentCard={gameState.CurrentCard} actionOnClick={handleDraw}/>
}
{
gameState.Hand &&
<Hand hand={gameState.Hand} actionOnClick={handleCardSend}/>
}
{
<CardTypeChoiceModal isOpen={gameState.MyState === 'CHOOSE'} handleChoice={handleChoice}/>
}
</main>
)
}
export default Game;

View File

@@ -0,0 +1,41 @@
import React, {FunctionComponent} from "react";
import {NoButton} from "../../../components/Button";
import Modal from "../../../components/Modal";
export interface Choice {
name: string;
icon: string;
}
interface Props {
isOpen: boolean;
handleChoice: (choice: string) => void;
}
const cardTypeChoices: Choice[] = [
{name: 'SPADES', icon: "bi-suit-spade-fill"},
{name: 'HEARTS', icon: "bi-suit-heart-fill"},
{name: 'DIAMONDS', icon: "bi-suit-diamond-fill"},
{name: 'CLUBS', icon: "bi-suit-club-fill"}
];
const CardTypeChoiceModal: FunctionComponent<Props> = (props) => {
return (
<Modal isOpen={props.isOpen} modalRoot={document.getElementById("game")}>
<div className={"card-type-choice-modal"}>
<div className={"card-type-choice-modal__choices"}>
{
cardTypeChoices.map(type =>
<NoButton key={type.name} onClick={() => props.handleChoice(type.name)} aria-label={type.name.charAt(0) + type.name.substring(1).toLowerCase()}>
<i className={`bi ${type.icon}`}/>
</NoButton>
)
}
</div>
<h3 className={"card-type-choice-modal__title"}>Choose a card type</h3>
</div>
</Modal>
)
}
export default CardTypeChoiceModal;

View File

@@ -0,0 +1,46 @@
import {FunctionComponent} from "react";
import {Connection} from "../../pages/Room";
import {Button} from "../../components/Button";
interface Props {
winner: string | undefined;
handleLobbyAction: (action: string) => void;
playerList: Connection[];
onKickClick: (playerId: string) => void;
}
const Lobby: FunctionComponent<Props> = ({winner, handleLobbyAction, playerList, onKickClick}) => {
const onStartClick = () => {
console.log('Start Game');
handleLobbyAction("START");
}
return (
<main className={"game-lobby"}>
<h1>Waiting for host to start the game</h1>
<section>
<h2>Players</h2>
<ol className={"game-lobby__player-list"}>
{
playerList.map((player) => {
return <li key={player.Id}>
<p>{player.Name}</p>
<Button className={"button-danger"} onClick={() => onKickClick(player.Id)}>Kick</Button>
</li>
})
}
</ol>
</section>
<section>
<h2>Game Settings</h2>
<i>Not available... yet?</i>
</section>
<Button className={"button-success game-lobby__start-game"} onClick={onStartClick}>
<strong>Start Game</strong>
</Button>
</main>
);
}
export default Lobby;

View File

@@ -7,9 +7,35 @@
src: local('Mau'), url(../assets/fonts/OrientalCatsLight.otf) format('opentype'); src: local('Mau'), url(../assets/fonts/OrientalCatsLight.otf) format('opentype');
} }
* {
box-sizing: border-box;
}
body {
position: relative;
margin: 0;
overflow: hidden;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
height: 100dvh;
width: 100dvw;
background-color: #131313;
color: #ffffff;
}
#root, .app {
position: absolute;
height: 100%;
width: 100%;
}
.app { .app {
width: 100vw; padding: 1rem;
height: 100vh; height: 100%;
width: 100%;
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
} }
.mau { .mau {

View File

@@ -1,4 +1,10 @@
@import "button"; @import "button";
@import "card"; @import "card";
@import "chat";
@import "card-type-choice-modal";
@import "deck";
@import "game";
@import "game-lobby";
@import "hand"; @import "hand";
@import "deck"; @import "modal";
@import "room-header";

View File

@@ -1,56 +1,46 @@
.gh-button { .icon-end {
appearance: none; display: grid;
background-color: #FAFBFC; grid-template-columns: 1fr auto;
border: 1px solid rgba(27, 31, 35, 0.15); }
border-radius: 6px;
box-shadow: rgba(27, 31, 35, 0.04) 0 1px 0, rgba(255, 255, 255, 0.25) 0 1px 0 inset; .icon-start {
box-sizing: border-box; display: grid;
color: #24292E; grid-template-columns: auto 1fr;
}
.no-button {
background-color: transparent;
border: none;
color: inherit;
cursor: pointer; cursor: pointer;
display: inline-block; }
font-family: -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 14px;
font-weight: 500;
line-height: 20px;
list-style: none;
padding: 6px 16px;
position: relative;
transition: background-color 0.2s cubic-bezier(0.3, 0, 0.5, 1);
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
vertical-align: middle;
white-space: nowrap;
word-wrap: break-word;
:hover { .button {
background-color: #F3F4F6; background-color: #2e2e2e;
text-decoration: none; border: none;
transition-duration: 0.1s; color: #fff;
font-size: 1rem;
padding: 1rem;
border-radius: 0.5rem;
cursor: pointer;
&:hover {
background-color: #3e3e3e;
} }
:disabled { &-success {
background-color: #FAFBFC; background-color: #276b00;
border-color: rgba(27, 31, 35, 0.15);
color: #959DA5; &:hover {
cursor: default; background-color: #3e8e00;
}
} }
:active { &-danger {
background-color: #EDEFF2; background-color: #6b0000;
box-shadow: rgba(225, 228, 232, 0.2) 0 1px 0 inset;
transition: none 0s;
}
:focus { &:hover {
outline: 1px transparent; background-color: #8e0000;
} }
:before {
display: none;
}
::-webkit-details-marker {
display: none;
} }
} }

View File

@@ -0,0 +1,17 @@
.card-type-choice-modal {
background-color: #9f7d2f;
border-radius: 0.5rem;
padding: 1rem 2rem;
&__choices {
display: flex;
gap: 0.5rem;
justify-content: space-between;
font-size: 3rem;
}
&__title {
text-align: center;
margin-top: 1rem;
}
}

View File

@@ -2,10 +2,6 @@
width: 100px; width: 100px;
min-width: 100px; min-width: 100px;
&-clickable {
cursor: pointer;
}
&__texture { &__texture {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -0,0 +1,38 @@
.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 {
display: grid;
grid-template-columns: 1fr auto;
background-color: #2e2e2e;
border: none;
color: #fff;
font-size: 1rem;
padding: 0.5rem;
border-radius: 0.5rem;
> input {
background-color: transparent;
border: none;
color: #fff;
font-size: 1rem;
outline: none;
}
}
}
}

View File

@@ -0,0 +1,41 @@
.game-lobby {
display: grid;
grid-template-rows: auto 1fr 1fr auto;
grid-row-gap: 1rem;
justify-items: center;
> * {
width: 100%;
}
&__player-list {
overflow-y: hidden;
padding: 0;
list-style: none;
min-height: 90%;
max-height: 90%;
height: 0;
&:hover {
overflow-y: scroll;
}
li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
background-color: #2e2e2e;
border-radius: 0.5rem;
padding: 0.5rem;
margin: 0.5rem 0;
button {
padding: 0 1rem;
}
}
}
&__start-game {
font-size: 1rem;
}
}

View File

@@ -0,0 +1,26 @@
.game {
height: 100%;
display: grid;
grid-template-rows: 1fr 1fr 1fr;
place-items: center;
position: relative;
&__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,9 +1,12 @@
.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;
&__item {
list-style: none;
}
} }

View File

@@ -0,0 +1,13 @@
.modal {
&-root {
position: absolute;
padding: 1rem;
height: 100%;
width: 100%;
background-color: rgba(0, 0, 0, 0.25);
display: flex;
justify-content: center;
align-items: center;
transition: all 0.5s ease-in-out;
}
}

View File

@@ -0,0 +1,10 @@
.aside-header {
&__titles {
margin-bottom: 1rem;
> * {
margin: 0;
}
}
}

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,19 @@
.room {
display: grid;
grid-template-columns: auto 1fr;
grid-column-gap: 1rem;
height: 100%;
&-aside {
display: grid;
min-width: 20%;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden auto;
button {
padding: 0.5rem;
font-size: 1rem;
width: 100%;
}
}
}

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

View File

@@ -1 +1,2 @@
@import "bootstrap";
@import "normalize"; @import "normalize";

2
src/styles/vendor/bootstrap.scss vendored Normal file
View File

@@ -0,0 +1,2 @@
//@import "../../../node_modules/bootstrap/scss/bootstrap.scss";
@import "../../../node_modules/bootstrap-icons/font/bootstrap-icons.scss";