[mobile] implement basic working Game UI

This commit is contained in:
Halit Aksoy 2024-03-27 21:48:48 +03:00
parent 341fe0e083
commit 2cca40034d
27 changed files with 1219 additions and 12 deletions

View File

@ -12,16 +12,19 @@
"dependencies": { "dependencies": {
"@react-navigation/native": "^6.1.17", "@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26", "@react-navigation/native-stack": "^6.9.26",
"@types/uuid": "^9.0.8",
"i18next": "^23.10.1", "i18next": "^23.10.1",
"mancala.js": "^0.0.2-beta.3", "mancala.js": "^0.0.2-beta.3",
"react": "18.2.0", "react": "18.2.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-native": "0.73.6", "react-native": "0.73.6",
"react-native-get-random-values": "^1.11.0",
"react-native-mmkv": "^2.12.2", "react-native-mmkv": "^2.12.2",
"react-native-safe-area-context": "^4.9.0", "react-native-safe-area-context": "^4.9.0",
"react-native-screens": "^3.29.0", "react-native-screens": "^3.29.0",
"react-native-snackbar": "^2.6.2", "react-native-snackbar": "^2.6.2",
"tiny-emitter": "^2.1.0" "tiny-emitter": "^2.1.0",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",

View File

@ -12,6 +12,9 @@ import { ConnectionState } from './rtmt/rtmt';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Snackbar from 'react-native-snackbar'; import Snackbar from 'react-native-snackbar';
// https://github.com/uuidjs/uuid/issues/514#issuecomment-691475020
import 'react-native-get-random-values';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
const context = initContext(); const context = initContext();

View File

@ -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();
}
}

View File

@ -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 (
<Pressable onPress={onClick}>
<View
style={{
backgroundColor: color,
//color: textColor,
margin: 5,
padding: 10,
borderRadius: 30,
}}
>
{text}
</View>
</Pressable>
);
};
export default Button;

View File

@ -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 (
<View style={{
display: 'flex',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center'
}}>
{children}
</View>
);
}
export default Center;

View File

@ -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 (
<View style={[{backgroundColor: props.color}, props.style, {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 30,
}]}>
{props.children}
</View>
);
}
export default CircularPanel;

View File

@ -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 (
<CircularPanel style={style} color={containerColor}>
<Text style={{ margin: 0, color: textColor}}>
{text}
</Text>
</CircularPanel>
);
} else {
return (<View></View>)
}
};
export default InfoPanel;

View File

@ -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<any> }> = ({ 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 (
<CircularPanel color={context.themeManager.theme.boardColor}>
<Text style={{ margin: 0, color: textColorOnBoard }}>{`${text}`}</Text>
</CircularPanel>
);
}
return <></>
}
export default LoadingComponent;

View File

@ -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 (
<View style={{
backgroundColor: props.context.themeManager.theme?.background,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: 1,
minHeight: 400
}}>
{props.children}
</View>
);
}
export default PageContainer;

View File

@ -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 (
<View style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent:'space-between'
}}>
{children}
</View>
);
}
export default Row;

View File

@ -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 (
<View style={{ width, height}}/>
);
}
export default Space;

View File

@ -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 (
<View style={[style, layoutMode === "right" ? styles.flexRtl : styles.flexLtr]}>
<Text style={[{color: textColorOnBoard}, styles.text]}>{
user.isAnonymous ? t("Anonymous") : user.name}
</Text>
<Space width={5} />
<View style={[styles.circle, (user.isOnline? styles.online: styles.offline)]} />
</View>
);
}
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;

View File

@ -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 (
<View style={[style, {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
alignSelf: 'stretch',
}]} >
{children}
</View>
);
}
export default BoardToolbar;

View File

@ -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 <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 (
<View style={{
backgroundColor: theme.boardColor,
padding: 20,
//display: 'grid',
//gridTemplateColumns: 'repeat(8, 11vw)',
//gridTemplateRows: 'repeat(2, 11vw)',
borderRadius: 30
}}>
<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}
</View>
);
};
export default BoardView;

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { FunctionComponent } from "react";
import Util from "../../util/Util";
import PitViewModel from "../../viewmodel/PitViewModel";
import StoneView from "./StoneView";
import { Pressable, View } from "react-native";
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 (
<Pressable onPress={onClick}>
<View style={{
backgroundColor: pitViewModel.pitColor,
margin: 5,
padding: 5,
borderRadius: 50,
display: 'flex',
alignItems: 'center',
alignContent: 'center',
justifyContent: 'center',
//justifyItems: 'center',
flexWrap: 'wrap',
}}>
{stones}
</View>
</Pressable>
);
};
export default PitView;

View File

@ -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 (
<View style={{
backgroundColor: color,
margin: 1,
width: 10,
height: 10,
borderRadius: 10,
}}>
</View>
);
};
export default StoneView;

View File

