feat(mobile): show the rules

A How to play screen with the same rules and examples as the web app, opened
from the help screen and from the game screen's help button.
This commit is contained in:
Halit Aksoy 2026-10-16 14:59:33 +03:00
parent f8ce8f215f
commit 636127dae4
7 changed files with 87 additions and 3 deletions

View File

@ -15,6 +15,7 @@ import { NativeModules, I18nManager, Platform } from 'react-native'
// https://github.com/uuidjs/uuid/issues/514#issuecomment-691475020 // https://github.com/uuidjs/uuid/issues/514#issuecomment-691475020
import 'react-native-get-random-values'; import 'react-native-get-random-values';
import HelpScreen from './screens/HelpScreen'; import HelpScreen from './screens/HelpScreen';
import RulesScreen from './screens/RulesScreen';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
import { server } from './const/config'; import { server } from './const/config';
@ -120,6 +121,8 @@ function App() {
options={{ title: t("Mancala"), headerStyle, headerTintColor }} /> options={{ title: t("Mancala"), headerStyle, headerTintColor }} />
<Stack.Screen name="Help" component={HelpScreen} <Stack.Screen name="Help" component={HelpScreen}
options={{ title: t("Help"), headerStyle, headerTintColor }} /> options={{ title: t("Help"), headerStyle, headerTintColor }} />
<Stack.Screen name="Rules" component={RulesScreen}
options={{ title: t("HowToPlay"), headerStyle, headerTintColor }} />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
); );

View File

@ -0,0 +1,49 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
import { Text, View } from 'react-native';
import { getColorByBrightness, getMoveOutcome, RuleExample, Theme } from '@mancala/core';
const TOP_PITS = [12, 11, 10, 9, 8, 7];
const BOTTOM_PITS = [0, 1, 2, 3, 4, 5];
// Stone counts on a small board as the bottom player sees it; `played` and `landed` mark a move.
const MiniBoard: FunctionComponent<{ board: number[], theme: Theme, played?: number, landed?: number }> = ({ board, theme, played, landed }) => {
const textOn = (color: string) => getColorByBrightness(color, theme.textColor, theme.textLightColor);
const cell = (index: number, store: boolean) => {
const color = index === landed ? theme.pitSelectedColor : theme.pitColor;
return (
<View key={index} style={{
flex: store ? 1.2 : 1, aspectRatio: store ? undefined : 1, minHeight: store ? undefined : 0,
borderRadius: 100, backgroundColor: color, alignItems: 'center', justifyContent: 'center',
borderWidth: index === played ? 2 : 0, borderColor: theme.playerTurnColor,
}}>
<Text style={{ fontSize: 11, fontWeight: '700', color: textOn(color) }}>{board[index] || ''}</Text>
</View>
);
};
return (
<View style={{ flexDirection: 'row', gap: 4, padding: 6, borderRadius: 12, backgroundColor: theme.boardColor }}>
{cell(13, true)}
<View style={{ flex: 6, gap: 4 }}>
<View style={{ flexDirection: 'row', gap: 4 }}>{TOP_PITS.map((index) => cell(index, false))}</View>
<View style={{ flexDirection: 'row', gap: 4 }}>{BOTTOM_PITS.map((index) => cell(index, false))}</View>
</View>
{cell(6, true)}
</View>
);
};
// A rule's example: the board before the move and after it, as the engine plays it.
const RuleBoard: FunctionComponent<{ example: RuleExample, theme: Theme }> = ({ example, theme }) => {
if (example.pitIndex === undefined) return <MiniBoard board={example.board} theme={theme} />;
const outcome = getMoveOutcome(example.board, example.pitIndex);
return (
<View style={{ gap: 6 }}>
<MiniBoard board={example.board} theme={theme} played={example.pitIndex} />
<Text style={{ textAlign: 'center', color: theme.textColor }}>↓</Text>
<MiniBoard board={outcome.board} theme={theme} landed={outcome.path[outcome.path.length - 1]} />
</View>
);
};
export default RuleBoard;

View File

@ -255,7 +255,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
<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>} /> <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("Rules", { context })
}}> }}>
<SvgXml xml={helpSvg.replaceAll("customColor", textColorOnAppBar)} width="24" height="24" /> <SvgXml xml={helpSvg.replaceAll("customColor", textColorOnAppBar)} width="24" height="24" />
</Pressable> </Pressable>

View File

@ -31,6 +31,9 @@ export default function HelpScreen({ navigation, route }: HelpScreenProps) {
alignContent: "center", alignContent: "center",
justifyContent: "center" justifyContent: "center"
}}> }}>
<Pressable onPress={() => navigation.push("Rules", { context })}>
<Text style={{ textAlign: "center", color: context.themeManager.theme.textColor }}>{t('HowToPlay')}</Text>
</Pressable>
<Pressable onPress={onGotoWebAppClick}> <Pressable onPress={onGotoWebAppClick}>
<Text style={{ textAlign: "center", color: context.themeManager.theme.textColor }}>{t('WebApp')}</Text> <Text style={{ textAlign: "center", color: context.themeManager.theme.textColor }}>{t('WebApp')}</Text>
</Pressable> </Pressable>

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ScrollView, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { RULES } from '@mancala/core';
import { RulesScreenProps } from '../types';
import RuleBoard from '../components/RuleBoard';
// How to play: the same rules and examples as the web app (docs/specs/tutorial, R5.1).
export default function RulesScreen({ route }: RulesScreenProps) {
const { context } = route.params;
const { t } = useTranslation();
const theme = context.themeManager.theme;
return (
<ScrollView style={{ flex: 1, backgroundColor: theme.background }} contentContainerStyle={{ padding: 16, gap: 24 }}>
{RULES.map((rule) => (
<View key={rule.id} style={{ gap: 8 }}>
<Text style={{ fontSize: 18, fontWeight: '700', color: theme.textColor }}>{t(rule.titleKey)}</Text>
{rule.textKeys.map((key) => (
<Text key={key} style={{ color: theme.textColor, lineHeight: 20 }}>{t(key)}</Text>
))}
{rule.example && <RuleBoard example={rule.example} theme={theme} />}
</View>
))}
</ScrollView>
);
}

View File

@ -8,6 +8,7 @@ export type RootStackParamList = {
Loby: { context: Context, botLevel?: BotLevel }, 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 }
Rules: { context: Context }
}; };
export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>; export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
@ -17,3 +18,5 @@ export type LobyScreenProps = NativeStackScreenProps<RootStackParamList, 'Loby'>
export type GameScreenProps = NativeStackScreenProps<RootStackParamList, 'Game'>; export type GameScreenProps = NativeStackScreenProps<RootStackParamList, 'Game'>;
export type HelpScreenProps = NativeStackScreenProps<RootStackParamList, 'Help'>; export type HelpScreenProps = NativeStackScreenProps<RootStackParamList, 'Help'>;
export type RulesScreenProps = NativeStackScreenProps<RootStackParamList, 'Rules'>;

View File

@ -11,6 +11,6 @@ Work through the tasks in order; each ends with its tests passing and a commit.
reset, scripted opponent replies. reset, scripted opponent replies.
- [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button. - [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
- [x] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`. - [x] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
- [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle. - [x] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle.
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope - [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope
items). items).