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 { 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();
}
}}>
<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 onPress={() => {
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",

View File

@ -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<string | undefined>(undefined);
const [choosingBotLevel, setChoosingBotLevel] = React.useState(false);
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
<Pressable onPress={() => navigation.replace('Loby', { context })}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} />
</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 && (
<>
<View style={{ height: 20 }} />

View File

@ -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<typeof setTimeout> | 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 (
<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>
<Pressable onPress={onCancelNewGameClick}>
<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 { 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 }
};

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.
- [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).