Merge mancala-frontend repo

This commit is contained in:
Halit Aksoy 2024-03-24 02:53:50 +03:00
commit c9a54a100d
66 changed files with 4573 additions and 0 deletions

3
frontend/.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"plugins": ["styled-jsx/babel"]
}

38
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Distribution directories
dist/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
.cache
.parcel-cache

7
frontend/.parcelrc Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "@parcel/config-default",
"transformers": {
"jsx:*.svg": ["@parcel/transformer-svg-react"],
"jsx:*": ["..."]
}
}

41
frontend/.vscode/.snippet.code-snippets vendored Normal file
View File

@ -0,0 +1,41 @@
{
// Place your mancala-frontend workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
"styled jsx": {
"scope": "typescriptreact",
"prefix": "styledjsx",
"body": [
"<style jsx>{`",
"\t$1",
"`}</style>"
],
"description": "Log output to console"
},
"React Functional Component": {
"scope": "typescriptreact",
"prefix": "rfc",
"body": [
"import * as React from 'react';",
"import { FunctionComponent } from 'react';",
"",
"const $TM_FILENAME_BASE: FunctionComponent = () => {",
"\treturn (",
"\t\t<div>",
"\t\t\t$0",
"\t\t\t<style jsx>{`",
"\t\t\t\t",
"\t\t\t`}</style>",
"\t\t</div>",
"\t);",
"}",
"",
"export default $TM_FILENAME_BASE;"
],
"description": "Log output to console"
}
}

15
frontend/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

3
frontend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

40
frontend/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "mancala-frontend",
"version": "0.2.1-alpha.3",
"description": "Mancala Game Frontend",
"scripts": {
"dev": "parcel src/index.html",
"test": "echo \"Error: no test specified\" && exit 1",
"build": "parcel build src/index.html",
"start": "parcel",
"clean": "rm -rf dist/*"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@szhsin/react-menu": "^3.1.2",
"@types/": "szhsin/react-menu",
"@types/eventemitter2": "^4.1.0",
"@types/styled-jsx": "^3.4.4",
"@types/uuid": "^8.3.4",
"eventemitter2": "^6.4.7",
"mancala.js": "^0.0.2-beta.3",
"notyf": "^3.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "6",
"styled-jsx": "^5.0.2",
"sweetalert": "^2.1.2",
"uuid": "^8.3.2"
},
"devDependencies": {
"@babel/core": "^7.18.6",
"@parcel/transformer-svg-react": "^2.6.2",
"@types/react": "^17.0.11",
"@types/react-dom": "^17.0.8",
"parcel": "^2.6.2",
"process": "^0.11.10",
"typescript": "^4.3.4"
}
}

6
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,6 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
const container = document.getElementById('main');
const root = createRoot(container!);
import MancalaApp from './MancalaApp';
root.render(<MancalaApp/>);

View File

@ -0,0 +1,93 @@
import * as React from 'react';
import { FunctionComponent, useState } from 'react';
import {
BrowserRouter,
Routes,
Route,
} from "react-router-dom";
import FloatingPanel from './components/FloatingPanel';
import GamePage from './routes/GamePage';
import Home from './routes/Home';
import { initContext } from './context/context';
import { RTMTWS } from './rtmt/rtmt_websocket';
import { getColorByBrightness } from './util/ColorUtil';
import { Theme } from './theme/Theme';
import LobyPage from './routes/LobyPage';
import swal from 'sweetalert';
import { ConnectionState } from './rtmt/rtmt';
import Util from './util/Util';
const context = initContext();
const MancalaApp: FunctionComponent = () => {
const [userKey, setUserKey] = useState<string | undefined>(undefined);
const [connectionState, setConnetionState] = useState<ConnectionState>("connecting");
const [theme, setTheme] = useState<Theme>(context.themeManager.theme);
const onConnectionError = (event: Event) => console.error("(RTMT) Connection Error: ", event);
const onConnectionChange = (_connectionState: ConnectionState) => setConnetionState(_connectionState);
const onThemeChange = (theme: Theme) => setTheme(theme);
const connectRTMT = (userKey: string) => {
const rtmt = context.rtmt as RTMTWS;
rtmt.on("error", onConnectionError);
rtmt.on("connectionchange", onConnectionChange)
rtmt.connectWebSocket(userKey)
return rtmt;
}
const loadUserKeyAndConnectServer = () => {
context.userKeyStore.getUserKey().then((userKey: string) => {
setUserKey(userKey);
connectRTMT(userKey);
}).catch((error) => {
//TODO: check if it is network error!
swal(context.texts.Error + "!", context.texts.ErrorWhenRetrievingInformation, "error");
console.error(error);
});
}
const disposeApp = () => {
context.rtmt?.dispose();
context.themeManager.off("themechange", onThemeChange);
}
React.useEffect(() => {
loadUserKeyAndConnectServer();
context.themeManager.on("themechange", onThemeChange);
return () => disposeApp();
}, []);
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<>
<BrowserRouter>
<Routes>
<Route index element={<Home context={context} theme={theme} userKey={userKey} />} />
<Route path="/" >
<Route path="game" >
<Route path=":gameId" element={<GamePage context={context} theme={theme} userKey={userKey} />} ></Route>
</Route>
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
</Route>
</Route>
</Routes>
</BrowserRouter>
<FloatingPanel context={context} color={context.themeManager.theme.boardColor} visible={connectionState != "connected"}>
<span style={{ color: textColorOnBoard, transition: 'color 0.5s' }}>{Util.getTextByConnectionState(context, connectionState)}</span>
</FloatingPanel>
</>
);
}
export default MancalaApp;

View File

