Merge mancala-frontend repo
This commit is contained in:
commit
c9a54a100d
3
frontend/.babelrc
Normal file
3
frontend/.babelrc
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"plugins": ["styled-jsx/babel"]
|
||||
}
|
||||
38
frontend/.gitignore
vendored
Normal file
38
frontend/.gitignore
vendored
Normal 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
7
frontend/.parcelrc
Normal 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
41
frontend/.vscode/.snippet.code-snippets
vendored
Normal 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
15
frontend/.vscode/launch.json
vendored
Normal 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
3
frontend/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
40
frontend/package.json
Normal file
40
frontend/package.json
Normal 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
6
frontend/src/App.tsx
Normal 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/>);
|
||||
93
frontend/src/MancalaApp.tsx
Normal file
93
frontend/src/MancalaApp.tsx
Normal 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;
|
||||
218
frontend/src/animation/PitAnimator.ts
Normal file
218
frontend/src/animation/PitAnimator.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
41
frontend/src/components/Button.tsx
Normal file
41
frontend/src/components/Button.tsx
Normal 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;
|
||||
21
frontend/src/components/Center.tsx
Normal file
21
frontend/src/components/Center.tsx
Normal 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;
|
||||
24
frontend/src/components/CircularPanel.tsx
Normal file
24
frontend/src/components/CircularPanel.tsx
Normal 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;
|
||||
32
frontend/src/components/FloatingPanel.tsx
Normal file
32
frontend/src/components/FloatingPanel.tsx
Normal 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;
|
||||
112
frontend/src/components/InfoPanel.tsx
Normal file
112
frontend/src/components/InfoPanel.tsx
Normal 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;
|
||||
28
frontend/src/components/LoadingComponent.tsx
Normal file
28
frontend/src/components/LoadingComponent.tsx
Normal 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;
|
||||
25
frontend/src/components/PageContainer.tsx
Normal file
25
frontend/src/components/PageContainer.tsx
Normal 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;
|
||||
20
frontend/src/components/Row.tsx
Normal file
20
frontend/src/components/Row.tsx
Normal 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;
|
||||
10
frontend/src/components/Space.tsx
Normal file
10
frontend/src/components/Space.tsx
Normal 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;
|
||||
71
frontend/src/components/UserStatus.tsx
Normal file
71
frontend/src/components/UserStatus.tsx
Normal 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;
|
||||
22
frontend/src/components/board/BoardToolbar.tsx
Normal file
22
frontend/src/components/board/BoardToolbar.tsx
Normal 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;
|
||||
63
frontend/src/components/board/BoardView.tsx
Normal file
63
frontend/src/components/board/BoardView.tsx
Normal 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;
|
||||
36
frontend/src/components/board/PitView.tsx
Normal file
36
frontend/src/components/board/PitView.tsx
Normal 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;
|
||||
20
frontend/src/components/board/StoneView.tsx
Normal file
20
frontend/src/components/board/StoneView.tsx
Normal 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;
|
||||
59
frontend/src/components/board/StoreView.tsx
Normal file
59
frontend/src/components/board/StoreView.tsx
Normal 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;
|
||||
21
frontend/src/components/headerbar/HeaderBar.tsx
Normal file
21
frontend/src/components/headerbar/HeaderBar.tsx
Normal 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;
|
||||
22
frontend/src/components/headerbar/HeaderbarIcon.tsx
Normal file
22
frontend/src/components/headerbar/HeaderbarIcon.tsx
Normal 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;
|
||||
18
frontend/src/components/headerbar/HeaderbarTitle.tsx
Normal file
18
frontend/src/components/headerbar/HeaderbarTitle.tsx
Normal 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;
|
||||
64
frontend/src/components/headerbar/ThemeSwitchMenu.tsx
Normal file
64
frontend/src/components/headerbar/ThemeSwitchMenu.tsx
Normal 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;
|
||||
13
frontend/src/const/channel_names.ts
Normal file
13
frontend/src/const/channel_names.ts
Normal 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"
|
||||
20
frontend/src/const/config.ts
Normal file
20
frontend/src/const/config.ts
Normal 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
114
frontend/src/const/texts.ts
Normal 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"
|
||||
}
|
||||
32
frontend/src/context/context.tsx
Normal file
32
frontend/src/context/context.tsx
Normal 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
|
||||
};
|
||||
};
|
||||
11
frontend/src/factory/BoardViewModelFactory.ts
Normal file
11
frontend/src/factory/BoardViewModelFactory.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
frontend/src/factory/PitViewModelFactory.ts
Normal file
13
frontend/src/factory/PitViewModelFactory.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
frontend/src/hooks/useWindowDimensions.ts
Normal file
26
frontend/src/hooks/useWindowDimensions.ts
Normal 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
31
frontend/src/index.html
Normal 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
76
frontend/src/mancala.svg
Normal 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 |
13
frontend/src/models/Game.ts
Normal file
13
frontend/src/models/Game.ts
Normal 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;
|
||||
}
|
||||
3
frontend/src/models/GameMove.ts
Normal file
3
frontend/src/models/GameMove.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface GameMove {
|
||||
index: number;
|
||||
}
|
||||
46
frontend/src/models/LoadingState.tsx
Normal file
46
frontend/src/models/LoadingState.tsx
Normal 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";
|
||||
}
|
||||
}
|
||||
6
frontend/src/models/User.ts
Normal file
6
frontend/src/models/User.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
isOnline: boolean;
|
||||
isAnonymous: boolean;
|
||||
}
|
||||
4
frontend/src/models/UserConnectionInfo.ts
Normal file
4
frontend/src/models/UserConnectionInfo.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface UserConnectionInfo {
|
||||
userId: string;
|
||||
isOnline: boolean;
|
||||
}
|
||||
329
frontend/src/routes/GamePage.tsx
Normal file
329
frontend/src/routes/GamePage.tsx
Normal 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;
|
||||
|
||||
58
frontend/src/routes/Home.tsx
Normal file
58
frontend/src/routes/Home.tsx
Normal 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;
|
||||
73
frontend/src/routes/LobyPage.tsx
Normal file
73
frontend/src/routes/LobyPage.tsx
Normal 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;
|
||||
12
frontend/src/rtmt/byte_util.ts
Normal file
12
frontend/src/rtmt/byte_util.ts
Normal 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)
|
||||
}
|
||||
20
frontend/src/rtmt/encode_decode_message.ts
Normal file
20
frontend/src/rtmt/encode_decode_message.ts
Normal 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
18
frontend/src/rtmt/rtmt.ts
Normal 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();
|
||||
}
|
||||
131
frontend/src/rtmt/rtmt_websocket.ts
Normal file
131
frontend/src/rtmt/rtmt_websocket.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
frontend/src/service/HttpService.ts
Normal file
23
frontend/src/service/HttpService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
frontend/src/store/GameStore.ts
Normal file
28
frontend/src/store/GameStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
53
frontend/src/store/KeyStore.ts
Normal file
53
frontend/src/store/KeyStore.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
33
frontend/src/theme/DarkTheme.ts
Normal file
33
frontend/src/theme/DarkTheme.ts
Normal 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;
|
||||
23
frontend/src/theme/GreyTheme.ts
Normal file
23
frontend/src/theme/GreyTheme.ts
Normal 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;
|
||||
25
frontend/src/theme/LightTheme.ts
Normal file
25
frontend/src/theme/LightTheme.ts
Normal 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;
|
||||
19
frontend/src/theme/Theme.ts
Normal file
19
frontend/src/theme/Theme.ts
Normal 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;
|
||||
};
|
||||
50
frontend/src/theme/ThemeManager.ts
Normal file
50
frontend/src/theme/ThemeManager.ts
Normal 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
8
frontend/src/types/custom.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
import 'react';
|
||||
|
||||
declare module 'react' {
|
||||
interface StyleHTMLAttributes<T> extends React.HTMLAttributes<T> {
|
||||
jsx?: boolean;
|
||||
global?: boolean;
|
||||
}
|
||||
}
|
||||
31
frontend/src/util/ColorUtil.ts
Normal file
31
frontend/src/util/ColorUtil.ts
Normal 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;
|
||||
}
|
||||
4
frontend/src/util/Notyf.ts
Normal file
4
frontend/src/util/Notyf.ts
Normal 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
32
frontend/src/util/Util.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
10
frontend/src/viewmodel/BoardViewModel.ts
Normal file
10
frontend/src/viewmodel/BoardViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
frontend/src/viewmodel/PitViewModel.ts
Normal file
18
frontend/src/viewmodel/PitViewModel.ts
Normal 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
8
frontend/vercel.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
1986
frontend/yarn.lock
Normal file
1986
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user