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