@ -0,0 +1,218 @@
import {
GameStep,
HistoryItem,
GAME_STEP_GAME_MOVE,
GAME_STEP_LAST_STONE_IN_EMPTY_PIT,
GAME_STEP_BOARD_CLEARED,
GAME_STEP_LAST_STONE_IN_BANK,
GAME_STEP_DOUBLE_STONE_IN_PIT,
MancalaGame,
} from "mancala.js";
import { v4 } from "uuid";
import { Context } from "../context/context";
import BoardViewModelFactory from "../factory/BoardViewModelFactory";
import { PitViewModelFactory } from "../factory/PitViewModelFactory";
import { Game } from "../models/Game";
import { Theme } from "../theme/Theme";
import { getColorByBrightness } from "../util/ColorUtil";
import BoardViewModel from "../viewmodel/BoardViewModel";
const animationUpdateInterval = 300;
export default class PitAnimator {
context: Context;
game: Game | undefined;
oldGame: Game | undefined;
currentIntervalID: number;
onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void;
boardViewModel: BoardViewModel | undefined;
oldBoardViewModel: BoardViewModel | undefined;
animationIndex: number = 0;
currentHistoryItem: HistoryItem | undefined;
constructor(
context: Context,
onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void
) {
this.context = context;
this.onBoardViewModelUpdate = onBoardViewModelUpdate;
this.context.themeManager.on("themechange", this.onThemeChange.bind(this));
}
get mancalaGame(): MancalaGame | undefined {
return this.game?.mancalaGame;
}
public setNewGame(game: Game) {
this.reset();
this.game = game;
this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game));
}
public setUpdatedGame(game: Game) {
this.resetAnimationState();
if (!this.game) {
this.setNewGame(game);
} else {
this.oldGame = this.game;
this.game = game;
this.onGameMoveAnimationStart();
}
}
onGameMoveAnimationStart() {
this.stopCurrentAnimation();
if (this.game && this.oldGame && this.mancalaGame && this.mancalaGame?.history.length > 0) {
const lastHistoryItem = this.mancalaGame.history[this.mancalaGame.history.length - 1];
if (lastHistoryItem.gameSteps.length > 0) {
this.animationIndex = 0;
this.currentHistoryItem = lastHistoryItem;
this.boardViewModel = this.getBoardViewModelFromGame(this.game);
this.oldBoardViewModel = this.getBoardViewModelFromGame(this.oldGame);
this.startAnimationUpdateCyle();
}
}
}
onAnimate() {
if (!this.currentHistoryItem || !this.game || !this.oldBoardViewModel || !this.mancalaGame) return;
if (this.animationIndex === this.currentHistoryItem.gameSteps.length) {
this.clearCurrentInterval();
this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game));
} else {
const gameStep = this.currentHistoryItem.gameSteps[this.animationIndex];
const index = this.mancalaGame.board.getPitIndexCircularly(gameStep.index);
this.animatePit(index, this.oldBoardViewModel, gameStep);
this.onBoardViewModelUpdate?.(this.oldBoardViewModel);
}
this.animationIndex++;
}
getGameMoveStepCount(historyItem: HistoryItem) {
return historyItem.gameSteps.filter(
(gameStep) => gameStep.type === GAME_STEP_GAME_MOVE
).length;
}
animatePit(
index: number,
boardViewModel: BoardViewModel,
gameStep: GameStep
) {
if (!this.currentHistoryItem || !this.game || !this.mancalaGame) return;
const pitViewModel = boardViewModel.pits[index];
if (this.animationIndex === 0) {
//This is one stone move case, TODO: beautify it later
if (this.getGameMoveStepCount(this.currentHistoryItem) === 1) {
const previousPitIndex = gameStep.index - 1;
if (previousPitIndex > 0) {
boardViewModel.pits[previousPitIndex].stoneCount = 0;
}
} else {
pitViewModel.stoneCount = 0;
}
}
const theme = this.context.themeManager.theme;
if (gameStep.type === GAME_STEP_GAME_MOVE) {
pitViewModel.stoneCount += 1;
pitViewModel.pitColor = theme.pitGameMoveAnimateColor;
} else if (gameStep.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT) {
pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
pitViewModel.stoneCount = 0;
const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly(
gameStep.data.oppositeIndex
);
const oppositePitViewModel = boardViewModel.pits[oppositeIndex];
oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
oppositePitViewModel.stoneCount = 0;
} else if (gameStep.type === GAME_STEP_LAST_STONE_IN_BANK) {
pitViewModel.pitColor = theme.pitLastStoneInBankPitAnimateColor;
} else if (gameStep.type === GAME_STEP_BOARD_CLEARED) {
for (const index of gameStep.data.pitIndexesThatHasStone) {
const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly(index);
const oppositePitViewModel = boardViewModel.pits[oppositeIndex];
oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
oppositePitViewModel.stoneCount = 0;
}
} else if (gameStep.type === GAME_STEP_DOUBLE_STONE_IN_PIT) {
const _index = this.mancalaGame.board.getPitIndexCircularly(index);
const pitViewModel = boardViewModel.pits[_index];
pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
pitViewModel.stoneCount = 0;
}
pitViewModel.stoneColor = getColorByBrightness(
pitViewModel.pitColor,
theme.stoneColor,
theme.stoneLightColor
);
}
startAnimationUpdateCyle() {
this.clearCurrentInterval();
this.currentIntervalID = setInterval(
() => this.onAnimate(),
animationUpdateInterval
);
}
stopCurrentAnimation() {
this.clearCurrentInterval();
if (this.oldGame) {
this.onBoardViewModelUpdate?.(
this.getBoardViewModelFromGame(this.oldGame)
);
}
this.resetAnimationState();
}
clearCurrentInterval() {
if (this.currentIntervalID) {
clearInterval(this.currentIntervalID);
}
}
public getBoardViewModelFromGame(game: Game): BoardViewModel {
const pitViewModels = this.createPitViewModelsFromGame(game);
return BoardViewModelFactory.create(v4(), pitViewModels);
}
private createPitViewModelsFromGame(game: Game) {
return game.mancalaGame.board.pits.map((pit) => {
const theme = this.context.themeManager.theme;
const stoneCount = pit.stoneCount;
const stoneColor = theme.stoneColor;
const pitColor = theme.pitColor;
const id = pit.index.toString();
return PitViewModelFactory.create({
id,
stoneCount,
stoneColor,
pitColor,
});
});
}
private onThemeChange(theme: Theme){
if(!this.game) return;
this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game));
}
public resetAnimationState() {
this.animationIndex = -1;
this.currentHistoryItem = undefined;
this.boardViewModel = undefined;
this.oldBoardViewModel = undefined;
}
public reset() {
this.resetAnimationState();
this.game = undefined;
this.oldGame = undefined;
}
public dispose() {
this.context.themeManager.off("themechange", this.onThemeChange.bind(this));
this.resetAnimationState();
this.clearCurrentInterval();
}
}

View File

@ -0,0 +1,41 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../context/context";
import { getColorByBrightness } from "../util/ColorUtil";
const Button: FunctionComponent<{
context: Context;
text: String;
onClick: () => void;
color: string;
}> = ({ context, text, color, onClick }) => {
const textColor = getColorByBrightness(
color,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<button
onClick={onClick}
style={{
backgroundColor: color,
color: textColor,
}}
>
<style jsx>{`
button {
margin: 5px;
padding: 10px;
border: none;
border-radius: 30px;
transition: color 0.5s;
transition: background-color 0.5s;
white-space: nowrap;
}
`}</style>
{text}
</button>
);
};
export default Button;

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const Center: FunctionComponent = ({children}) => {
return (
<div className='center'>
{children}
<style jsx>{`
.center {
display: flex;
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
}
`}</style>
</div>
);
}
export default Center;

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const CircularPanel: FunctionComponent<{
color: string;
style?: React.CSSProperties
}> = (props) => {
return (
<div style={Object.assign({ background: props.color }, props.style)}>
<style jsx>{`
div {
padding: 10px 20px;
border-radius: 30px;
transition: background-color 0.5s;
white-space: nowrap;
}
`}
</style>
{props.children}
</div>
);
}
export default CircularPanel;

View File

@ -0,0 +1,32 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../context/context";
const FloatingPanel: FunctionComponent<{
context: Context;
color: string;
visible: boolean;
}> = (props) => {
if(props.visible === false) return <></>
return (
<div style={{
backgroundColor: props.color,
}}>
<style jsx>{`
div {
position: absolute;
bottom: 0px;
left: 0px;
padding: 15px;
border-top-right-radius: 1vw;
min-width: 10vw;
min-height: 1vw;
z-index: 100;
}
`}</style>
{props.children}
</div>
)
};
export default FloatingPanel;

View File

