From f9ea00d1381076580252c80613b48893cd97845a Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:50:39 +0300 Subject: [PATCH] feat(mobile): show the game clocks Both clocks next to the players, counting down in server time with the same running and low-time styles as the web app. The game screen now handles on_game_end, so a game that ends on time ends on the phone too and says why. --- apps/mobile/src/components/GameClockView.tsx | 42 ++++++++++++++++++ apps/mobile/src/components/InfoPanel.tsx | 9 +++- apps/mobile/src/components/UserStatus.tsx | 7 ++- apps/mobile/src/hooks/useGameClock.ts | 27 ++++++++++++ apps/mobile/src/screens/GameScreen.tsx | 45 ++++++++++++++++---- docs/specs/game-clock/tasks.md | 2 +- 6 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/components/GameClockView.tsx create mode 100644 apps/mobile/src/hooks/useGameClock.ts 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.