diff --git a/mobile/package.json b/mobile/package.json index 8cce6ef..5c751dd 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,16 +12,19 @@ "dependencies": { "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.9.26", + "@types/uuid": "^9.0.8", "i18next": "^23.10.1", "mancala.js": "^0.0.2-beta.3", "react": "18.2.0", "react-i18next": "^14.1.0", "react-native": "0.73.6", + "react-native-get-random-values": "^1.11.0", "react-native-mmkv": "^2.12.2", "react-native-safe-area-context": "^4.9.0", "react-native-screens": "^3.29.0", "react-native-snackbar": "^2.6.2", - "tiny-emitter": "^2.1.0" + "tiny-emitter": "^2.1.0", + "uuid": "^9.0.1" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx index 9750825..3e72a67 100644 --- a/mobile/src/App.tsx +++ b/mobile/src/App.tsx @@ -12,6 +12,9 @@ import { ConnectionState } from './rtmt/rtmt'; import { useTranslation } from 'react-i18next'; import Snackbar from 'react-native-snackbar'; +// https://github.com/uuidjs/uuid/issues/514#issuecomment-691475020 +import 'react-native-get-random-values'; + const Stack = createNativeStackNavigator(); const context = initContext(); diff --git a/mobile/src/animation/PitAnimator.ts b/mobile/src/animation/PitAnimator.ts new file mode 100644 index 0000000..6238f68 --- /dev/null +++ b/mobile/src/animation/PitAnimator.ts @@ -0,0 +1,220 @@ +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 | undefined; + 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(); + + //@ts-ignore + 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(); + } +} diff --git a/mobile/src/components/Button.tsx b/mobile/src/components/Button.tsx new file mode 100644 index 0000000..3cfa3bc --- /dev/null +++ b/mobile/src/components/Button.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Context } from "../context/context"; +import { getColorByBrightness } from "../util/ColorUtil"; +import { Pressable, View } from "react-native"; + +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 ( + + + {text} + + + ); +}; + +export default Button; diff --git a/mobile/src/components/Center.tsx b/mobile/src/components/Center.tsx new file mode 100644 index 0000000..fe79b33 --- /dev/null +++ b/mobile/src/components/Center.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { View } from 'react-native'; + +const Center: FunctionComponent<{children: React.ReactNode}> = ({children}) => { + return ( + + {children} + + ); +} + +export default Center; \ No newline at end of file diff --git a/mobile/src/components/CircularPanel.tsx b/mobile/src/components/CircularPanel.tsx new file mode 100644 index 0000000..b09d5e0 --- /dev/null +++ b/mobile/src/components/CircularPanel.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { View, ViewStyle } from 'react-native'; + +const CircularPanel: FunctionComponent<{ + color: string; + style?: ViewStyle, + children: React.ReactNode +}> = (props) => { + return ( + + {props.children} + + ); +} + +export default CircularPanel; diff --git a/mobile/src/components/InfoPanel.tsx b/mobile/src/components/InfoPanel.tsx new file mode 100644 index 0000000..9f5cdb6 --- /dev/null +++ b/mobile/src/components/InfoPanel.tsx @@ -0,0 +1,116 @@ +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"; +import { useTranslation } from "react-i18next"; +import { Text, View, ViewStyle } from "react-native"; + +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; + + const { t } = useTranslation(); + + if (leftPlayer) { + return isSpectator ? `${leftPlayer.name} ${t('UserLeftTheGame')}` : + leftPlayer.id == currentUser.id ? t('YouLeftTheGame') : t('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} ${t('Won')}` : + game.mancalaGame.getWonPlayerId() === currentUser.id + ? t('YouWon') + : t('YouLost'); + } else { + whoWon = t('GameDraw'); + } + return t('GameEnded') + " " + whoWon; + } + + if (game) { + const playingPlayer = game.mancalaGame.checkIsPlayerTurn(whitePlayer.id) ? whitePlayer : blackPlayer; + return isSpectator ? `${playingPlayer.name} ${t('Playing')}` : game.mancalaGame.checkIsPlayerTurn(currentUser.id) + ? t('YourTurn') + : t('OpponentTurn'); + } + + return undefined; +} + +const InfoPanel: FunctionComponent<{ + context: Context; + game?: Game; + currentUser: User; + whitePlayer: User; + blackPlayer: User; + leftPlayer?: User; + style?: ViewStyle; + 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 ( + + + {text} + + + ); + } else { + return () + } + }; + +export default InfoPanel; diff --git a/mobile/src/components/LoadingComponent.tsx b/mobile/src/components/LoadingComponent.tsx new file mode 100644 index 0000000..6d9d832 --- /dev/null +++ b/mobile/src/components/LoadingComponent.tsx @@ -0,0 +1,31 @@ +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'; +import { useTranslation } from 'react-i18next'; +import { Text } from 'react-native'; + +const LoadingComponent: FunctionComponent<{ context: Context, loadingState: LoadingState }> = ({ context, loadingState }) => { + const { t } = useTranslation(); + 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() ? t('Loading') +"..." : loadingState.errorMessage; + return ( + + {`${text}`} + + ); + } + return <> +} + +export default LoadingComponent; \ No newline at end of file diff --git a/mobile/src/components/PageContainer.tsx b/mobile/src/components/PageContainer.tsx new file mode 100644 index 0000000..5a3afbc --- /dev/null +++ b/mobile/src/components/PageContainer.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Theme } from "../theme/Theme"; +import { Context } from "../context/context"; +import { View } from "react-native"; + +const PageContainer: FunctionComponent<{ context: Context, children: React.ReactNode }> = (props) => { + return ( + + {props.children} + + ); +} + +export default PageContainer; \ No newline at end of file diff --git a/mobile/src/components/Row.tsx b/mobile/src/components/Row.tsx new file mode 100644 index 0000000..68aafd5 --- /dev/null +++ b/mobile/src/components/Row.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { View } from 'react-native'; + +const Row: FunctionComponent<{children: React.ReactNode}> = ({children}) => { + return ( + + {children} + + ); +} + +export default Row; \ No newline at end of file diff --git a/mobile/src/components/Space.tsx b/mobile/src/components/Space.tsx new file mode 100644 index 0000000..b8f8d09 --- /dev/null +++ b/mobile/src/components/Space.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { View } from 'react-native'; + +const Space: FunctionComponent<{ width?: number, height?: number }> = ({ width, height }) => { + return ( + + ); +} + +export default Space; \ No newline at end of file diff --git a/mobile/src/components/UserStatus.tsx b/mobile/src/components/UserStatus.tsx new file mode 100644 index 0000000..8ae2e9f --- /dev/null +++ b/mobile/src/components/UserStatus.tsx @@ -0,0 +1,80 @@ +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'; +import { Theme } from '../theme/Theme'; +import { StyleSheet, Text, View, ViewStyle } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +export type LayoutMode = "right" | "left"; + +const UserStatus: FunctionComponent<{ + context: Context, + user: User, + layoutMode: LayoutMode, + visible?: boolean, + style?: ViewStyle +}> = ({ context, user, layoutMode, visible, style }) => { + if (visible === false) return <>; + const textColorOnBoard = getColorByBrightness( + context.themeManager.theme.background, + context.themeManager.theme.textColor, + context.themeManager.theme.textLightColor + ); + const { t } = useTranslation(); + const styles = getDynamicStyles(context.themeManager.theme); + return ( + + { + user.isAnonymous ? t("Anonymous") : user.name} + + + + + ); +} + +function getDynamicStyles(theme: Theme) { + return StyleSheet.create({ + online: { + backgroundColor: theme.boardColor + }, + offline: { + backgroundColor: 'transparent' + }, + circle: { + width: 15, + height: 15, + minWidth: 15, + minHeight: 15, + borderRadius: 15, + borderWidth: 2, + borderColor: theme.boardColor, + borderStyle: "solid", + }, + flexRtl: { + display: 'flex', + flexDirection: 'row-reverse', + alignItems: 'center', + }, + flexLtr: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + text: { + fontWeight: 'bold', + overflow: 'hidden', + }, + icon: { + color : "grey", + width: 32, + height: 32, + fontSize: 32, + } + }); +} + +export default UserStatus; \ No newline at end of file diff --git a/mobile/src/components/board/BoardToolbar.tsx b/mobile/src/components/board/BoardToolbar.tsx new file mode 100644 index 0000000..4b06e54 --- /dev/null +++ b/mobile/src/components/board/BoardToolbar.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { View, ViewStyle } from 'react-native'; + +const BoardToolbar: FunctionComponent<{ visible?: boolean, style?: ViewStyle, children: React.ReactNode }> = ({ children, visible, style }) => { + if (visible === false) return <>; + return ( + + {children} + + ); +} + +export default BoardToolbar; \ No newline at end of file diff --git a/mobile/src/components/board/BoardView.tsx b/mobile/src/components/board/BoardView.tsx new file mode 100644 index 0000000..5ae7a6e --- /dev/null +++ b/mobile/src/components/board/BoardView.tsx @@ -0,0 +1,61 @@ +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"; +import { View } from "react-native"; + +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 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 ( + + + + {revert ? player1Pits?.reverse() : player2Pits?.reverse()} + {revert ? player2Pits : player1Pits} + + ); +}; + +export default BoardView; diff --git a/mobile/src/components/board/PitView.tsx b/mobile/src/components/board/PitView.tsx new file mode 100644 index 0000000..2a89d3c --- /dev/null +++ b/mobile/src/components/board/PitView.tsx @@ -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"; +import { Pressable, View } from "react-native"; + +const PitView: FunctionComponent<{ + pitViewModel: PitViewModel; + onClick: () => void; +}> = ({ pitViewModel, onClick }) => { + const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( + + )); + + return ( + + + {stones} + + + ); +}; + +export default PitView; \ No newline at end of file diff --git a/mobile/src/components/board/StoneView.tsx b/mobile/src/components/board/StoneView.tsx new file mode 100644 index 0000000..84a809e --- /dev/null +++ b/mobile/src/components/board/StoneView.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { View } from "react-native"; + +const StoneView: FunctionComponent<{ color: string }> = ({ color }) => { + return ( + + + ); +}; + +export default StoneView; \ No newline at end of file diff --git a/mobile/src/components/board/StoreView.tsx b/mobile/src/components/board/StoreView.tsx new file mode 100644 index 0000000..b0a5162 --- /dev/null +++ b/mobile/src/components/board/StoreView.tsx @@ -0,0 +1,54 @@ +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"; +import { Text, View } from "react-native"; + +const StoreView: FunctionComponent<{ + context: Context; + pitViewModel: PitViewModel; + gridColumn: string; + gridRow: string; +}> = ({ context, pitViewModel, gridColumn, gridRow }) => { + const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( + + )); + const textColor = getColorByBrightness( + pitViewModel.pitColor, + context.themeManager.theme.textColor, + context.themeManager.theme.textLightColor + ); + return ( + + {stones} + + {stones.length} + + + ); +}; + +export default StoreView; \ No newline at end of file diff --git a/mobile/src/factory/BoardViewModelFactory.ts b/mobile/src/factory/BoardViewModelFactory.ts new file mode 100644 index 0000000..cd8e74f --- /dev/null +++ b/mobile/src/factory/BoardViewModelFactory.ts @@ -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); + } +} diff --git a/mobile/src/factory/PitViewModelFactory.ts b/mobile/src/factory/PitViewModelFactory.ts new file mode 100644 index 0000000..00d33b2 --- /dev/null +++ b/mobile/src/factory/PitViewModelFactory.ts @@ -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); + } +} diff --git a/mobile/src/screens/GameScreen.tsx b/mobile/src/screens/GameScreen.tsx index 7a4b768..d47805e 100644 --- a/mobile/src/screens/GameScreen.tsx +++ b/mobile/src/screens/GameScreen.tsx @@ -1,16 +1,314 @@ import * as React from 'react'; -import { View, Button, Text } from 'react-native'; +import { View, Button, Text, useWindowDimensions } from 'react-native'; import { useTranslation } from 'react-i18next'; import { GameScreenProps } from '../types'; - +import { useState } from 'react'; +import { Game, GameUsersConnectionInfo } from '../models/Game'; +import BoardViewModel from '../viewmodel/BoardViewModel'; +import { Link } from '@react-navigation/native'; +import { MancalaGame, Pit } from 'mancala.js'; +import { v4 } from 'uuid'; +import PitAnimator from '../animation/PitAnimator'; +import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_listen_game_events, channel_unlisten_game_events, channel_leave_game, channel_game_move } from '../const/channel_names'; +import { GameMove } from '../models/GameMove'; +import { LoadingState } from '../models/LoadingState'; +import { getColorByBrightness } from '../util/ColorUtil'; +import Util from '../util/Util'; +import Snackbar from 'react-native-snackbar'; +import PageContainer from '../components/PageContainer'; +import Center from '../components/Center'; +import InfoPanel from '../components/InfoPanel'; +import LoadingComponent from '../components/LoadingComponent'; +import UserStatus from '../components/UserStatus'; +import BoardToolbar from '../components/board/BoardToolbar'; +import BoardView from '../components/board/BoardView'; export function GameScreen({ navigation, route }: GameScreenProps) { - - const { context, gameId } = route.params; + + const { context, gameId, userKey } = route.params; const { t } = useTranslation(); - return ( - - {gameId} - + const [game, setGame] = 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 [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState(); + + const { height, width } = useWindowDimensions(); + + const [gameLoadingState, setLoadingStateGame] = useState>(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; + Snackbar.show({text: t("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, t("ConnectionLost"))) return; + + // TODO + //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, t("ConnectionLost"))) return; + navigation.navigate("Loby", { context }) + }; + + const onPitSelect = (index: number, pit: Pit) => { + if (!game || isSpectator || !userKey) { + return; + } + if (userKeyWhoLeave) { + Snackbar.show({text: t("GameEnded")}); + return; + } + if (game.mancalaGame.state === "ended") { + Snackbar.show({text: t("GameEnded")}); + return; + } + if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; + if (game.mancalaGame.getPlayerIdByIndex(index) !== userKey) { + Snackbar.show({text: t("UCanOnlyPlayYourOwnPits")}); + return; + } + const pitIndexForUser = index % (game.mancalaGame.board.totalPitCount() / 2); + if (!game.mancalaGame.canPlayerMove(userKey, pitIndexForUser)) { + Snackbar.show({text: t("OpponentTurn")}); + return; + } + if (checkHasAnOngoingAction()) { + Snackbar.show({text: t("UMustWaitUntilCurrentMoveComplete")}); + return; + } + if (!boardViewModel) return; + //TODO: this check should be in mancala.js + if (pit.stoneCount === 0) { + Snackbar.show({text: t("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(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: t('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 ( + + {/* {renderHeaderBar()} */} + {renderMobileBoardToolbar()} + {buildBoardTopToolbar()} + {showBoardView && ( + + )} +
+ +
+
+ ); + + //function renderHeaderBar() { + // return + // + // + // + // + // + // + // + // + // + // + //