@ -0,0 +1,112 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../context/context";
import { Game } from "../models/Game";
import { User } from "../models/User";
import { getColorByBrightness } from "../util/ColorUtil";
import CircularPanel from "./CircularPanel";
function getInfoPanelTextByGameState(params: {
context: Context;
game?: Game;
currentUser: User;
whitePlayer: User;
blackPlayer: User;
leftPlayer?: User;
isSpectator?: boolean;
}): string | undefined {
const {
context,
game,
currentUser,
whitePlayer,
blackPlayer,
leftPlayer,
isSpectator
} = params;
if (leftPlayer) {
return isSpectator ? `${leftPlayer.name} ${context.texts.UserLeftTheGame}` :
leftPlayer.id == currentUser.id ? context.texts.YouLeftTheGame : context.texts.OpponentLeftTheGame;
}
const isGameEnded = game?.mancalaGame.state == "ended";
if (isGameEnded) {
const wonPlayerID = game.mancalaGame.getWonPlayerId();
let whoWon;
if (wonPlayerID) {
const wonPlayer = wonPlayerID == whitePlayer.id ? whitePlayer : blackPlayer;
whoWon = isSpectator ? `${wonPlayer.name} ${context.texts.Won}` :
game.mancalaGame.getWonPlayerId() === currentUser.id
? context.texts.YouWon
: context.texts.YouLost;
} else {
whoWon = context.texts.GameDraw;
}
return context.texts.GameEnded + " " + whoWon;
}
if (game) {
const playingPlayer = game.mancalaGame.checkIsPlayerTurn(whitePlayer.id) ? whitePlayer : blackPlayer;
return isSpectator ? `${playingPlayer.name} ${context.texts.Playing}` : game.mancalaGame.checkIsPlayerTurn(currentUser.id)
? context.texts.YourTurn
: context.texts.OpponentTurn;
}
return undefined;
}
const InfoPanel: FunctionComponent<{
context: Context;
game?: Game;
currentUser: User;
whitePlayer: User;
blackPlayer: User;
leftPlayer?: User;
style?: React.CSSProperties;
visible?: boolean;
isSpectator?: boolean;
}> = ({
context,
game,
currentUser,
whitePlayer,
blackPlayer,
leftPlayer,
style,
visible,
isSpectator
}) => {
if (visible === false) return <></>;
const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false;
const containerColor = isUserTurn
? context.themeManager.theme.playerTurnColor
: context.themeManager.theme.boardColor;
const textColor = getColorByBrightness(
containerColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const text = getInfoPanelTextByGameState({
context,
game,
currentUser,
whitePlayer,
blackPlayer,
leftPlayer,
isSpectator
});
if (text) {
return (
<CircularPanel style={style} color={containerColor}>
<h4 style={{ margin: "0", color: textColor, transition: 'color 0.5s' }}>
{text}
</h4>
</CircularPanel>
);
} else {
return (<div></div>)
}
};
export default InfoPanel;

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
import { Context } from '../context/context';
import { LoadingState } from '../models/LoadingState';
import { getColorByBrightness } from '../util/ColorUtil';
import CircularPanel from './CircularPanel';
const LoadingComponent: FunctionComponent<{ context: Context, loadingState: LoadingState<any> }> = ({ context, loadingState }) => {
if (loadingState.isUnset() || loadingState.isLoaded()) {
return <></>
}
if (loadingState.isLoading() || loadingState.isError()) {
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const text = loadingState.isLoading() ? context.texts.Loading +"..." : loadingState.errorMessage;
return (
<CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${text}`}</h4>
</CircularPanel>
);
}
return <></>
}
export default LoadingComponent;

View File

@ -0,0 +1,25 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Theme } from "../theme/Theme";
const PageContainer: FunctionComponent<{ theme: Theme }> = (props) => {
return (
<div style={{
background: props.theme?.background,
}}>
<style jsx>{`
div {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-height: 400px;
transition: background-color 0.5s;
}
`}</style>
{props.children}
</div>
);
}
export default PageContainer;

View File

@ -0,0 +1,20 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const Row: FunctionComponent = ({children}) => {
return (
<div className="row">
{children}
<style jsx>{`
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
`}</style>
</div>
);
}
export default Row;

View File

@ -0,0 +1,10 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const Space: FunctionComponent<{ width?: string, height?: string }> = ({ width, height }) => {
return (
<div style={{ width, height}}/>
);
}
export default Space;

View File

@ -0,0 +1,71 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
import { Context } from '../context/context';
import { User } from '../models/User';
import { getColorByBrightness } from '../util/ColorUtil';
import Space from './Space';
export type LayoutMode = "right" | "left";
const UserStatus: FunctionComponent<{
context: Context,
user: User,
layoutMode: LayoutMode,
visible?: boolean,
style?: React.CSSProperties
}> = ({ context, user, layoutMode, visible, style }) => {
if (visible === false) return <></>;
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.background,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<div style={style} className={layoutMode === "right" ? "flex-rtl" : "flex-ltr"}>
<span style={{color: textColorOnBoard, transition: 'color 0.5s'}} className='text'>{user.isAnonymous ? context.texts.Anonymous : user.name}</span>
<Space width='5px' />
<div className={"circle " + (user.isOnline ? "online" : "offline")}></div>
<style jsx>{`
.online {
background-color: ${context.themeManager.theme.boardColor};
}
.offline {
background-color: transparent;
}
.circle {
width: 15px;
height: 15px;
min-width: 15px;
min-height: 15px;
border-radius: 15px;
border: 2px solid ${context.themeManager.theme.boardColor};
transition: background-color 0.5s;
transition: color 0.5s;
}
.flex-rtl {
display: flex;
flex-direction: row-reverse;
align-items: center;
}
.flex-ltr {
display: flex;
flex-direction: row;
align-items: center;
}
.text {
font-weight: bold;
text-overflow: ellipsis;
overflow: hidden;
}
.icon {
color : "grey";
width: 32px;
height: 32px;
font-size: 32px;
}
`}</style>
</div>
);
}
export default UserStatus;

View File

@ -0,0 +1,22 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const BoardToolbar: FunctionComponent<{ visible?: boolean, style?: React.CSSProperties }> = ({ children, visible, style }) => {
if(visible === false) return <></>;
return (
<div style={style} className='toolbar'>
{children}
<style jsx>{`
.toolbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
align-self: stretch;
}
`}</style>
</div>
);
}
export default BoardToolbar;

View File

@ -0,0 +1,63 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import BoardViewModel from "../../viewmodel/BoardViewModel";
import PitViewModel from "../../viewmodel/PitViewModel";
import PitView from "./PitView";
import StoreView from "./StoreView";
import { Game } from "../../models/Game";
import { Pit } from "mancala.js";
const BoardView: FunctionComponent<{
game: Game;
context: Context;
boardId: string;
boardViewModel: BoardViewModel;
revert: boolean;
onPitSelect: (index: number, pit: Pit) => void;
}> = ({ game, context, boardId, boardViewModel, revert, onPitSelect: onPitSelect }) => {
const mancalaGame = game?.mancalaGame;
const theme = context.themeManager.theme;
const createPitView = (key: any, pit: Pit, pitViewModel: PitViewModel) => {
return <PitView key={key} pitViewModel={pitViewModel} onClick={() => onPitSelect(pit.index, pit)} />;
};
const createPitViewList = (pits: Pit[]) => pits.map((pit, index) => createPitView(index, pit, boardViewModel.pits[pit.index]));
const player1Pits = createPitViewList(mancalaGame?.board.player1Pits);
const player2Pits = createPitViewList(mancalaGame?.board.player2Pits);
const player1BankIndex = mancalaGame?.board.player1BankIndex();
const player2BankIndex = mancalaGame?.board.player2BankIndex();
const player1BankViewModel = boardViewModel.pits[player1BankIndex];
const player2BankViewModel = boardViewModel.pits[player2BankIndex];
return (
<div className="board" style={{ background: theme.boardColor }}>
<StoreView
context={context}
pitViewModel={revert ? player2BankViewModel : player1BankViewModel}
gridColumn="8 / 9"
gridRow="1 / 3"
/>
<StoreView
context={context}
pitViewModel={revert ? player1BankViewModel : player2BankViewModel}
gridColumn="1 / 2"
gridRow="1 / 3"
/>
{revert ? player1Pits?.reverse() : player2Pits?.reverse()}
{revert ? player2Pits : player1Pits}
<style jsx>{`
.board {
padding: 2vw;
display: grid;
grid-template-columns: repeat(8, 11vw);
grid-template-rows: repeat(2, 11vw);
border-radius: 3vw;
transition: background-color 0.5s;
}
`}</style>
</div>
);
};
export default BoardView;

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { FunctionComponent } from "react";
import Util from "../../util/Util";
import PitViewModel from "../../viewmodel/PitViewModel";
import StoneView from "./StoneView";
const PitView: FunctionComponent<{
pitViewModel: PitViewModel;
onClick: () => void;
}> = ({ pitViewModel, onClick }) => {
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
<StoneView key={index} color={pitViewModel.stoneColor} />
));
return (
<div className="pit" onClick={onClick} style={{ background: pitViewModel.pitColor }}>
{stones}
<style jsx>{`
.pit {
margin: 5px;
padding: 5px;
border-radius: 10vw;
transition: background-color 0.5s;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
flex-wrap: wrap;
}
`}</style>
</div>
);
};
export default PitView;

View File

@ -0,0 +1,20 @@
import * as React from "react";
import { FunctionComponent } from "react";
const StoneView: FunctionComponent<{ color: string }> = ({ color }) => {
return (
<div className="stone" style={{ background: color }}>
<style jsx>{`
.stone {
margin: 1px;
width: 1vw;
height: 1vw;
border-radius: 10vw;
transition: background-color 0.5s;
}
`}</style>
</div>
);
};
export default StoneView;

View File

@ -0,0 +1,59 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import { getColorByBrightness } from "../../util/ColorUtil";
import Util from "../../util/Util";
import PitViewModel from "../../viewmodel/PitViewModel";
import StoneView from "./StoneView";
const StoreView: FunctionComponent<{
context: Context;
pitViewModel: PitViewModel;
gridColumn: string;
gridRow: string;
}> = ({ context, pitViewModel, gridColumn, gridRow }) => {
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
<StoneView key={index} color={pitViewModel.stoneColor} />
));
const textColor = getColorByBrightness(
pitViewModel.pitColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<div
className="store"
style={{
background: pitViewModel.pitColor,
gridColumn: gridColumn,
gridRow: gridRow
}}>
{stones}
<span className="store-stone-count-text" style={{ color: textColor, transition: 'color 0.5s' }}>
{stones.length}
</span>
<style jsx>{`
.store {
margin: 5px;
border-radius: 10vw;
display: flex;
align-items: center;
justify-content: center;
align-content: center;
flex-wrap: wrap;
position: relative;
transition: background-color 0.5s;
}
.store-stone-count-text {
position: absolute;
bottom: 2vw;
font-family: monospace;
font-weight: bold;
font-size: 2vw;
}
`}</style>
</div>
);
};
export default StoreView;

View File

@ -0,0 +1,21 @@
import * as React from "react";
import { FunctionComponent } from "react";;
const HeaderBar: FunctionComponent<{ color?: string }> = ({children, color }) => {
return (
<div style={{ background: color }} className="header-bar">
{children}
<style jsx>{`
.header-bar {
padding: 0px 4vw;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
align-self: stretch;
transition: background-color 0.5s;
}
`}</style>
</div>)
}
export default HeaderBar;

View File

@ -0,0 +1,22 @@
import * as React from "react";
import { FunctionComponent } from "react";
//@ts-ignore
import MancalaIcon from "jsx:../../mancala.svg";
const HeaderbarIcon: FunctionComponent = () => {
return (
<div className="header-bar-icon-wrapper">
<MancalaIcon style={{ height: "30px", width: "30px" }} />
<style jsx>{`
.header-bar-icon-wrapper {
margin-right: 10px;
display: flex;
flex-direction: row;
align-items: center;
}
`}</style>
</div>
);
}
export default HeaderbarIcon;

View File

@ -0,0 +1,18 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
import { Context } from '../../context/context';
const HeaderbarTitle: FunctionComponent<{ title: string, color: string }> = ({ title, color }) => {
return (
<h1 style={{ color: color, transition: 'color 0.5s' }} className="header-bar-title">
{title}
<style jsx>{`
.header-bar-title {
margin: 10px 0px;
}
`}</style>
</h1>
);
}
export default HeaderbarTitle;

View File

@ -0,0 +1,64 @@
import { Menu, MenuItem } from "@szhsin/react-menu";
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css"
const ThemeSwitchMenu: FunctionComponent<{ context: Context, textColor: string }> = (props) => {
const { context, textColor } = props;
const menuButton = <span
style={{ color: textColor, cursor: 'pointer', userSelect: 'none' }}
className="material-symbols-outlined">
light_mode
</span>;
const menuItems = context.themeManager.themes.map((theme, index) => {
return (
<MenuItem
key={index}
style={{ color: textColor }}
//@ts-ignore
onMouseOver={(event) => {
const htmlElement: HTMLElement = event.target as HTMLElement;
if (htmlElement.localName === "li") htmlElement.style.background = "transparent";
}}
//@ts-ignore
onMouseOut={(event) => {
const htmlElement: HTMLElement = event.target as HTMLElement;
if (htmlElement.localName === "li") htmlElement.style.background = "transparent";
}}
onClick={() => (context.themeManager.theme = theme)}>
<div style={{ background: theme.themePreviewColor }} className="theme-color-circle" />
{theme.name}
<style jsx>{`
.theme-color-circle {
border-radius: 5vw;
width: 1vw;
height: 1vw;
margin-right: 1vw;
}
`}</style>
</MenuItem>
);
})
return (
<div className="menu-container">
<Menu
menuStyle={{ background: context.themeManager.theme.appBarBgColor }}
menuButton={menuButton}
transition
align="end">
{menuItems}
</Menu>
<style jsx>{`
.menu-container {
margin: 0 1vh;
display: flex;
align-items: center;
}
`}</style>
</div>
);
}
export default ThemeSwitchMenu;

View File

@ -0,0 +1,13 @@
export const channel_new_game = "new_game"
export const channel_on_game_start = "on_game_start"
export const channel_game_move = "game_move"
export const channel_on_game_update = "on_game_update"
export const channel_leave_game = "leave_game"
export const channel_on_game_end = "on_game_end"
export const channel_on_game_crashed = "on_game_crashed"
export const channel_on_game_user_leave = "on_game_user_leave"
export const channel_ping = "ping"
export const channel_pong = "pong"
export const channel_on_user_connection_change = "channel_on_user_connection_change"
export const channel_listen_game_events = "channel_listen_game_events"
export const channel_unlisten_game_events = "channel_unlisten_game_events"

View File

@ -0,0 +1,20 @@
export const useLocalServer = false;
export const isAlpha = true;
export type Server = {
serverAdress: string;
wsServerAdress: string;
};
export const server: Server = useLocalServer ? {
serverAdress: "http://localhost:5000",
wsServerAdress: "ws://localhost:5000",
} : isAlpha ? {
serverAdress: "https://segin.one/mancala-backend-alpha",
wsServerAdress: "wss://segin.one/mancala-backend-alpha/",
} : {
serverAdress: "https://segin.one/mancala-backend-beta",
wsServerAdress: "wss://segin.one/mancala-backend-beta/",
};
export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000;

114
frontend/src/const/texts.ts Normal file
View File

@ -0,0 +1,114 @@
export type Texts = {
Mancala: string,
Leave: string,
NewGame: string,
YourTurn: string,
OpponentTurn: string,
GameEnded: string,
InternalErrorOccurred: string,
YouWon: string,
Won: string,
YouLost: string,
Connecting: string,
Connected: string,
CannotConnect: string,
ConnectionLost: string,
ConnectingAgain: string,
ServerError: string,
SearchingOpponet: string,
OpponentLeftTheGame: string,
YouLeftTheGame: string,
UserLeftTheGame: string,
SearchingOpponent: string,
PleaseWait: string,
GameDraw: string,
Anonymous: string,
GameNotFound: string,
Loading: string,
Playing: string,
Error: string,
ErrorWhenRetrievingInformation: string,
UCanOnlyPlayYourOwnPits: string,
UMustWaitUntilCurrentMoveComplete: string,
UCanNotPlayEmptyPit: string,
AreYouSureToLeaveGame: string,
Yes: string,
Cancel: string,
}
export const EnUs: Texts = {
Mancala: "Mancala",
Leave: "Leave The Game",
NewGame: "New Game",
YourTurn: "Your Turn",
OpponentTurn: "Opponent Turn",
GameEnded: "Game Ended",
InternalErrorOccurred: "An internal error has occurred",
YouWon: "You Won",
Won: "Won",
YouLost: "You Lost",
Connecting: "Connecting",
Connected: "Connected",
CannotConnect: "Can't Connect",
ConnectionLost: "Network Connection Lost",
ConnectingAgain: "Connecting Again",
ServerError: "Server Error",
SearchingOpponet: "Searching Opponet",
OpponentLeftTheGame: "Opponent Leaves The Game",
YouLeftTheGame: "You Left The Game",
UserLeftTheGame: "Left The Game",
SearchingOpponent: "Searching Opponent",
PleaseWait: "Please Wait",
GameDraw: "Draw",
Anonymous: "Anonymous",
GameNotFound: "Game Not Found",
Loading: "Loading",
Playing: "Playing",
Error: "Error",
ErrorWhenRetrievingInformation: "An error occured when retrieving information!",
UCanOnlyPlayYourOwnPits: "You can only play your own pits",
UMustWaitUntilCurrentMoveComplete: "You must wait until the current move is complete",
UCanNotPlayEmptyPit: "You can not play empty pit",
AreYouSureToLeaveGame: "Are you sure to leave game?",
Yes: "Yes",
Cancel: "Cancel",
}
export const TrTr: Texts = {
Mancala: "Köçürme",
Leave: "Oyundan Ayrıl",
NewGame: "Yeni Oyun",
YourTurn: "Sıra Sende",
OpponentTurn: "Sıra Rakipte",
GameEnded: "Oyun Bitti",
InternalErrorOccurred: "Dahili bir hata oluştu",
YouWon: "Kazandın",
Won: "Kazandı",
YouLost: "Kaybettin",
Connecting: "Bağlanılıyor",
Connected: "Bağlandı",
CannotConnect: "Bağlanılamadı",
ConnectionLost: "Ağ Bağlantısı Koptu",
ConnectingAgain: "Tekrar Bağlanılıyor",
ServerError: "Sunucu Hatası",
SearchingOpponet: "Rakip Aranıyor",
OpponentLeftTheGame: "Rakip Oyundan Ayrıldı",
YouLeftTheGame: "Sen Oyundan Ayrıldın",
UserLeftTheGame: "Oyundan Ayrıldı",
SearchingOpponent: "Rakip Aranıyor",
PleaseWait: "Lütfen Bekleyin",
GameDraw: "Berabere",
Anonymous: "Anonim",
GameNotFound: "Oyun Bulunamadı",
Loading: "Yükleniyor",
Playing: "Oynuyor",
Error: "Hata",
ErrorWhenRetrievingInformation: "Bilgiler toplanırken bir hata oluştu!",
UCanOnlyPlayYourOwnPits: "Sadece sana ait olan kuyular ile oynayabilirsin",
UMustWaitUntilCurrentMoveComplete: "Devam eden haraketin bitmesini beklemelisin",
UCanNotPlayEmptyPit: "Boş kuyu ile oynayamazsın",
AreYouSureToLeaveGame: "Oyundan ayrılmak istediğine emin misin?",
Yes: "Evet",
Cancel: "İptal"
}

View File

@ -0,0 +1,32 @@
import { server } from "../const/config";
import { Texts, TrTr } from "../const/texts";
import { RTMT } from "../rtmt/rtmt";
import { RTMTWS } from "../rtmt/rtmt_websocket";
import { HttpServiceImpl } from "../service/HttpService";
import { GameStore, GameStoreImpl } from "../store/GameStore";
import { UserKeyStore, UserKeyStoreImpl } from "../store/KeyStore";
import ThemeManager from "../theme/ThemeManager";
export type Context = {
rtmt: RTMT;
userKeyStore: UserKeyStore;
texts: Texts;
themeManager: ThemeManager;
gameStore: GameStore;
};
export const initContext = (): Context => {
const rtmt = new RTMTWS();
const httpService = new HttpServiceImpl(server.serverAdress);
const userKeyStore = new UserKeyStoreImpl({ httpService });
const gameStore = new GameStoreImpl({ httpService });
const texts = TrTr;
const themeManager = new ThemeManager();
return {
rtmt: rtmt,
userKeyStore: userKeyStore,
texts: texts,
themeManager,
gameStore
};
};

View File

@ -0,0 +1,11 @@
import BoardViewModel from "../viewmodel/BoardViewModel";
import PitViewModel from "../viewmodel/PitViewModel";
export default class BoardViewModelFactory {
public static create(
id: string,
pitViewModels: PitViewModel[]
): BoardViewModel {
return new BoardViewModel(id, pitViewModels);
}
}

View File

@ -0,0 +1,13 @@
import PitViewModel from "../viewmodel/PitViewModel";
export class PitViewModelFactory {
public static create(params: {
id: string;
stoneCount: number;
stoneColor: string;
pitColor: string;
}): PitViewModel {
const { id, stoneCount, stoneColor, pitColor } = params;
return new PitViewModel(id, stoneCount, stoneColor, pitColor);
}
}

View File

@ -0,0 +1,26 @@
import { useState, useEffect } from 'react';
//https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs
function getWindowDimensions() {
const { innerWidth: width, innerHeight: height } = window;
return {
width,
height
};
}
export default function useWindowDimensions() {
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
useEffect(() => {
function handleResize() {
setWindowDimensions(getWindowDimensions());
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowDimensions;
}

31
frontend/src/index.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Mancala</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="mancala.svg">
</head>
<body style="margin: 0px;">
<div id="main" style="display: flex;"></div>
<script type="module" src="./App.tsx"></script>
</body>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@48,400,0,0" />
<style>
html{
height: 100%;
}
body{
height: 100%;
}
#main{
height: 100%;
}
</style>
</html>

76
frontend/src/mancala.svg Normal file
View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100.00002mm"
height="100.00001mm"
viewBox="0 0 100.00002 100.00001"
version="1.1"
id="svg316"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview318"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false" />
<defs
id="defs313" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-230.50606,-73.754324)">
<rect
style="fill:#b3b3b3;stroke-width:1.03151"
id="rect489-5-7-8"
width="100"
height="100"
x="230.50607"
y="73.754326"
ry="27.328773"
inkscape:export-filename="mancala.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<rect
style="fill:#666666;fill-opacity:1;stroke-width:0.747434"
id="rect642-6-2-0-7"
width="35"
height="35"
x="281.50607"
y="87.754311"
ry="17.5" />
<rect
style="fill:#b3b3b3;stroke-width:0.747434"
id="rect642-6-7-9-9-9"
width="35"
height="35"
x="244.50607"
y="87.754311"
ry="17.5" />
<rect
style="fill:#666666;fill-opacity:1;stroke:none;stroke-width:0.747434;stroke-opacity:1"
id="rect642-6-2-1-3-2"
width="35"
height="35"
x="281.50607"
y="124.75433"
ry="17.5" />
<rect
style="fill:#666666;fill-opacity:1;stroke-width:1.05044"
id="rect1550-0"
width="35"
height="70"
x="244.50607"
y="87.754311"
ry="17.5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,13 @@
import { MancalaGame } from "mancala.js";
import { UserConnectionInfo } from "./UserConnectionInfo";
export interface Game {
id: string;
mancalaGame: MancalaGame;
gameUsersConnectionInfo: GameUsersConnectionInfo;
}
export interface GameUsersConnectionInfo {
user1ConnectionInfo: UserConnectionInfo;
user2ConnectionInfo: UserConnectionInfo;
}

View File

@ -0,0 +1,3 @@
export interface GameMove {
index: number;
}

View File

@ -0,0 +1,46 @@
export type LoadingStateType = "unset" | "loading" | "loaded" | "error";
export class LoadingState<T> {
state: LoadingStateType;
errorMessage?: string;
value?: T;
constructor(props: { state?: LoadingStateType, errorMessage?: string, value?: T }) {
this.state = props.state ? props.state : "unset";
this.errorMessage = props.errorMessage;
this.value = props.value;
}
public static Unset<T>() {
return new LoadingState<T>({ state: "unset" });
}
public static Loading<T>() {
return new LoadingState<T>({ state: "loading" });
}
public static Error<T>(props: { errorMessage: string }) {
const { errorMessage } = props;
return new LoadingState<T>({ state: "error", errorMessage });
}
public static Loaded<T>(props: { value?: T }) {
const { value } = props;
return new LoadingState<T>({ state: "loaded", value });
}
public isUnset() : boolean {
return this.state === "unset";
}
public isLoading() : boolean {
return this.state === "loading";
}
public isError() : boolean {
return this.state === "error";
}
public isLoaded() : boolean {
return this.state === "loaded";
}
}

View File

@ -0,0 +1,6 @@
export interface User {
id: string;
name: string;
isOnline: boolean;
isAnonymous: boolean;
}

View File

@ -0,0 +1,4 @@
export interface UserConnectionInfo {
userId: string;
isOnline: boolean;
}

View File

@ -0,0 +1,329 @@
import { MancalaGame, Pit } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { v4 } from 'uuid';
import PitAnimator from '../animation/PitAnimator';
import BoardToolbar from '../components/board/BoardToolbar';
import BoardView from '../components/board/BoardView';
import Button from '../components/Button';
import HeaderBar from '../components/headerbar/HeaderBar';
import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import InfoPanel from '../components/InfoPanel';
import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer';
import Row from '../components/Row';
import UserStatus from '../components/UserStatus';
import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events } from '../const/channel_names';
import { Context } from '../context/context';
import useWindowDimensions from '../hooks/useWindowDimensions';
import { GameMove } from '../models/GameMove';
import { LoadingState } from '../models/LoadingState';
import { Theme } from '../theme/Theme';
import { getColorByBrightness } from '../util/ColorUtil';
import BoardViewModel from '../viewmodel/BoardViewModel';
import Center from '../components/Center';
import { Game, GameUsersConnectionInfo } from '../models/Game';
import notyf from '../util/Notyf';
import swal from 'sweetalert';
import Util from '../util/Util';
const GamePage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme,
}> = ({ context, userKey, theme }) => {
let params = useParams<{ gameId: string }>();
const [game, setGame] = useState<Game | undefined>(undefined);
const [userKeyWhoLeave, setUserKeyWhoLeave] = useState<string | undefined>(undefined);
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel | undefined>(undefined);
const [boardId, setBoardId] = useState<string>("-1");
const [pitAnimator, setPitAnimator] = useState<PitAnimator | undefined>(undefined);
// It is a flag for ongoing action such as send game move.
// We have to block future actions if there is an ongoing action.
const [hasOngoingAction, setHasOngoingAction] = useState<boolean>(false);
const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState<GameUsersConnectionInfo | undefined>();
const { height, width } = useWindowDimensions();
const navigate = useNavigate();
const [gameLoadingState, setLoadingStateGame] = useState<LoadingState<Game>>(LoadingState.Unset());
const checkIsSpectator = (game: Game) => userKey !== game.mancalaGame.player1Id && userKey !== game.mancalaGame.player2Id;
const mancalaGame: MancalaGame | undefined = game?.mancalaGame;
const isSpectator = game ? checkIsSpectator(game) : undefined;
const isPlayer2 = !isSpectator && userKey === mancalaGame?.player2Id;
const onGameUpdate = (pitAnimator: PitAnimator, newGame: Game) => {
setGame(newGame);
pitAnimator.setUpdatedGame(newGame);
setHasOngoingAction(false);
setGameUsersConnectionInfo(newGame.gameUsersConnectionInfo);
}
const isUserOnline = (userId: string) => {
if (!gameUsersConnectionInfo) return false;
const user1ConnectionInfo = gameUsersConnectionInfo.user1ConnectionInfo;
const user2ConnectionInfo = gameUsersConnectionInfo.user2ConnectionInfo;
if (user1ConnectionInfo.userId === userId) return user1ConnectionInfo.isOnline;
if (user2ConnectionInfo.userId === userId) return user2ConnectionInfo.isOnline;
return false;
}
const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => {
const newGame: Game = message as Game;
newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame);
onGameUpdate(pitAnimator, newGame);
}
const onGameCrashed = (message: any) => {
const newCrashMessage = message as string;
notyf.error(context.texts.InternalErrorOccurred);
console.error("on_game_crash");
console.error(newCrashMessage);
}
const onGameUserLeave = (message: any) => {
const userKeyWhoLeave = message;
setUserKeyWhoLeave(userKeyWhoLeave);
setHasOngoingAction(false);
};
const onUserConnectionChange = (message: any) => {
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
setGameUsersConnectionInfo(gameUsersConnectionInfo);
};
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message);
context.rtmt.addMessageListener(channel_on_game_update, _onGameUpdate);
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id);
return () => {
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_unlisten_game_events, game.id);
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
context.rtmt.removeMessageListener(channel_on_user_connection_change, onUserConnectionChange);
}
};
const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
boardViewModel.id = v4();
setBoardId(boardViewModel.id);
setBoardViewModel(boardViewModel);
};
const getBoardIndex = (index: number) => {
if (!game || !mancalaGame) return -1;
const pitsLenght = mancalaGame.board.pits.length;
if (userKey === mancalaGame.player2Id) return index + pitsLenght / 2;
return index;
};
const getOpponentId = () => mancalaGame?.player1Id === userKey ? mancalaGame?.player2Id : mancalaGame?.player1Id;
const checkHasAnOngoingAction = () => hasOngoingAction;
const onLeaveGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context)) return;
swal({
title: context.texts.AreYouSureToLeaveGame,
icon: "warning",
buttons: [context.texts.Yes, context.texts.Cancel],
dangerMode: true,
})
.then((cancel) => {
if (!cancel) {
context.rtmt.sendMessage(channel_leave_game, {});
}
});
};
const onNewGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context)) return;
navigate("/loby")
};
const onPitSelect = (index: number, pit: Pit) => {
if (!game || isSpectator || !userKey) {
return;
}
if(userKeyWhoLeave) {
notyf.error(context.texts.GameEnded);
return;
}
if(game.mancalaGame.state === "ended") {
notyf.error(context.texts.GameEnded);
return;
}
if (Util.checkConnectionAndMaybeAlert(context)) return;
if (game.mancalaGame.getPlayerIdByIndex(index) !== userKey) {
notyf.error(context.texts.UCanOnlyPlayYourOwnPits);
return;
}
const pitIndexForUser = index % (game.mancalaGame.board.totalPitCount() / 2);
if (!game.mancalaGame.canPlayerMove(userKey, pitIndexForUser)) {
notyf.error(context.texts.OpponentTurn);
return;
}
if (checkHasAnOngoingAction()) {
notyf.error(context.texts.UMustWaitUntilCurrentMoveComplete);
return;
}
if (!boardViewModel) return;
//TODO: this check should be in mancala.js
if (pit.stoneCount === 0) {
notyf.error(context.texts.UCanNotPlayEmptyPit);
return;
}
setHasOngoingAction(true);
boardViewModel.pits[getBoardIndex(pitIndexForUser)].pitColor =
context.themeManager.theme.pitSelectedColor;
updateBoardViewModel(boardViewModel);
const gameMove: GameMove = { index: pitIndexForUser };
context.rtmt.sendMessage(channel_game_move, gameMove);
};
React.useEffect(() => {
let pitAnimator: PitAnimator | undefined;
let unlistenMessages: () => void;
setLoadingStateGame(LoadingState.Loading())
context.gameStore.get(params.gameId!!).then((game) => {
if (game) {
pitAnimator = new PitAnimator(context, updateBoardViewModel);
setPitAnimator(pitAnimator);
onGameUpdate(pitAnimator, game);
unlistenMessages = listenMessages(game, pitAnimator);
setLoadingStateGame(LoadingState.Loaded({ value: game }))
} else {
setLoadingStateGame(LoadingState.Error({ errorMessage: context.texts.GameNotFound }))
}
})
return () => {
unlistenMessages?.();
pitAnimator?.dispose();
};
}, []);
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const isMobile = width < 600;
const renderNewGameBtn = isSpectator || (userKeyWhoLeave || !game || (game && game.mancalaGame.state == "ended"));
const showBoardView = game && boardViewModel && userKey && true;
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
const topLocatedUser = {
id: topLocatedUserId,
name: "Anonymous",
isOnline: isUserOnline(topLocatedUserId),
isAnonymous: true
};
const bottomLocatedUser = {
id: bottomLocatedUserId,
name: "Anonymous",
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
isAnonymous: true
};
const currentUser = isSpectator ? {
id: "2",
name: "Anonymous",
isOnline: context.rtmt.connectionState === "connected",
isAnonymous: true
} : bottomLocatedUser;
const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined;
return (
<PageContainer theme={theme!}>
{renderHeaderBar()}
{renderMobileBoardToolbar()}
{buildBoardTopToolbar()}
{showBoardView && (
<BoardView
game={game}
boardId={boardId}
boardViewModel={boardViewModel}
context={context}
onPitSelect={onPitSelect}
revert={isPlayer2} />
)}
<Center>
<LoadingComponent context={context} loadingState={gameLoadingState}></LoadingComponent>
</Center>
</PageContainer>
);
function renderHeaderBar() {
return <HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={context.texts.Mancala} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<Button
context={context}
color={context.themeManager.theme.pitColor}
text={renderNewGameBtn ? context.texts.NewGame : context.texts.Leave}
onClick={renderNewGameBtn ? onNewGameClick : onLeaveGameClick} />
</Row>
</HeaderBar>;
}
function renderMobileBoardToolbar() {
return <BoardToolbar style={{ justifyContent: "center" }} visible={showBoardView && isMobile || false}>
{buildInfoPanel({ visible: isMobile })}
</BoardToolbar>;
}
function buildBoardTopToolbar() {
return <BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
<UserStatus style={{
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false} />
{buildInfoPanel({ visible: !isMobile })}
<UserStatus style={{
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false} />
</BoardToolbar>;
}
function buildInfoPanel(params: { visible: boolean }) {
return (
<InfoPanel
style={{ marginTop: "0.5rem", marginBottom: "0.5rem" }}
context={context}
game={game}
currentUser={currentUser}
whitePlayer={topLocatedUser}
blackPlayer={bottomLocatedUser}
leftPlayer={leftPlayer}
visible={params.visible}
isSpectator={isSpectator} />
);
}
}
export default GamePage;

View File

@ -0,0 +1,58 @@
import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
import { getColorByBrightness } from "../util/ColorUtil";
import { Theme } from "../theme/Theme";
import HeaderBar from "../components/headerbar/HeaderBar";
import PageContainer from "../components/PageContainer";
import Row from "../components/Row";
import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
import Button from "../components/Button";
import { Context } from "../context/context";
import { Link, useNavigate } from "react-router-dom";
import Util from "../util/Util";
const Home: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme,
}> = ({ context, userKey, theme }) => {
const navigate = useNavigate();
const onNewGameClick = () => {
if(Util.checkConnectionAndMaybeAlert(context)) return;
navigate("/loby")
};
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<PageContainer theme={theme!}>
<HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={context.texts.Mancala} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<Button
context={context}
color={context.themeManager.theme.pitColor}
text={context.texts.NewGame}
onClick={onNewGameClick} />
</Row>
</HeaderBar>
</PageContainer>
);
};
export default Home;

View File

@ -0,0 +1,73 @@
import { CommonMancalaGame, MancalaGame } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Center from '../components/Center';
import CircularPanel from '../components/CircularPanel';
import HeaderBar from '../components/headerbar/HeaderBar';
import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import PageContainer from '../components/PageContainer';
import Row from '../components/Row';
import { channel_on_game_start } from '../const/channel_names';
import { Context } from '../context/context';
import { Theme } from '../theme/Theme';
import { getColorByBrightness } from '../util/ColorUtil';
const LobyPage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme
}> = ({ context, userKey, theme }) => {
let navigate = useNavigate();
const onGameStart = (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame;
navigate(`/game/${newGame.id}`)
}
useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.sendMessage("new_game", {});
return () => {
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
}
}, []);
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<PageContainer theme={theme!}>
<HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={context.texts.Mancala} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
</Row>
</HeaderBar>
<Center>
<CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${context.texts.SearchingOpponent} ${context.texts.PleaseWait}...`}</h4>
</CircularPanel>
</Center>
</PageContainer>
);
}
export default LobyPage;