@ -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) => (
<StoneView key={index} color={pitViewModel.stoneColor} />
));
const textColor = getColorByBrightness(
pitViewModel.pitColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<View
style={{
backgroundColor: pitViewModel.pitColor,
//gridColumn: gridColumn,
//gridRow: gridRow,
margin: 5,
borderRadius: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
alignContent: 'center',
flexWrap: 'wrap',
position: 'relative',
}}>
{stones}
<Text style={{
color: textColor,
position: 'absolute',
bottom: 20,
fontFamily: 'monospace',
fontWeight: 'bold',
fontSize: 20,
}}>
{stones.length}
</Text>
</View>
);
};
export default StoreView;

View File

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

View File

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

View File

@ -1,16 +1,314 @@
import * as React from 'react'; 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 { useTranslation } from 'react-i18next';
import { GameScreenProps } from '../types'; 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) { export function GameScreen({ navigation, route }: GameScreenProps) {
const { context, gameId } = route.params; const { context, gameId, userKey } = route.params;
const { t } = useTranslation(); const { t } = useTranslation();
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 [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;
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 ( return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> <PageContainer context={context}>
<Text>{gameId}</Text> {/* {renderHeaderBar()} */}
</View> {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: 15, marginLeft: "6%", maxWidth: isMobile ? 40 : 30,
width: isMobile ? 40 : 30
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false} />
{buildInfoPanel({ visible: !isMobile })}
<UserStatus style={{
marginBottom: 15, marginRight: "6%", maxWidth: isMobile ? 40: 30,
width: isMobile ? 40: 30
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false} />
</BoardToolbar>;
}
function buildInfoPanel(params: { visible: boolean }) {
return (
<InfoPanel
style={{ marginTop: 10, marginBottom: 10 }}
context={context}
game={game}
currentUser={currentUser}
whitePlayer={topLocatedUser}
blackPlayer={bottomLocatedUser}
leftPlayer={leftPlayer}
visible={params.visible}
isSpectator={isSpectator} />
); );
} }
}

View File

@ -11,9 +11,10 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
const { context } = route.params; const { context } = route.params;
const { t } = useTranslation(); const { t } = useTranslation();
const onGameStart = (message: Object) => { const onGameStart = async (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame; const newGame: CommonMancalaGame = message as CommonMancalaGame;
navigation.navigate("Game", { context, gameId: newGame.id }) const userKey = await context.userKeyStore.getUserKey();
navigation.navigate("Game", { context, gameId: newGame.id, userKey })
} }
useEffect(() => { useEffect(() => {

View File

@ -4,7 +4,7 @@ import { Context } from '../context/context';
export type RootStackParamList = { export type RootStackParamList = {
Home: { context: Context }, Home: { context: Context },
Loby: { context: Context }, Loby: { context: Context },
Game: { context: Context, gameId: string } Game: { context: Context, gameId: string, userKey: string }
}; };
export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>; export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;

View File

@ -0,0 +1,32 @@
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[] {
//@ts-ignore
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;
}

34
mobile/src/util/Util.ts Normal file
View File

@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { Context } from "../context/context";
import { ConnectionState } from "../rtmt/rtmt";
import Snackbar from "react-native-snackbar";
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, message: string): boolean {
if (context.rtmt.connectionState !== "connected") {
Snackbar.show({text: message});
return true;
}
return false;
}
public static getTextByConnectionState(context: Context, connectionState: ConnectionState): string {
const { t } = useTranslation();
const map: { [key: string]: string } = {
connecting: t("Connecting"),
connected: t("Connected"),
error: t("CannotConnect"),
closed: t("ConnectingAgain"),
reconnecting: t("ConnectingAgain"),
};
return map[connectionState];
}
}

View File

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

View File

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

View File

@ -2041,6 +2041,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
"@types/uuid@^9.0.8":
version "9.0.8"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba"
integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==
"@types/yargs-parser@*": "@types/yargs-parser@*":
version "21.0.3" version "21.0.3"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
@ -3420,6 +3425,11 @@ expect@^29.7.0:
jest-message-util "^29.7.0" jest-message-util "^29.7.0"
jest-util "^29.7.0" jest-util "^29.7.0"
fast-base64-decode@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418"
integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@ -5644,6 +5654,13 @@ react-is@^17.0.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-native-get-random-values@^1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz#1ca70d1271f4b08af92958803b89dccbda78728d"
integrity sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==
dependencies:
fast-base64-decode "^1.0.0"
react-native-mmkv@^2.12.2: react-native-mmkv@^2.12.2:
version "2.12.2" version "2.12.2"
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.12.2.tgz#4bba0f5f04e2cf222494cce3a9794ba6a4894dee" resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.12.2.tgz#4bba0f5f04e2cf222494cce3a9794ba6a4894dee"
@ -6595,6 +6612,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
v8-to-istanbul@^9.0.1: v8-to-istanbul@^9.0.1:
version "9.2.0" version "9.2.0"
resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad"