Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
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