View File

@ -0,0 +1,12 @@
import { Bytes } from "./rtmt"
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder("utf-8")
export function encodeText(text : string) {
const bytes = textEncoder.encode(text)
return bytes
}
export function decodeText(bytes : Bytes) {
return textDecoder.decode(bytes)
}

View File

@ -0,0 +1,20 @@
import { decodeText, encodeText } from "./byte_util";
import { Bytes } from "./rtmt";
const headerLenght = 4
//
// channel is string, message is byte array
//
export function encode(channel : string, message : Object) {
return JSON.stringify({
channel,
message
})
}
//
// return { channel : string, message : byte array}
//
export function decode(bytes : string) {
return JSON.parse(bytes);
}

18
frontend/src/rtmt/rtmt.ts Normal file
View File

@ -0,0 +1,18 @@
import EventEmitter2, { Listener } from "eventemitter2"
export type Bytes = Uint8Array
export type OnMessage = (message : Object) => any
export type ConnectionState = "none" | "connecting" | "error" | "connected" | "closed" | "reconnecting";
export type RtmtEventTypes = "open" | "close" | "connected" | "error" | "disconnected" | "message" | "connectionchange";
export interface RTMT extends EventEmitter2 {
get connectionState() : ConnectionState;
sendMessage: (channel: string, message: Object) => void;
addMessageListener(channel: string, callback: (message: any) => void);
removeMessageListener(channel: string, callback: (message: any) => void);
on(event: RtmtEventTypes, callback: (...value: any[]) => void): Listener | this;
off(event: RtmtEventTypes, callback: (...value: any[]) => void): this;
dispose();
}

