Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
40
apps/frontend/src/components/GameClockView.tsx
Normal file
40
apps/frontend/src/components/GameClockView.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
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 background = 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 (
|
||||||
|
<span className="clock" style={{ background, color }} role="timer">
|
||||||
|
{formatClock(timeMs)}
|
||||||
|
<style jsx>{`
|
||||||
|
.clock {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 30px;
|
||||||
|
border: 2px solid ${theme.boardColor};
|
||||||
|
font-weight: bold;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.5s, color 0.5s;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GameClockView;
|
||||||
@ -13,8 +13,10 @@ const UserStatus: FunctionComponent<{
|
|||||||
user: User,
|
user: User,
|
||||||
layoutMode: LayoutMode,
|
layoutMode: LayoutMode,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties,
|
||||||
}> = ({ 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 { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -29,6 +31,7 @@ const UserStatus: FunctionComponent<{
|
|||||||
<span style={{color: textColorOnBoard, transition: 'color 0.5s'}} className='text'>{user.isAnonymous ? t("Anonymous") : user.name}</span>
|
<span style={{color: textColorOnBoard, transition: 'color 0.5s'}} className='text'>{user.isAnonymous ? t("Anonymous") : user.name}</span>
|
||||||
<Space width='5px' />
|
<Space width='5px' />
|
||||||
<div className={"circle " + (user.isOnline ? "online" : "offline")}></div>
|
<div className={"circle " + (user.isOnline ? "online" : "offline")}></div>
|
||||||
|
{accessory && <><Space width='8px' />{accessory}</>}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.online {
|
.online {
|
||||||
background-color: ${context.themeManager.theme.boardColor};
|
background-color: ${context.themeManager.theme.boardColor};
|
||||||
|
|||||||
27
apps/frontend/src/hooks/useGameClock.ts
Normal file
27
apps/frontend/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>();
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ import LoadingComponent from '../components/LoadingComponent';
|
|||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import Row from '../components/Row';
|
import Row from '../components/Row';
|
||||||
import UserStatus from '../components/UserStatus';
|
import UserStatus from '../components/UserStatus';
|
||||||
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, PlayedMove, TURN_TIMEOUT_MS } from '@mancala/core';
|
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core';
|
||||||
import { Context } from '../context/context';
|
import { Context } from '../context/context';
|
||||||
import useWindowDimensions from '../hooks/useWindowDimensions';
|
import useWindowDimensions from '../hooks/useWindowDimensions';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
@ -34,8 +34,9 @@ import ReviewBanner from '../components/history/ReviewBanner';
|
|||||||
import useMoveKeys from '../hooks/useMoveKeys';
|
import useMoveKeys from '../hooks/useMoveKeys';
|
||||||
import { createBoardViewModel } from '../util/BoardViewModels';
|
import { createBoardViewModel } from '../util/BoardViewModels';
|
||||||
import { getResultText } from '../util/GameResultText';
|
import { getResultText } from '../util/GameResultText';
|
||||||
import CircularPanel from '../components/CircularPanel';
|
|
||||||
import ReplayView from '../components/history/ReplayView';
|
import ReplayView from '../components/history/ReplayView';
|
||||||
|
import GameClockView from '../components/GameClockView';
|
||||||
|
import useGameClock from '../hooks/useGameClock';
|
||||||
|
|
||||||
const GamePage: FunctionComponent<{
|
const GamePage: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
@ -69,9 +70,6 @@ const GamePage: FunctionComponent<{
|
|||||||
// Set when the server says the game ended, for whatever reason.
|
// Set when the server says the game ended, for whatever reason.
|
||||||
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
|
|
||||||
// Ticks every second while a countdown is shown.
|
|
||||||
const [now, setNow] = useState<number>(Date.now());
|
|
||||||
|
|
||||||
const { height, width } = useWindowDimensions();
|
const { height, width } = useWindowDimensions();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -306,21 +304,12 @@ const GamePage: FunctionComponent<{
|
|||||||
} as FinishedGameSummary, isSpectator ? undefined : userKey, t)}`
|
} as FinishedGameSummary, isSpectator ? undefined : userKey, t)}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Countdown for the waiting player while the opponent is offline on their turn.
|
const clockTimes = useGameClock(game, gameEnd);
|
||||||
const opponentInfo = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo]
|
const renderClock = (playerId: string) => {
|
||||||
.find((info) => info && info.userId !== userKey);
|
const seat = mancalaGame && getSeat(mancalaGame, playerId);
|
||||||
const countdownMs = !isSpectator && !isGameOver && opponentInfo && !opponentInfo.isOnline
|
if (!clockTimes || !seat) return undefined;
|
||||||
&& opponentInfo.offlineSince !== undefined && mancalaGame?.turnPlayerId === opponentInfo.userId
|
const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms;
|
||||||
? Math.max(0, opponentInfo.offlineSince + TURN_TIMEOUT_MS - now)
|
return <GameClockView context={context} timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
|
||||||
: undefined;
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (countdownMs === undefined) return;
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [countdownMs === undefined]);
|
|
||||||
const formatCountdown = (ms: number) => {
|
|
||||||
const seconds = Math.ceil(ms / 1000);
|
|
||||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
||||||
};
|
};
|
||||||
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";
|
||||||
@ -355,11 +344,6 @@ const GamePage: FunctionComponent<{
|
|||||||
{renderHeaderBar()}
|
{renderHeaderBar()}
|
||||||
{renderMobileBoardToolbar()}
|
{renderMobileBoardToolbar()}
|
||||||
{buildBoardTopToolbar()}
|
{buildBoardTopToolbar()}
|
||||||
{showBoardView && countdownMs !== undefined && (
|
|
||||||
<CircularPanel color={context.themeManager.theme.playerTurnColor} style={{ marginBottom: "0.5rem" }}>
|
|
||||||
{t("OpponentOfflineCountdown", { time: formatCountdown(countdownMs) })}
|
|
||||||
</CircularPanel>
|
|
||||||
)}
|
|
||||||
{showBoardView && isReviewing && (
|
{showBoardView && isReviewing && (
|
||||||
<ReviewBanner
|
<ReviewBanner
|
||||||
context={context}
|
context={context}
|
||||||
@ -423,19 +407,22 @@ const GamePage: FunctionComponent<{
|
|||||||
<UserStatus style={{
|
<UserStatus style={{
|
||||||
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
|
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
|
||||||
width: isMobile ? "40vw" : "30vw"
|
width: isMobile ? "40vw" : "30vw"
|
||||||
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false} />
|
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false}
|
||||||
|
accessory={renderClock(topLocatedUserId)} />
|
||||||
{buildInfoPanel({ visible: !isMobile })}
|
{buildInfoPanel({ visible: !isMobile })}
|
||||||
<UserStatus style={{
|
<UserStatus style={{
|
||||||
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
|
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
|
||||||
width: isMobile ? "40vw" : "30vw"
|
width: isMobile ? "40vw" : "30vw"
|
||||||
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false} />
|
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false}
|
||||||
|
accessory={renderClock(bottomLocatedUserId)} />
|
||||||
</BoardToolbar>;
|
</BoardToolbar>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInfoPanel(params: { visible: boolean }) {
|
function buildInfoPanel(params: { visible: boolean }) {
|
||||||
return (
|
return (
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
style={{ marginTop: "0.5rem", marginBottom: "0.5rem" }}
|
// Long texts such as "Game Ended · You Won · Opponent ran out of time" wrap on phones.
|
||||||
|
style={{ marginTop: "0.5rem", marginBottom: "0.5rem", ...(isMobile && { whiteSpace: "normal", textAlign: "center" }) }}
|
||||||
context={context}
|
context={context}
|
||||||
game={game}
|
game={game}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
`checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests.
|
`checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests.
|
||||||
- [x] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and
|
- [x] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and
|
||||||
`clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests.
|
`clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests.
|
||||||
- [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile
|
- [x] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile
|
||||||
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).
|
||||||
- [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
|
- [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
|
||||||
|
|||||||
@ -72,5 +72,4 @@ export const en = {
|
|||||||
WatchFromStart: "Watch from start",
|
WatchFromStart: "Watch from start",
|
||||||
Speed: "Speed",
|
Speed: "Speed",
|
||||||
Duration: "Duration",
|
Duration: "Duration",
|
||||||
OpponentOfflineCountdown: "Opponent is offline. You win in {{time}} if they don't come back."
|
|
||||||
}
|
}
|
||||||
@ -72,5 +72,4 @@ export const tr = {
|
|||||||
WatchFromStart: "Baştan izle",
|
WatchFromStart: "Baştan izle",
|
||||||
Speed: "Hız",
|
Speed: "Hız",
|
||||||
Duration: "Süre",
|
Duration: "Süre",
|
||||||
OpponentOfflineCountdown: "Rakip çevrimdışı. Dönmezse {{time}} içinde kazanırsın."
|
|
||||||
};
|
};
|
||||||
@ -1,11 +1,6 @@
|
|||||||
import { GameStep } from "mancala.js";
|
import { GameStep } from "mancala.js";
|
||||||
import { GameClock } from "./GameClock";
|
import { GameClock } from "./GameClock";
|
||||||
|
|
||||||
// How long the player whose turn it is may stay offline before the opponent wins.
|
|
||||||
export const TURN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
||||||
// How long both players may stay offline before the game ends without a winner.
|
|
||||||
export const INACTIVE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
export interface PlayerSummary {
|
export interface PlayerSummary {
|
||||||
userId: string;
|
userId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@ -4,6 +4,4 @@ export interface UserConnectionInfo {
|
|||||||
// Sent by servers with Keycloak auth; undefined for legacy userKey players.
|
// Sent by servers with Keycloak auth; undefined for legacy userKey players.
|
||||||
name?: string;
|
name?: string;
|
||||||
isGuest?: boolean;
|
isGuest?: boolean;
|
||||||
// Epoch milliseconds since the user has been offline; undefined while online.
|
|
||||||
offlineSince?: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user