diff --git a/apps/mobile/src/components/BotLevelPicker.tsx b/apps/mobile/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..6cbda7a --- /dev/null +++ b/apps/mobile/src/components/BotLevelPicker.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from '@mancala/core'; +import { Context } from '../context/context'; +import CircularPanel from './CircularPanel'; + +// The four bot levels; the level chosen last is highlighted and remembered on the device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const theme = context.themeManager.theme; + const lastLevel = getLastBotLevel(context.storage); + return ( + + {BOT_LEVELS.map((level) => ( + { + setLastBotLevel(context.storage, level); + onChoose(level); + }}> + {t(getBotLevelTextKey(level))}} /> + + ))} + + ); +}; + +export default BotLevelPicker; diff --git a/apps/mobile/src/screens/GameScreen.tsx b/apps/mobile/src/screens/GameScreen.tsx index d05709e..404b432 100644 --- a/apps/mobile/src/screens/GameScreen.tsx +++ b/apps/mobile/src/screens/GameScreen.tsx @@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native' import { useTranslation } from 'react-i18next'; import { GameScreenProps } from '../types'; import { useState } from 'react'; -import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat } from "@mancala/core"; +import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat, getBotLevel, getPlayerDisplayName } from "@mancala/core"; import { MancalaGame, Pit } from 'mancala.js'; import { v4 } from 'uuid'; 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, channel_on_game_end } from '@mancala/core'; @@ -159,10 +159,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) { }); }; + // In a game against the bot, a new game is another one at the same level. const onNewGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; context.storage.deleteString('lastGameId'); - navigation.replace("Loby", { context }) + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + navigation.replace("Loby", { context, botLevel: opponentBotLevel }) }; const onPitSelect = (index: number, pit: Pit) => { @@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { onLeaveGameClick(); } }}> - {isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}} /> + {isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}} /> { navigation.push("Help", { context }) @@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) { const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; + // Players have no names on mobile yet (legacy users), except the bot. + const topBotName = getPlayerDisplayName({ userId: topLocatedUserId }, t); + const bottomBotName = getPlayerDisplayName({ userId: bottomLocatedUserId }, t); const topLocatedUser = { id: topLocatedUserId, - name: "Anonymous", + name: topBotName ?? "Anonymous", isOnline: isUserOnline(topLocatedUserId), - isAnonymous: true + isAnonymous: !topBotName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: "Anonymous", + name: bottomBotName ?? "Anonymous", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: true + isAnonymous: !bottomBotName }; const currentUser = isSpectator ? { id: "2", diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index aa7757b..0bbf6b4 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native'; import { useTranslation } from 'react-i18next'; import { HomeScreenProps } from '../types'; import CircularPanel from '../components/CircularPanel'; +import BotLevelPicker from '../components/BotLevelPicker'; import { SvgXml } from 'react-native-svg'; import helpSvg from '../svg/help'; import { getColorByBrightness } from "@mancala/core"; @@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { const { context } = route.params; const { t } = useTranslation(); const [lastGameId, setLastGameId] = React.useState(undefined); + const [choosingBotLevel, setChoosingBotLevel] = React.useState(false); const textColorOnAppBar = getColorByBrightness( context.themeManager.theme.appBarBgColor, @@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { navigation.replace('Loby', { context })}> {t('NewGame')}} /> + + {choosingBotLevel ? ( + + {t('ChooseBotLevel')} + navigation.replace('Loby', { context, botLevel })} /> + + ) : ( + setChoosingBotLevel(true)}> + {t('PlayWithBot')}} /> + + )} {lastGameId && ( <> diff --git a/apps/mobile/src/screens/LobyScreen.tsx b/apps/mobile/src/screens/LobyScreen.tsx index 7c5bf78..76648d4 100644 --- a/apps/mobile/src/screens/LobyScreen.tsx +++ b/apps/mobile/src/screens/LobyScreen.tsx @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'; import { LobyScreenProps } from '../types'; import { useEffect } from 'react'; import { CommonMancalaGame } from 'mancala.js'; -import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import CircularPanel from '../components/CircularPanel'; import { getColorByBrightness } from "@mancala/core"; import { SvgXml } from 'react-native-svg'; @@ -12,7 +13,13 @@ import helpSvg from '../svg/help'; export default function LobyScreen({ navigation, route }: LobyScreenProps) { - const { context } = route.params; + const { context, botLevel } = route.params; + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = React.useState(false); + + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } const { t } = useTranslation(); const onGameStart = async (message: Object) => { @@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); - context.rtmt.sendMessage("new_game", {}); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); } }, []); @@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { return ( - {t('SearchingOpponent') + " " + t('PleaseWait') + "..."}} /> + {(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}} /> + {botOffered && ( + + {t('NoOpponentYet')} + + + )} {t('Cancel')}} /> diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 4b2db02..190597a 100644 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -1,9 +1,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { BotLevel } from '@mancala/core'; import { Context } from '../context/context'; export type RootStackParamList = { Home: { context: Context }, - Loby: { context: Context }, + // botLevel: start a game against the bot at once instead of searching for an opponent. + Loby: { context: Context, botLevel?: BotLevel }, Game: { context: Context, gameId: string, userKey: string } Help: { context: Context } }; diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index dc3a7d3..3459e5f 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -16,6 +16,6 @@ Work through the tasks in order; each ends with its tests passing and a commit. `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. - [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot game, bot names everywhere players are shown. -- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. +- [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. - [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items).