View File

@ -0,0 +1,131 @@
import { decode, encode } from "./encode_decode_message";
import { channel_ping, channel_pong } from "../const/channel_names";
import { RTMT_WS_PING_INTERVAL, RTMT_WS_PING_INTERVAL_BUFFER_TIME, server } from "../const/config";
import EventEmitter2, { Listener } from "eventemitter2";
import { Bytes, ConnectionState, RTMT, RtmtEventTypes } from "./rtmt";
const MESSAGE_CHANNEL_PREFIX = "message_channel";
export class RTMTWS extends EventEmitter2 implements RTMT {
private webSocket?: WebSocket;
private pingTimeout?: number = undefined;
private _connectionState: ConnectionState = "none";
private userKey: string;
get connectionState(): ConnectionState {
return this._connectionState;
}
protected setConnectionState(connectionState: ConnectionState) {
this._connectionState = connectionState;
this.emit("connectionchange", this._connectionState);
}
private createWebSocket() {
const url = server.wsServerAdress + "?userKey=" + this.userKey;
const webSocket = new WebSocket(url);
webSocket.onopen = () => this.onWebSocketOpen(webSocket);
webSocket.onclose = () => this.onWebSocketClose(webSocket);
webSocket.onmessage = (event: MessageEvent) => this.onWebSocketMessage(webSocket, event);
webSocket.onerror = (error: any) => this.onWebSocketError(webSocket, error);
}
private disposeWebSocket() {
if (!this.webSocket) return;
this.webSocket.onopen = () => { };
this.webSocket.onclose = () => { };
this.webSocket.onmessage = (event: MessageEvent) => { };
this.webSocket.onerror = (error: any) => { };
this.webSocket = undefined;
}
public connectWebSocket(userKey: string) {
this.setConnectionState("connecting");
this.userKey = userKey;
this.createWebSocket();
}
private reconnectWebSocket() {
this.setConnectionState("reconnecting");
this.disposeWebSocket();
setTimeout(() => this.createWebSocket(), 1000);
}
protected onWebSocketOpen(webSocket: WebSocket) {
this.webSocket = webSocket;
this.setConnectionState("connected");
console.info("(RTMT) WebSocket has opened");
this.heartbeat();
this.emit("open");
}
protected onWebSocketMessage(webSocket: WebSocket, event: MessageEvent) {
const { channel, message } = decode(event.data);
this.onMessage(channel, message);
}
protected onWebSocketError(webSocket: WebSocket, error: any) {
this.setConnectionState("error");
console.error(error);
this.emit("error", error);
}
protected onWebSocketClose(webSocket: WebSocket) {
this.setConnectionState("closed");
console.info("(RTMT) WebSocket has closed");
//this.WebSocket = undefined
clearTimeout(this.pingTimeout);
this.emit("close");
this.reconnectWebSocket();
}
private heartbeat() {
clearTimeout(this.pingTimeout);
this.pingTimeout = setTimeout(() => {
if (!this.webSocket) return;
console.log("(RTMT) WebSocket self closed");
this.webSocket.close();
this.onWebSocketClose(this.webSocket);
}, RTMT_WS_PING_INTERVAL + RTMT_WS_PING_INTERVAL_BUFFER_TIME);
}
public sendMessage(channel: string, message: Object) {
if (this.webSocket === undefined) {
console.error("(RTMT) WebSocket is undefined");
return;
}
const data = encode(channel, message);
this.webSocket.send(data);
}
private onMessage(channel: string, message: Bytes) {
if (channel === channel_ping) {
this.heartbeat();
this.sendMessage(channel_pong, {});
return;
}
// TODO: Maybe we should warn if there is not any listener for channel
this.emit(MESSAGE_CHANNEL_PREFIX + channel, message);
}
public on(event: RtmtEventTypes, callback: (...value: any[]) => void): Listener | this {
return super.on(event, callback);
}
public off(event: RtmtEventTypes, callback: (...value: any[]) => void): this {
return super.off(event, callback);
}
public addMessageListener(channel: string, callback: (message: any) => void) {
super.on(MESSAGE_CHANNEL_PREFIX + channel, callback);
}
public removeMessageListener(channel: string, callback: (message: any) => void) {
super.off(MESSAGE_CHANNEL_PREFIX + channel, callback);
}
public dispose() {
this.disposeWebSocket();
this.removeAllListeners();
}
}

