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:
parent
e66c4a3a41
commit
d1918fed10
31
apps/mobile/src/components/BotLevelPicker.tsx
Normal file
31
apps/mobile/src/components/BotLevelPicker.tsx
Normal 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;
|
||||
@ -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",
|
||||
|
||||
@ -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 }} />
|
||||
|
||||
@ -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>} />
|
||||
|
||||
@ -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 }
|
||||
};
|
||||
|
||||
@ -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).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user