feat(mobile): offer games against the bot

The lobby offers the bot levels after 10 seconds without an opponent, the
home screen has Play with bot, the bot is shown with its level, and a new
game after a bot game is one at the same level.
This commit is contained in:
Halit Aksoy 2026-10-16 14:23:22 +03:00
parent e66c4a3a41
commit d1918fed10
6 changed files with 84 additions and 13 deletions

View File

@ -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 (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
{BOT_LEVELS.map((level) => (
<Pressable key={level} accessibilityRole="button" onPress={() => {
setLastBotLevel(context.storage, level);
onChoose(level);
}}>
<CircularPanel
color={level === lastLevel ? theme.boardColor : theme.background}
style={{ borderWidth: 1, borderColor: theme.boardColor }}
children={<Text style={{ color: theme.textColor }}>{t(getBotLevelTextKey(level))}</Text>} />
</Pressable>
))}
</View>
);
};
export default BotLevelPicker;

View File

@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } 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 { 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 { MancalaGame, Pit } from 'mancala.js';
import { v4 } from 'uuid'; 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'; 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 = () => { const onNewGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return;
context.storage.deleteString('lastGameId'); 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) => { const onPitSelect = (index: number, pit: Pit) => {
@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
onLeaveGameClick(); onLeaveGameClick();
} }
}}> }}>
<CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}</Text>} /> <CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}</Text>} />
</Pressable> </Pressable>
<Pressable onPress={() => { <Pressable onPress={() => {
navigation.push("Help", { context }) navigation.push("Help", { context })
@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
const showBoardView = game && boardViewModel && userKey && true; const showBoardView = game && boardViewModel && userKey && true;
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; 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 = { const topLocatedUser = {
id: topLocatedUserId, id: topLocatedUserId,
name: "Anonymous", name: topBotName ?? "Anonymous",
isOnline: isUserOnline(topLocatedUserId), isOnline: isUserOnline(topLocatedUserId),
isAnonymous: true isAnonymous: !topBotName
}; };
const bottomLocatedUser = { const bottomLocatedUser = {
id: bottomLocatedUserId, id: bottomLocatedUserId,
name: "Anonymous", name: bottomBotName ?? "Anonymous",
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
isAnonymous: true isAnonymous: !bottomBotName
}; };
const currentUser = isSpectator ? { const currentUser = isSpectator ? {
id: "2", id: "2",

View File

@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { HomeScreenProps } from '../types'; import { HomeScreenProps } from '../types';
import CircularPanel from '../components/CircularPanel'; import CircularPanel from '../components/CircularPanel';
import BotLevelPicker from '../components/BotLevelPicker';
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
import helpSvg from '../svg/help'; import helpSvg from '../svg/help';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
const { context } = route.params; const { context } = route.params;
const { t } = useTranslation(); const { t } = useTranslation();
const [lastGameId, setLastGameId] = React.useState<string | undefined>(undefined); const [lastGameId, setLastGameId] = React.useState<string | undefined>(undefined);
const [choosingBotLevel, setChoosingBotLevel] = React.useState(false);
const textColorOnAppBar = getColorByBrightness( const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor, context.themeManager.theme.appBarBgColor,
@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
<Pressable onPress={() => navigation.replace('Loby', { context })}> <Pressable onPress={() => navigation.replace('Loby', { context })}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} />
</Pressable> </Pressable>
<View style={{ height: 20 }} />
{choosingBotLevel ? (
<View style={{ alignItems: 'center', gap: 10, paddingHorizontal: 16 }}>
<Text style={{ color: context.themeManager.theme.textColor }}>{t('ChooseBotLevel')}</Text>
<BotLevelPicker context={context} onChoose={(botLevel) => navigation.replace('Loby', { context, botLevel })} />
</View>
) : (
<Pressable onPress={() => setChoosingBotLevel(true)}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('PlayWithBot')}</Text>} />
</Pressable>
)}
{lastGameId && ( {lastGameId && (
<> <>
<View style={{ height: 20 }} /> <View style={{ height: 20 }} />

View File

@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { LobyScreenProps } from '../types'; import { LobyScreenProps } from '../types';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { CommonMancalaGame } from 'mancala.js'; 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 CircularPanel from '../components/CircularPanel';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
@ -12,7 +13,13 @@ import helpSvg from '../svg/help';
export default function LobyScreen({ navigation, route }: LobyScreenProps) { 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 { t } = useTranslation();
const onGameStart = async (message: Object) => { const onGameStart = async (message: Object) => {
@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
useEffect(() => { useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.sendMessage("new_game", {}); let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
if (botLevel) {
startBotGame(botLevel);
} else {
context.rtmt.sendMessage(channel_new_game, {});
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
}
return () => { return () => {
if (botOfferTimer) clearTimeout(botOfferTimer);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
} }
}, []); }, []);
@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
return ( return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: context.themeManager.theme?.background }}> <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: context.themeManager.theme?.background }}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: "#000" }}>{t('SearchingOpponent') + " " + t('PleaseWait') + "..."}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: "#000" }}>{(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}</Text>} />
{botOffered && (
<View style={{ alignItems: 'center', gap: 10, marginTop: 20, paddingHorizontal: 16 }}>
<Text style={{ color: context.themeManager.theme.textColor }}>{t('NoOpponentYet')}</Text>
<BotLevelPicker context={context} onChoose={startBotGame} />
</View>
)}
<View style={{height: 10}}></View> <View style={{height: 10}}></View>
<Pressable onPress={onCancelNewGameClick}> <Pressable onPress={onCancelNewGameClick}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('Cancel')}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('Cancel')}</Text>} />

View File

@ -1,9 +1,11 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BotLevel } from '@mancala/core';
import { Context } from '../context/context'; import { Context } from '../context/context';
export type RootStackParamList = { export type RootStackParamList = {
Home: { context: Context }, 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 } Game: { context: Context, gameId: string, userKey: string }
Help: { context: Context } Help: { context: Context }
}; };

View File

@ -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. `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 - [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. 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`, - [ ] 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). `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items).