View File

@ -0,0 +1,23 @@
import { server } from "../const/config";
export interface HttpService {
get: (route: string) => Promise<Response>;
}
export class HttpServiceImpl implements HttpService {
public serverAdress: string;
constructor(serverAdress: string) {
this.serverAdress = serverAdress;
}
public async get(route: string): Promise<Response> {
const url = server.serverAdress + route;
const requestOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
};
const response = await fetch(url, requestOptions);
return response;
}
}

View File

@ -0,0 +1,28 @@
import { CommonMancalaGame, MancalaGame } from "mancala.js";
import { Game } from "../models/Game";
import { HttpService } from "../service/HttpService";
export interface GameStore {
get(id: string): Promise<Game | undefined>;
}
export class GameStoreImpl implements GameStore {
httpService: HttpService;
constructor(props: { httpService: HttpService }) {
this.httpService = props.httpService;
}
async get(id: string): Promise<Game | undefined> {
try {
const response = await this.httpService.get(`/game/${id}`);
const json = await response.json();
const game: Game = json as Game;
game.mancalaGame = MancalaGame.createFromMancalaGame(game.mancalaGame);
return game;
} catch (error) {
// todo check error
Promise.resolve(undefined);
}
}
}

View File

@ -0,0 +1,53 @@
import { HttpService } from "../service/HttpService"
const user_key = "user_key"
export interface UserKeyStore {
getUserKey: () => Promise<string>;
}
export class UserKeyStoreImpl implements UserKeyStore {
private httpService: HttpService;
private keyStoreHttp: UserKeyStore;
private keyStoreLocalStorage = new UserKeyStoreLocalStorage()
constructor(props: { httpService: HttpService }) {
this.httpService = props.httpService;
this.keyStoreHttp = new UserKeyStoreLocalHttp({ httpService: this.httpService });
}
public async getUserKey(): Promise<string> {
const maybeUserKey = await this.keyStoreLocalStorage.getUserKey();
if (maybeUserKey === undefined) {
const userKey = await this.keyStoreHttp.getUserKey()
this.keyStoreLocalStorage.storeUserKey(userKey)
return Promise.resolve(userKey);
} else {
return Promise.resolve(maybeUserKey);
}
}
}
export class UserKeyStoreLocalHttp implements UserKeyStore {
httpService: HttpService;
constructor(params: { httpService: HttpService }) {
this.httpService = params.httpService;
}
public async getUserKey(): Promise<string> {
const response = await this.httpService.get("/register/")
return response.text();
}
}
export class UserKeyStoreLocalStorage {
public getUserKey(): Promise<string | undefined> {
const userKey = localStorage.getItem(user_key)
return Promise.resolve(userKey === null ? undefined : userKey)
}
public storeUserKey(userKey: string): void {
localStorage.setItem(user_key, userKey)
}
}

