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.
This commit is contained in:
parent
8671bb2553
commit
f9ea00d138
42
apps/mobile/src/components/GameClockView.tsx
Normal file
42
apps/mobile/src/components/GameClockView.tsx
Normal file
@ -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 (
|
||||||
|
<Text accessibilityRole="timer" style={[styles.clock, { backgroundColor, color, borderColor: theme.boardColor }]}>
|
||||||
|
{formatClock(timeMs)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
clock: {
|
||||||
|
paddingVertical: 2,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderRadius: 30,
|
||||||
|
borderWidth: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default GameClockView;
|
||||||
@ -69,6 +69,8 @@ const InfoPanel: FunctionComponent<{
|
|||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
isSpectator?: boolean;
|
isSpectator?: boolean;
|
||||||
|
// Shown instead of the text derived from the game, e.g. why a game ended.
|
||||||
|
overrideText?: string;
|
||||||
}> = ({
|
}> = ({
|
||||||
context,
|
context,
|
||||||
game,
|
game,
|
||||||
@ -78,7 +80,8 @@ const InfoPanel: FunctionComponent<{
|
|||||||
leftPlayer,
|
leftPlayer,
|
||||||
style,
|
style,
|
||||||
visible,
|
visible,
|
||||||
isSpectator
|
isSpectator,
|
||||||
|
overrideText
|
||||||
}) => {
|
}) => {
|
||||||
if (visible === false) return <></>;
|
if (visible === false) return <></>;
|
||||||
const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false;
|
const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false;
|
||||||
@ -90,7 +93,8 @@ const InfoPanel: FunctionComponent<{
|
|||||||
context.themeManager.theme.textColor,
|
context.themeManager.theme.textColor,
|
||||||
context.themeManager.theme.textLightColor
|
context.themeManager.theme.textLightColor
|
||||||
);
|
);
|
||||||
const text = getInfoPanelTextByGameState({
|
// Always computed: it uses a hook, so it must run on every render.
|
||||||
|
const gameStateText = getInfoPanelTextByGameState({
|
||||||
context,
|
context,
|
||||||
game,
|
game,
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -99,6 +103,7 @@ const InfoPanel: FunctionComponent<{
|
|||||||
leftPlayer,
|
leftPlayer,
|
||||||
isSpectator
|
isSpectator
|
||||||
});
|
});
|
||||||
|
const text = overrideText ?? gameStateText;
|
||||||
if (text) {
|
if (text) {
|
||||||
return (
|
return (
|
||||||
<CircularPanel style={style} color={containerColor}>
|
<CircularPanel style={style} color={containerColor}>
|
||||||
|
|||||||
@ -15,8 +15,10 @@ const UserStatus: FunctionComponent<{
|
|||||||
user: User,
|
user: User,
|
||||||
layoutMode: LayoutMode,
|
layoutMode: LayoutMode,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
style?: ViewStyle
|
style?: ViewStyle,
|
||||||
}> = ({ context, user, layoutMode, visible, style }) => {
|
// Shown after the online status, e.g. the player's clock.
|
||||||
|
accessory?: React.ReactNode,
|
||||||
|
}> = ({ context, user, layoutMode, visible, style, accessory }) => {
|
||||||
if (visible === false) return <></>;
|
if (visible === false) return <></>;
|
||||||
const textColorOnBoard = getColorByBrightness(
|
const textColorOnBoard = getColorByBrightness(
|
||||||
context.themeManager.theme.background,
|
context.themeManager.theme.background,
|
||||||
@ -32,6 +34,7 @@ const UserStatus: FunctionComponent<{
|
|||||||
</Text>
|
</Text>
|
||||||
<Space width={5} />
|
<Space width={5} />
|
||||||
<View style={[styles.circle, (user.isOnline? styles.online: styles.offline)]} />
|
<View style={[styles.circle, (user.isOnline? styles.online: styles.offline)]} />
|
||||||
|
{accessory && <><Space width={6} />{accessory}</>}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/mobile/src/hooks/useGameClock.ts
Normal file
27
apps/mobile/src/hooks/useGameClock.ts
Normal file
@ -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<Game | undefined>(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();
|
||||||
|
}
|
||||||
@ -3,10 +3,10 @@ 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 } from "@mancala/core";
|
import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat } 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 } 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 Util from '../util/Util';
|
||||||
import Snackbar from 'react-native-snackbar';
|
import Snackbar from 'react-native-snackbar';
|
||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
@ -19,6 +19,8 @@ import BoardView from '../components/board/BoardView';
|
|||||||
import CircularPanel from '../components/CircularPanel';
|
import CircularPanel from '../components/CircularPanel';
|
||||||
import { SvgXml } from 'react-native-svg';
|
import { SvgXml } from 'react-native-svg';
|
||||||
import helpSvg from '../svg/help';
|
import helpSvg from '../svg/help';
|
||||||
|
import GameClockView from '../components/GameClockView';
|
||||||
|
import useGameClock from '../hooks/useGameClock';
|
||||||
export function GameScreen({ navigation, route }: GameScreenProps) {
|
export function GameScreen({ navigation, route }: GameScreenProps) {
|
||||||
|
|
||||||
const { context, gameId, userKey } = route.params;
|
const { context, gameId, userKey } = route.params;
|
||||||
@ -40,6 +42,9 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
|
|
||||||
const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState<GameUsersConnectionInfo | undefined>();
|
const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState<GameUsersConnectionInfo | undefined>();
|
||||||
|
|
||||||
|
// Set when the server says the game ended, for whatever reason.
|
||||||
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
|
|
||||||
const { height, width } = useWindowDimensions();
|
const { height, width } = useWindowDimensions();
|
||||||
|
|
||||||
const [gameLoadingState, setLoadingStateGame] = useState<LoadingState<Game>>(LoadingState.Unset());
|
const [gameLoadingState, setLoadingStateGame] = useState<LoadingState<Game>>(LoadingState.Unset());
|
||||||
@ -86,6 +91,11 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
setHasOngoingAction(false);
|
setHasOngoingAction(false);
|
||||||
context.storage.deleteString('lastGameId');
|
context.storage.deleteString('lastGameId');
|
||||||
};
|
};
|
||||||
|
const onGameEnd = (message: GameEndMessage) => {
|
||||||
|
setGameEnd(message);
|
||||||
|
setHasOngoingAction(false);
|
||||||
|
context.storage.deleteString('lastGameId');
|
||||||
|
};
|
||||||
const onUserConnectionChange = (message: any) => {
|
const onUserConnectionChange = (message: any) => {
|
||||||
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
||||||
setGameUsersConnectionInfo(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_crashed, onGameCrashed);
|
||||||
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
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);
|
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id);
|
||||||
return () => {
|
return () => {
|
||||||
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_unlisten_game_events, game.id);
|
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_crashed, onGameCrashed);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
context.rtmt.removeMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
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) {
|
if (!game || isSpectator || !userKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (userKeyWhoLeave) {
|
if (userKeyWhoLeave || gameEnd) {
|
||||||
Snackbar.show({ text: t("GameEnded") });
|
Snackbar.show({ text: t("GameEnded") });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -219,7 +231,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateHeaderButton = () => {
|
const updateHeaderButton = () => {
|
||||||
const isGameEnded = game?.mancalaGame.state === "ended" || leftPlayer;
|
const isGameEnded = game?.mancalaGame.state === "ended" || leftPlayer || gameEnd;
|
||||||
const isError = gameLoadingState.isError();
|
const isError = gameLoadingState.isError();
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@ -253,7 +265,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
updateHeaderButton();
|
updateHeaderButton();
|
||||||
}, [navigation, game, userKeyWhoLeave, gameLoadingState]);
|
}, [navigation, game, userKeyWhoLeave, gameEnd, gameLoadingState]);
|
||||||
|
|
||||||
const textColorOnAppBar = getColorByBrightness(
|
const textColorOnAppBar = getColorByBrightness(
|
||||||
context.themeManager.theme.appBarBgColor,
|
context.themeManager.theme.appBarBgColor,
|
||||||
@ -285,6 +297,18 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
isAnonymous: true
|
isAnonymous: true
|
||||||
} : bottomLocatedUser;
|
} : bottomLocatedUser;
|
||||||
const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined;
|
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 <GameClockView context={context} timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<PageContainer context={context}>
|
<PageContainer context={context}>
|
||||||
{/* {renderHeaderBar()} */}
|
{/* {renderHeaderBar()} */}
|
||||||
@ -336,10 +360,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
|
|
||||||
function buildBoardTopToolbar() {
|
function buildBoardTopToolbar() {
|
||||||
return <BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
|
return <BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
|
||||||
<UserStatus style={{ marginLeft: 10, width: 100 }} context={context}
|
<UserStatus style={{ marginLeft: 10, width: 160 }} context={context}
|
||||||
layoutMode="left" user={topLocatedUser} visible={showBoardView || false} />
|
layoutMode="left" user={topLocatedUser} visible={showBoardView || false}
|
||||||
|
accessory={renderClock(topLocatedUserId)} />
|
||||||
{buildInfoPanel({ visible: !isMobile })}
|
{buildInfoPanel({ visible: !isMobile })}
|
||||||
<UserStatus style={{ marginLeft: 10, width: 100 }} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false} />
|
<UserStatus style={{ marginRight: 10, width: 160 }} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false}
|
||||||
|
accessory={renderClock(bottomLocatedUserId)} />
|
||||||
</BoardToolbar>;
|
</BoardToolbar>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,7 +380,8 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
blackPlayer={bottomLocatedUser}
|
blackPlayer={bottomLocatedUser}
|
||||||
leftPlayer={leftPlayer}
|
leftPlayer={leftPlayer}
|
||||||
visible={params.visible}
|
visible={params.visible}
|
||||||
isSpectator={isSpectator} />
|
isSpectator={isSpectator}
|
||||||
|
overrideText={endText} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`,
|
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).
|
`UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then).
|
||||||
- [x] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
|
- [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
|
- [ ] 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.
|
update before or with the backend; the out of scope items); mark `game-history` R3.3 and R3.4 as replaced.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user