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 { 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",
|
||||||
|
|||||||
@ -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 }} />
|
||||||
|
|||||||
@ -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>} />
|
||||||
|
|||||||
@ -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 }
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user