diff --git a/apps/mobile/src/components/GameClockView.tsx b/apps/mobile/src/components/GameClockView.tsx
new file mode 100644
index 0000000..267f58c
--- /dev/null
+++ b/apps/mobile/src/components/GameClockView.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react';
+import { FunctionComponent } from 'react';
+import { StyleSheet, Text } from 'react-native';
+import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core";
+import { Context } from '../context/context';
+
+// Readable on every theme; the themes have no warning color of their own.
+const LOW_TIME_COLOR = "#b3261e";
+const LOW_TIME_TEXT_COLOR = "#ffffff";
+
+// One player's clock: highlighted while it runs, in a warning color when the running clock is low.
+const GameClockView: FunctionComponent<{
+ context: Context,
+ timeMs: number,
+ running: boolean,
+}> = ({ context, timeMs, running }) => {
+ const theme = context.themeManager.theme;
+ const lowTime = running && timeMs < CLOCK_LOW_TIME_MS;
+ const backgroundColor = lowTime ? LOW_TIME_COLOR : running ? theme.playerTurnColor : "transparent";
+ const color = lowTime ? LOW_TIME_TEXT_COLOR : running
+ ? getColorByBrightness(theme.playerTurnColor, theme.textColor, theme.textLightColor)
+ : getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
+ return (
+
+ {formatClock(timeMs)}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ clock: {
+ paddingVertical: 2,
+ paddingHorizontal: 8,
+ borderRadius: 30,
+ borderWidth: 2,
+ overflow: 'hidden',
+ fontWeight: 'bold',
+ fontVariant: ['tabular-nums'],
+ },
+});
+
+export default GameClockView;
diff --git a/apps/mobile/src/components/InfoPanel.tsx b/apps/mobile/src/components/InfoPanel.tsx
index eb5680f..8c07e58 100644
--- a/apps/mobile/src/components/InfoPanel.tsx
+++ b/apps/mobile/src/components/InfoPanel.tsx
@@ -69,6 +69,8 @@ const InfoPanel: FunctionComponent<{
style?: ViewStyle;
visible?: boolean;
isSpectator?: boolean;
+ // Shown instead of the text derived from the game, e.g. why a game ended.
+ overrideText?: string;
}> = ({
context,
game,
@@ -78,7 +80,8 @@ const InfoPanel: FunctionComponent<{
leftPlayer,
style,
visible,
- isSpectator
+ isSpectator,
+ overrideText
}) => {
if (visible === false) return <>>;
const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false;
@@ -90,7 +93,8 @@ const InfoPanel: FunctionComponent<{
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
- const text = getInfoPanelTextByGameState({
+ // Always computed: it uses a hook, so it must run on every render.
+ const gameStateText = getInfoPanelTextByGameState({
context,
game,
currentUser,
@@ -99,6 +103,7 @@ const InfoPanel: FunctionComponent<{
leftPlayer,
isSpectator
});
+ const text = overrideText ?? gameStateText;
if (text) {
return (
diff --git a/apps/mobile/src/components/UserStatus.tsx b/apps/mobile/src/components/UserStatus.tsx
index e7a6dd6..6a91adc 100644
--- a/apps/mobile/src/components/UserStatus.tsx
+++ b/apps/mobile/src/components/UserStatus.tsx
@@ -15,8 +15,10 @@ const UserStatus: FunctionComponent<{
user: User,
layoutMode: LayoutMode,
visible?: boolean,
- style?: ViewStyle
-}> = ({ context, user, layoutMode, visible, style }) => {
+ style?: ViewStyle,
+ // Shown after the online status, e.g. the player's clock.
+ accessory?: React.ReactNode,
+}> = ({ context, user, layoutMode, visible, style, accessory }) => {
if (visible === false) return <>>;
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.background,
@@ -32,6 +34,7 @@ const UserStatus: FunctionComponent<{
+ {accessory && <>{accessory}>}
);
}
diff --git a/apps/mobile/src/hooks/useGameClock.ts b/apps/mobile/src/hooks/useGameClock.ts
new file mode 100644
index 0000000..cc64163
--- /dev/null
+++ b/apps/mobile/src/hooks/useGameClock.ts
@@ -0,0 +1,27 @@
+import { useEffect, useRef, useState } from "react";
+import { ClockState, ClockSync, Game, GameEndMessage } from "@mancala/core";
+
+const TICK_MS = 250;
+
+// Both players' clocks in a live game, counting down between the server's messages. Undefined when the server sends
+// no clock.
+export default function useGameClock(game: Game | undefined, gameEnd: GameEndMessage | undefined): ClockState | undefined {
+ const sync = useRef(new ClockSync()).current;
+ const lastGame = useRef(undefined);
+ // Every game message is a new object, so a changed reference means a new message.
+ if (game && game !== lastGame.current) {
+ sync.update(game);
+ lastGame.current = game;
+ }
+ if (gameEnd) sync.stop(gameEnd.clock);
+
+ const [, setTick] = useState(0);
+ const isRunning = sync.isRunning;
+ useEffect(() => {
+ if (!isRunning) return;
+ const timer = setInterval(() => setTick((tick) => tick + 1), TICK_MS);
+ return () => clearInterval(timer);
+ }, [isRunning]);
+
+ return sync.getTimes();
+}
diff --git a/apps/mobile/src/screens/GameScreen.tsx b/apps/mobile/src/screens/GameScreen.tsx
index b7cfa02..d05709e 100644
--- a/apps/mobile/src/screens/GameScreen.tsx
+++ b/apps/mobile/src/screens/GameScreen.tsx
@@ -3,10 +3,10 @@ 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 } from "@mancala/core";
+import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat } 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 } 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';
import Util from '../util/Util';
import Snackbar from 'react-native-snackbar';
import PageContainer from '../components/PageContainer';
@@ -19,6 +19,8 @@ import BoardView from '../components/board/BoardView';
import CircularPanel from '../components/CircularPanel';
import { SvgXml } from 'react-native-svg';
import helpSvg from '../svg/help';
+import GameClockView from '../components/GameClockView';
+import useGameClock from '../hooks/useGameClock';
export function GameScreen({ navigation, route }: GameScreenProps) {
const { context, gameId, userKey } = route.params;
@@ -40,6 +42,9 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState();
+ // Set when the server says the game ended, for whatever reason.
+ const [gameEnd, setGameEnd] = useState(undefined);
+
const { height, width } = useWindowDimensions();
const [gameLoadingState, setLoadingStateGame] = useState>(LoadingState.Unset());
@@ -86,6 +91,11 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
setHasOngoingAction(false);
context.storage.deleteString('lastGameId');
};
+ const onGameEnd = (message: GameEndMessage) => {
+ setGameEnd(message);
+ setHasOngoingAction(false);
+ context.storage.deleteString('lastGameId');
+ };
const onUserConnectionChange = (message: any) => {
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
setGameUsersConnectionInfo(gameUsersConnectionInfo);
@@ -97,6 +107,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
+ context.rtmt.addMessageListener(channel_on_game_end, onGameEnd);
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id);
return () => {
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_unlisten_game_events, game.id);
@@ -104,6 +115,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
context.rtmt.removeMessageListener(channel_on_user_connection_change, onUserConnectionChange);
+ context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd);
}
};
@@ -157,7 +169,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
if (!game || isSpectator || !userKey) {
return;
}
- if (userKeyWhoLeave) {
+ if (userKeyWhoLeave || gameEnd) {
Snackbar.show({ text: t("GameEnded") });
return;
}
@@ -219,7 +231,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
}, []);
const updateHeaderButton = () => {
- const isGameEnded = game?.mancalaGame.state === "ended" || leftPlayer;
+ const isGameEnded = game?.mancalaGame.state === "ended" || leftPlayer || gameEnd;
const isError = gameLoadingState.isError();
navigation.setOptions({
headerRight: () => (
@@ -253,7 +265,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
React.useEffect(() => {
updateHeaderButton();
- }, [navigation, game, userKeyWhoLeave, gameLoadingState]);
+ }, [navigation, game, userKeyWhoLeave, gameEnd, gameLoadingState]);
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
@@ -285,6 +297,18 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
isAnonymous: true
} : bottomLocatedUser;
const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined;
+ // A game that ended on time is not over by the rules, so say why it ended.
+ const endText = gameEnd?.endReason !== "timeout" ? undefined
+ : isSpectator ? t("GameEnded")
+ : gameEnd.winnerId === userKey ? `${t("GameEnded")} · ${t("YouWon")} · ${t("OpponentTimedOut")}`
+ : `${t("GameEnded")} · ${t("YouLost")} · ${t("YouTimedOut")}`;
+ const clockTimes = useGameClock(game, gameEnd);
+ const renderClock = (playerId: string) => {
+ const seat = mancalaGame && getSeat(mancalaGame, playerId);
+ if (!clockTimes || !seat) return undefined;
+ const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms;
+ return ;
+ };
return (
{/* {renderHeaderBar()} */}
@@ -336,10 +360,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
function buildBoardTopToolbar() {
return
-
+
{buildInfoPanel({ visible: !isMobile })}
-
+
;
}
@@ -354,7 +380,8 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
blackPlayer={bottomLocatedUser}
leftPlayer={leftPlayer}
visible={params.visible}
- isSpectator={isSpectator} />
+ isSpectator={isSpectator}
+ overrideText={endText} />
);
}
}
diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md
index 6455d90..793252e 100644
--- a/docs/specs/game-clock/tasks.md
+++ b/docs/specs/game-clock/tasks.md
@@ -16,6 +16,6 @@ Work through the tasks in order; each ends with its tests passing and a commit.
layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`,
`UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then).
- [x] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
-- [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle.
+- [x] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle.
- [ ] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile
update before or with the backend; the out of scope items); mark `game-history` R3.3 and R3.4 as replaced.