View File

@ -0,0 +1,33 @@
import { Theme } from "./Theme";
// https://colorhunt.co/palette/525252414141313131ec625f
const colors = {
primary: "#414141",
secondary: "#313131",
tertiary: "#606060",
quaternary: "#808080",
};
const colorSpecial = "#337a44";
const darkTheme: Theme = {
id: "2",
name: "Dark Theme",
themePreviewColor: colors.primary,
background: colors.primary,
appBarBgColor: colors.secondary,
textColor: colors.primary,
textLightColor: "#AAAAAA",
playerTurnColor: colors.tertiary,
boardColor: colors.secondary,
pitColor: colors.tertiary,
pitSelectedColor: colors.secondary,
stoneColor: "#252525",
stoneLightColor: "#252525",
pitGameMoveAnimateColor: colors.quaternary,
pitEmptyPitAnimateColor: colorSpecial,
pitLastStoneInBankPitAnimateColor: colorSpecial,
pitGetRivalStonePitAnimateColor: colorSpecial,
};
export default darkTheme;

View File

@ -0,0 +1,23 @@
import { Theme } from "./Theme";
const greyTheme: Theme = {
id: "1",
name: "Grey Theme",
themePreviewColor: "#4D606E",
background: "#EEEEEE",
appBarBgColor: "#e4e4e4",
textColor: "#4D606E",
textLightColor: "#EEEEEE",
playerTurnColor: "#84b8a6",
boardColor: "#4D606E",
pitColor: "#D3D4D8",
pitSelectedColor: "#8837fa",
stoneColor: "#393E46",
stoneLightColor: "#EEEEEE",
pitGameMoveAnimateColor: "#c9b43c",
pitEmptyPitAnimateColor: "#5d7322",
pitLastStoneInBankPitAnimateColor: "#9463f7",
pitGetRivalStonePitAnimateColor: "#ff3d44",
};
export default greyTheme;

