diff --git a/package.json b/package.json index 9781d67..0567cb1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "mancala.js": "^0.0.2-beta.3", "react": "^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-router-dom": "6", "styled-jsx": "^5.0.2", "uuid": "^8.3.2" }, diff --git a/src/App.tsx b/src/App.tsx index 5edb8b9..d079d3d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,6 @@ -import { initContext } from './context/context'; -initContext(); - import * as React from 'react'; import { createRoot } from 'react-dom/client'; const container = document.getElementById('main'); const root = createRoot(container!); -import Home from './routes/Home'; -root.render(); \ No newline at end of file +import MancalaApp from './MancalaApp'; +root.render(); \ No newline at end of file diff --git a/src/MancalaApp.tsx b/src/MancalaApp.tsx new file mode 100644 index 0000000..2329499 --- /dev/null +++ b/src/MancalaApp.tsx @@ -0,0 +1,101 @@ +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 { ConnectionState } from './models/ConnectionState'; +import { Theme } from './theme/Theme'; +import LobyPage from './routes/LobyPage'; +const context = initContext(); + +const MancalaApp: FunctionComponent = () => { + + const [userKey, setUserKey] = useState(undefined); + + const [connectionState, setConnetionState] = useState("connecting"); + + const [theme, setTheme] = useState(context.themeManager.theme); + + const onConnectionDone = () => { + setConnetionState("connected"); + }; + const onConnectionLost = () => { + connectToServer("reconnecting"); + }; + const onConnectionError = (event: Event) => { + setConnetionState("error"); + }; + const connectToServer = async (connectionState: ConnectionState) => { + setConnetionState(connectionState); + const userKey = await context.userKeyStore.getUserKey(); + setUserKey(userKey); + const rtmtws = context.rtmt as RTMTWS; + if (rtmtws) { + rtmtws.initWebSocket( + userKey, + onConnectionDone, + onConnectionLost, + onConnectionError + ); + } else { + console.error("context.rtmt is not RTMTWS"); + } + }; + React.useEffect(() => { + connectToServer("connecting"); + context.themeManager.onThemeChange = (theme: Theme) => { + setTheme(theme); + } + return () => { + // todo: dispose rtmt.dispose + //context.rtmt.dispose(); + }; + }, []); + + const showConnectionState = connectionState != "connected"; + const floatingPanelColor = context.themeManager.theme.boardColor; + const connectionStateText = () => { + const map: { [key: string]: string } = { + connecting: context.texts.Connecting, + connected: context.texts.Connected, + error: context.texts.CannotConnect, + reconnecting: context.texts.ConnectingAgain, + }; + return map[connectionState]; + }; + const textColorOnBoard = getColorByBrightness( + context.themeManager.theme.boardColor, + context.themeManager.theme.textColor, + context.themeManager.theme.textLightColor + ); + return ( + <> + + + } /> + + + } > + + }> + + + + + + {connectionStateText()} + + + ); +} + +export default MancalaApp; \ No newline at end of file diff --git a/src/animation/PitAnimator.ts b/src/animation/PitAnimator.ts index 38cf540..2f4d442 100644 --- a/src/animation/PitAnimator.ts +++ b/src/animation/PitAnimator.ts @@ -42,7 +42,7 @@ export default class PitAnimator { this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game)); } - public setUpdatedGame(game: MancalaGame, forceClear = false) { + public setUpdatedGame(game: MancalaGame) { this.resetAnimationState(); if (!this.game) { this.setNewGame(game); @@ -68,7 +68,7 @@ export default class PitAnimator { } onAnimate() { - if(!this.currentHistoryItem || !this.game || !this.oldBoardViewModel) return; + if (!this.currentHistoryItem || !this.game || !this.oldBoardViewModel) return; if (this.animationIndex === this.currentHistoryItem.gameSteps.length) { this.clearCurrentInterval(); this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game)); @@ -92,7 +92,7 @@ export default class PitAnimator { boardViewModel: BoardViewModel, gameStep: GameStep ) { - if(!this.currentHistoryItem || !this.game) return; + if (!this.currentHistoryItem || !this.game) return; const pitViewModel = boardViewModel.pits[index]; if (this.animationIndex === 0) { //This is one stone move case, TODO: beautify it later diff --git a/src/components/LoadingComponent.tsx b/src/components/LoadingComponent.tsx new file mode 100644 index 0000000..3c9495e --- /dev/null +++ b/src/components/LoadingComponent.tsx @@ -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 }> = ({ 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 ( + +

{`${text}`}

+
+ ); + } + return <> +} + +export default LoadingComponent; \ No newline at end of file diff --git a/src/const/config.ts b/src/const/config.ts new file mode 100644 index 0000000..95fea6b --- /dev/null +++ b/src/const/config.ts @@ -0,0 +1,15 @@ +export const useLocalServer = false; + +export type Server = { + serverAdress: string; + wsServerAdress: string; +}; + +export const server: Server = useLocalServer ? { + serverAdress: "http://localhost:5000", + wsServerAdress: "ws://localhost:5000", +} : { + serverAdress: "https://segin.one/mancala-backend-beta", + wsServerAdress: "wss://segin.one/mancala-backend-beta/", +}; + diff --git a/src/const/texts.ts b/src/const/texts.ts index b9b1133..ac2de91 100644 --- a/src/const/texts.ts +++ b/src/const/texts.ts @@ -22,6 +22,8 @@ export type Texts = { PleaseWait : string, GameDraw : string, Anonymous: string, + GameNotFound: string, + Loading: string, } export const EnUs: Texts = { @@ -47,6 +49,8 @@ export const EnUs: Texts = { PleaseWait : "Please Wait", GameDraw : "Draw", Anonymous: "Anonymous", + GameNotFound: "Game Not Found", + Loading: "Loading", } export const TrTr: Texts = { @@ -71,5 +75,7 @@ export const TrTr: Texts = { SearchingOpponent: "Rakip Aranıyor", PleaseWait: "Lütfen Bekleyin", GameDraw : "Berabere", - Anonymous: "Anonim" + Anonymous: "Anonim", + GameNotFound: "Oyun Bulunamadı", + Loading: "Yükleniyor", } \ No newline at end of file diff --git a/src/context/context.tsx b/src/context/context.tsx index 28d2485..ee35337 100644 --- a/src/context/context.tsx +++ b/src/context/context.tsx @@ -1,7 +1,10 @@ +import { server } from "../const/config"; import { Texts, TrTr } from "../const/texts"; import { RTMT } from "../rtmt/rtmt"; import { RTMTWS } from "../rtmt/rtmt_websocket"; -import { UserKeyStore, UserKeyStoreImpl } from "../store/key_store"; +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 = { @@ -9,11 +12,14 @@ export type Context = { userKeyStore: UserKeyStore; texts: Texts; themeManager: ThemeManager; + gameStore: GameStore; }; -export const initContext = () => { +export const initContext = (): Context => { const rtmt = new RTMTWS(); - const userKeyStore = new UserKeyStoreImpl(); + const httpService = new HttpServiceImpl(server.serverAdress); + const userKeyStore = new UserKeyStoreImpl({ httpService }); + const gameStore = new GameStoreImpl({ httpService }); const texts = TrTr; const themeManager = new ThemeManager(); return { @@ -21,7 +27,6 @@ export const initContext = () => { userKeyStore: userKeyStore, texts: texts, themeManager, + gameStore }; }; - -export const context: Context = initContext(); diff --git a/src/models/ConnectionState.ts b/src/models/ConnectionState.ts new file mode 100644 index 0000000..5010944 --- /dev/null +++ b/src/models/ConnectionState.ts @@ -0,0 +1 @@ +export type ConnectionState = "connecting" | "error" | "connected" | "reconnecting"; \ No newline at end of file diff --git a/src/models/LoadingState.tsx b/src/models/LoadingState.tsx new file mode 100644 index 0000000..dfe1e4e --- /dev/null +++ b/src/models/LoadingState.tsx @@ -0,0 +1,46 @@ +export type LoadingStateType = "unset" | "loading" | "loaded" | "error"; + +export class LoadingState { + 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() { + return new LoadingState({ state: "unset" }); + } + + public static Loading() { + return new LoadingState({ state: "loading" }); + } + + public static Error(props: { errorMessage: string }) { + const { errorMessage } = props; + return new LoadingState({ state: "error", errorMessage }); + } + + public static Loaded(props: { value?: T }) { + const { value } = props; + return new LoadingState({ 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"; + } +} \ No newline at end of file diff --git a/src/routes/GamePage.tsx b/src/routes/GamePage.tsx new file mode 100644 index 0000000..36df155 --- /dev/null +++ b/src/routes/GamePage.tsx @@ -0,0 +1,244 @@ +import { CommonMancalaGame, 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 } from '../const/channel_names'; +import { Context } from '../context/context'; +import useWindowDimensions from '../hooks/useWindowDimensions'; +import { ConnectionState } from '../models/ConnectionState'; +import { GameMove } from '../models/GameMove'; +import { LoadingState } from '../models/LoadingState'; +import { UserConnectionInfo } from '../models/UserConnectionInfo'; +import { Theme } from '../theme/Theme'; +import { getColorByBrightness } from '../util/ColorUtil'; +import BoardViewModel from '../viewmodel/BoardViewModel'; +import Center from '../components/Center'; + +const GamePage: FunctionComponent<{ + context: Context, + userKey?: string, + theme: Theme, + connectionState: ConnectionState +}> = ({ context, userKey, theme, connectionState }) => { + let params = useParams<{ gameId: string }>(); + + const [game, setGame] = useState(undefined); + + const [crashMessage, setCrashMessage] = useState(undefined); + + const [userKeyWhoLeave, setUserKeyWhoLeave] = useState(undefined); + + const [boardViewModel, setBoardViewModel] = useState(undefined); + + const [boardId, setBoardId] = useState("-1"); + + const [pitAnimator, setPitAnimator] = useState(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(false); + + const [isOpponentOnline, setIsOpponentOnline] = useState(false); + + const { height, width } = useWindowDimensions(); + + const navigate = useNavigate(); + + const [gameLoadingState, setLoadingStateGame] = useState>(LoadingState.Unset()); + + const onGameUpdate = (pitAnimator: PitAnimator, message: Object) => { + const newGame: CommonMancalaGame = message as CommonMancalaGame; + const mancalaGame = MancalaGame.createFromMancalaGame(newGame); + setGame(mancalaGame); + pitAnimator.setUpdatedGame(mancalaGame); + setHasOngoingAction(false); + } + const onGameCrashed = (message: any) => { + const newCrashMessage = message as string; + console.error("on_game_crash"); + console.error(newCrashMessage); + setCrashMessage(newCrashMessage); + } + const onGameUserLeave = (message: any) => { + const userKeyWhoLeave = message; + setUserKeyWhoLeave(userKeyWhoLeave); + setHasOngoingAction(false); + }; + const onUserConnectionChange = (message: any) => { + const userConnectionInfo = message as UserConnectionInfo; + //todo: change this when implementing watch the game feature + setIsOpponentOnline(userConnectionInfo.isOnline); + }; + + const listenMessages = (pitAnimator: PitAnimator) : () => void => { + const _onGameUpdate = (message: object) => onGameUpdate(pitAnimator, message); + context.rtmt.listenMessage(channel_on_game_update, _onGameUpdate); + context.rtmt.listenMessage(channel_on_game_crashed, onGameCrashed); + context.rtmt.listenMessage(channel_on_game_user_leave, onGameUserLeave); + context.rtmt.listenMessage(channel_on_user_connection_change, onUserConnectionChange) + return () => { + context.rtmt.unlistenMessage(channel_on_game_update, _onGameUpdate); + context.rtmt.unlistenMessage(channel_on_game_crashed, onGameCrashed); + context.rtmt.unlistenMessage(channel_on_game_user_leave, onGameUserLeave); + context.rtmt.unlistenMessage(channel_on_user_connection_change, onUserConnectionChange); + } + }; + + const updateBoardViewModel = (boardViewModel: BoardViewModel) => { + boardViewModel.id = v4(); + setBoardId(boardViewModel.id); + setBoardViewModel(boardViewModel); + }; + + const getBoardIndex = (index: number) => { + if (!game) return -1; + if (userKey === game.player2Id) return index + game.board.pits.length / 2; + return index; + }; + + const getOpponentId = () => game?.player1Id === userKey ? game?.player2Id : game?.player1Id; + + const checkHasAnOngoingAction = () => hasOngoingAction; + + const onLeaveGameClick = () => { + context.rtmt.sendMessage(channel_leave_game, {}); + }; + + const onNewGameClick = () => { + navigate("/loby") + }; + + const onPitSelect = (index: number, pit: Pit) => { + if (checkHasAnOngoingAction()) { + return; + } + setHasOngoingAction(true); + if (!boardViewModel) return; + //TODO : stoneCount comes from view model! + if (pit.stoneCount === 0) { + //TODO : warn user + return; + } + boardViewModel.pits[getBoardIndex(index)].pitColor = + context.themeManager.theme.pitSelectedColor; + updateBoardViewModel(boardViewModel); + const gameMove: GameMove = { index: index }; + 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) { + setGame(game); + setHasOngoingAction(false); + pitAnimator = new PitAnimator(context, updateBoardViewModel); + pitAnimator.setNewGame(game); + setPitAnimator(pitAnimator); + unlistenMessages = listenMessages(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 renderNewGameBtn = userKeyWhoLeave || !game || (game && game.state == "ended"); + const showBoardView = game && boardViewModel && userKey && true; + const opponentUser = { id: getOpponentId() || "0", name: "Anonymous", isOnline: isOpponentOnline, isAnonymous: true }; + const user = { id: userKey || "1", name: "Anonymous", isOnline: connectionState === "connected", isAnonymous: true }; + + const isMobile = width < 600; + + return ( + + + + + + + + + + + + +