View File

@ -0,0 +1,25 @@
import { Theme } from "./Theme";
const colorSpecial = "#8B8B8B";
const lightTheme: Theme = {
id: "1",
name: "Light Theme",
themePreviewColor: "#9B9B9B",
background: "#BBBBBB",
appBarBgColor: "#7B7B7B",
textColor: "#5B5B5B",
textLightColor: "#EBEBEB",
playerTurnColor: "#6B6B6B",
boardColor: "#9B9B9B",
pitColor: "#B8B8B8",
pitSelectedColor: "#9B9B9B",
stoneColor: "#5B5B5B",
stoneLightColor: "#3B3B3B",
pitGameMoveAnimateColor: "#ABABAB",
pitEmptyPitAnimateColor: colorSpecial,
pitLastStoneInBankPitAnimateColor: colorSpecial,
pitGetRivalStonePitAnimateColor: colorSpecial,
};
export default lightTheme;

View File

@ -0,0 +1,19 @@
export type Theme = {
id: string;
name: string;
themePreviewColor: string; // for theme switch menu
textColor: string;
textLightColor: string;
background: string;
appBarBgColor: string;
playerTurnColor: string;
boardColor: string;
pitColor: string;
pitSelectedColor: string;
stoneColor: string;
stoneLightColor: string;
pitGameMoveAnimateColor: string;
pitEmptyPitAnimateColor: string;
pitLastStoneInBankPitAnimateColor: string;
pitGetRivalStonePitAnimateColor: string;
};

View File

@ -0,0 +1,50 @@
import lightTheme from "./LightTheme";
import greyTheme from "./GreyTheme";
import { Theme } from "./Theme";
import darkTheme from "./DarkTheme";
import EventEmitter2, { Listener } from "eventemitter2";
export const themes = [lightTheme, darkTheme, greyTheme];
const THEME_ID = "theme_id";
export type ThemeManagerEvents = "themechange";
export default class ThemeManager extends EventEmitter2 {
private _theme: Theme;
constructor() {
super();
this._theme = this.readFromLocalStorage() || lightTheme;
}
public get theme() {
return this._theme;
}
public set theme(value: Theme) {
this._theme = value;
this.emit("themechange", value);
this.writetToLocalStorage(value);
}
private writetToLocalStorage(value: Theme) {
localStorage.setItem(THEME_ID, value.id);
}
private readFromLocalStorage(): Theme | undefined {
const themeID = localStorage.getItem(THEME_ID);
const theme = themes.find((eachTheme: Theme) => themeID === eachTheme.id);
return theme;
}
public get themes(): Theme[] {
return themes;
}
public on(event: ThemeManagerEvents, callback: (...value: any[]) => void): Listener | this {
return super.on(event, callback);
}
public off(event: ThemeManagerEvents, callback: (...value: any[]) => void): this {
return super.off(event, callback);
}
}

8
frontend/src/types/custom.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import 'react';
declare module 'react' {
interface StyleHTMLAttributes<T> extends React.HTMLAttributes<T> {
jsx?: boolean;
global?: boolean;
}
}

View File

@ -0,0 +1,31 @@
export function getBrightness(r: number, g: number, b: number) {
return (r * 299 + g * 587 + b * 114) / 1000;
}
export function getBrightnessFromHexColor(hexColor: string): number {
const [r, g, b] = hexToRgb(hexColor);
return getBrightness(r, g, b);
}
// from https://www.codegrepper.com/code-examples/javascript/javascript+convert+color+string+to+rgb
export function rgbToHex(r: number, g: number, b: number): string {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
// from https://www.codegrepper.com/code-examples/javascript/javascript+convert+color+string+to+rgb
export function hexToRgb(
hex: string,
result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
): number[] {
return result ? result.map((i) => parseInt(i, 16)).slice(1) : null;
//returns [23, 14, 45] -> reformat if needed
}
export function getColorByBrightness(
color: string,
lightColor: string,
darkColor: string
): string {
return getBrightnessFromHexColor(color) < 125 ? darkColor : lightColor;
}

View File

@ -0,0 +1,4 @@
import { Notyf } from 'notyf';
import 'notyf/notyf.min.css';
const notyf = new Notyf();
export default notyf;

32
frontend/src/util/Util.ts Normal file
View File

@ -0,0 +1,32 @@
import { Context } from "../context/context";
import { ConnectionState } from "../rtmt/rtmt";
import notyf from "./Notyf";
export default class Util {
public static range(size: number) {
var ans: number[] = [];
for (let i = 0; i < size; i++) {
ans.push(i);
}
return ans;
}
public static checkConnectionAndMaybeAlert(context: Context): boolean {
if (context.rtmt.connectionState !== "connected") {
notyf.error(context.texts.ConnectionLost);
return true;
}
return false;
}
public static getTextByConnectionState(context: Context, connectionState: ConnectionState): string {
const map: { [key: string]: string } = {
connecting: context.texts.Connecting,
connected: context.texts.Connected,
error: context.texts.CannotConnect,
closed: context.texts.ConnectingAgain,
reconnecting: context.texts.ConnectingAgain,
};
return map[connectionState];
}
}

View File

@ -0,0 +1,10 @@
import PitViewModel from "./PitViewModel";
export default class BoardViewModel {
id: string;
pits: PitViewModel[];
constructor(id: string, pits: PitViewModel[]) {
this.id = id;
this.pits = pits;
}
}

View File

@ -0,0 +1,18 @@
export default class PitViewModel {
id: string;
stoneCount: number;
stoneColor: string;
pitColor: string;
constructor(
id: string,
stoneCount: number,
stoneColor: string,
pitColor: string
) {
this.id = id;
this.stoneCount = stoneCount;
this.stoneColor = stoneColor;
this.pitColor = pitColor;
}
}

8
frontend/vercel.json Normal file
View File

@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/"
}
]
}

1986
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff