+ {/* Self player line at top */}
+ {selfPlayerId.current ? (
+
+
+
+ ) : null}
{playerIds.map((playerId: AppPlayerId) => {
const color =
carColors[playerIds.indexOf(playerId) % carColors.length];
+
+ // Skip self player
+ if (playerId === selfPlayerId.current) {
+ return null;
+ }
+
+ // Skip overflowing player lines
+ if (playerIds.indexOf(playerId) >= tracksCount) {
+ return null;
+ }
+
return (
;
+ return (
+
+
+
+ );
}
diff --git a/apps/client/src/pages/race/templates/race-view/race-line/RaceLine.template.tsx b/apps/client/src/pages/race/templates/race-view/race-line/RaceLine.template.tsx
index c755daf0..3099d64d 100644
--- a/apps/client/src/pages/race/templates/race-view/race-line/RaceLine.template.tsx
+++ b/apps/client/src/pages/race/templates/race-view/race-line/RaceLine.template.tsx
@@ -2,6 +2,10 @@ import { ReactElement } from 'react';
import { useSelector } from 'react-redux';
import { AppPlayerId, AppPlayerLogId, AppRaceId } from '@razor/models';
import { RootState } from '@razor/store';
+import { Text } from 'apps/client/src/components';
+import { TextSize, TextType } from 'apps/client/src/models';
+import { useComputePlayerRanks } from 'apps/client/src/utils/custom-hooks/compute-player-rank';
+import cs from 'classnames';
import {
getCarComponentSize,
@@ -28,6 +32,9 @@ export function RaceLine({
}: RaceLineProps): ReactElement | null {
const playerLogId: AppPlayerLogId = `${raceId}-${playerId}`;
const game = useSelector((store: RootState) => store.game);
+ const playersModel = game.playersModel;
+ const playerName = playersModel[playerId]?.name || 'Unknown';
+ const [playerRanks] = useComputePlayerRanks(raceId);
const playerLogs = game.playerLogsModel[playerLogId];
if (!playerLogs) {
@@ -57,13 +64,30 @@ export function RaceLine({
className='relative mx-auto'
style={{ width: `${raceTrackWidth}px`, height: `${lineHeight}px` }}>
-
+
+ {`${playerRanks[playerId]}`}
+
+
+
+
+ {playerName}
+
+
);
diff --git a/apps/client/src/router.tsx b/apps/client/src/router.tsx
new file mode 100644
index 00000000..db51397c
--- /dev/null
+++ b/apps/client/src/router.tsx
@@ -0,0 +1,36 @@
+import { ReactElement } from 'react';
+import { BrowserRouter, Route, Routes } from 'react-router-dom';
+
+import { NotFound } from './pages/NotFound';
+import { GuardedRoute } from './utils/guardedRoute';
+import { Home, Layout, Leaderboard, Race, Room } from './pages';
+
+export function Router(): ReactElement {
+ return (
+
+
+ }>
+ } />
+
+ } />
+ }
+ />
+ }
+ />
+
+ }
+ />
+
+
+ } />
+
+
+ );
+}
diff --git a/apps/client/src/services/audio-manager.ts b/apps/client/src/services/audio-manager.ts
new file mode 100644
index 00000000..47286bad
--- /dev/null
+++ b/apps/client/src/services/audio-manager.ts
@@ -0,0 +1,74 @@
+interface AudioInstance {
+ instance: HTMLAudioElement;
+ lastPlayedTimestamp: number;
+}
+
+export class AudioManager {
+ /** Link of the audio */
+ private audioLink: string;
+ /** Circular dynamic array containing audios */
+ private audioCircularArray: AudioInstance[] = [];
+ /** Pointer to the next audio to be played */
+ private nextPointer = 0;
+ /** Number of initial audio instances to be created */
+ private initialAudioCount = 2;
+
+ constructor(link: string) {
+ this.audioLink = link;
+ this.initializeAudioInstances();
+ }
+
+ /** Update the pointer to the next audio instance in the circular array */
+ private updatePointer(): void {
+ this.nextPointer = (this.nextPointer + 1) % this.audioCircularArray.length;
+ }
+
+ /** Update pointer to oldest instance */
+ private updatePointerToOldest(): void {
+ const oldestAudio = this.audioCircularArray.reduce((prev, curr) =>
+ prev.lastPlayedTimestamp < curr.lastPlayedTimestamp ? prev : curr,
+ );
+ this.nextPointer = this.audioCircularArray.indexOf(oldestAudio);
+ }
+
+ /** Create a new audio instance and push it to the list */
+ private createNewAudio(): HTMLAudioElement {
+ const audio = new Audio(this.audioLink);
+ this.audioCircularArray.push({
+ instance: audio,
+ lastPlayedTimestamp: Date.now(),
+ });
+ return audio;
+ }
+
+ /** Create few Audio instances at the beginning and push them to the list */
+ private initializeAudioInstances(): void {
+ for (let i = 0; i < this.initialAudioCount; i++) {
+ this.createNewAudio();
+ }
+ }
+
+ /** Get the next audio instance to be played.
+ * If the audio instance is already playing,
+ * create a new one and set the pointer to oldest audio instance.
+ */
+ private getNextAudioInstance(): HTMLAudioElement {
+ const audioInstance = this.audioCircularArray[this.nextPointer];
+
+ // If audio is instance is paused means it is just created or it played completely,
+ // as we don't pause the audio purposely.
+ if (audioInstance.instance.paused) {
+ audioInstance.lastPlayedTimestamp = Date.now();
+ this.updatePointer();
+ return audioInstance.instance;
+ }
+
+ this.updatePointerToOldest();
+ return this.createNewAudio();
+ }
+
+ public playAudio(): void {
+ const audio = this.getNextAudioInstance();
+ audio.play();
+ }
+}
diff --git a/apps/client/src/services/handlers/race-timeout.ts b/apps/client/src/services/handlers/race-timeout.ts
new file mode 100644
index 00000000..25117ab1
--- /dev/null
+++ b/apps/client/src/services/handlers/race-timeout.ts
@@ -0,0 +1,13 @@
+import { AllProtocolToTypeMap, SocketProtocols } from '@razor/models';
+
+import { socket } from '../socket-communication';
+
+export function raceTimeout(): void {
+ type TimeoutData = AllProtocolToTypeMap[SocketProtocols.InformTimeout];
+ const data: TimeoutData = {
+ timestamp: Date.now(),
+ };
+ socket.emit(SocketProtocols.InformTimeout, data);
+
+ // store end race should be called after server notifies the timeout
+}
diff --git a/apps/client/src/services/index.ts b/apps/client/src/services/index.ts
index 67b02ff8..7e914125 100644
--- a/apps/client/src/services/index.ts
+++ b/apps/client/src/services/index.ts
@@ -1 +1,2 @@
+export * from './audio-manager';
export * from './socket-communication';
diff --git a/apps/client/src/utils/custom-hooks/compute-max-race-tracks.ts b/apps/client/src/utils/custom-hooks/compute-max-race-tracks.ts
new file mode 100644
index 00000000..e8baa6a4
--- /dev/null
+++ b/apps/client/src/utils/custom-hooks/compute-max-race-tracks.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+
+import { RaceConstants } from '../../constants';
+
+/** Compute max race tracks count on window height change */
+export function useComputeMaxRaceTracks(): number {
+ const [windowHeight, setWindowHeight] = useState
(window.innerHeight);
+
+ useEffect(() => {
+ window.addEventListener('resize', () => {
+ setWindowHeight(window.innerHeight);
+ });
+
+ return () => {
+ window.removeEventListener('resize', () => {
+ setWindowHeight(window.innerHeight);
+ });
+ };
+ }, []);
+
+ const maxRaceTracks = RaceConstants.MIN_RACE_TRACKS;
+ const approxAllocatableRaceTrackHeight = windowHeight * 0.35;
+ if (approxAllocatableRaceTrackHeight > 300) {
+ const freeHeight = approxAllocatableRaceTrackHeight - 300;
+ const additionalRaceTracks = Math.floor(freeHeight / 15); // Considering one row takes 20px
+ return additionalRaceTracks + maxRaceTracks;
+ }
+
+ return maxRaceTracks;
+}
diff --git a/apps/client/src/utils/custom-hooks/compute-player-rank.ts b/apps/client/src/utils/custom-hooks/compute-player-rank.ts
new file mode 100644
index 00000000..65708797
--- /dev/null
+++ b/apps/client/src/utils/custom-hooks/compute-player-rank.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { AppPlayerId, AppPlayerLogId, AppRaceId } from '@razor/models';
+import { RootState } from '@razor/store';
+
+type PlayerCursorPos = Record;
+type PlayerRank = Record;
+
+export function useComputePlayerRanks(raceId: AppRaceId): PlayerRank[] {
+ const [initialRaceId] = useState(raceId);
+ const game = useSelector((store: RootState) => store.game);
+ const race = game.racesModel[initialRaceId];
+ const playerLogsModel = game.playerLogsModel;
+
+ const [playerRanks, setPlayerRanks] = useState({});
+
+ useEffect(() => {
+ const playersIds = Object.keys(race.players) as AppPlayerId[];
+ const playerCursorPos: PlayerCursorPos = {};
+ // Extract last recorded char index for each player
+ playersIds.forEach(playerId => {
+ const playerLogsId: AppPlayerLogId = `${initialRaceId}-${playerId}`;
+ const playerLogs = playerLogsModel[playerLogsId];
+ const lastRecordedCharIndex = playerLogs?.length
+ ? playerLogs[playerLogs.length - 1].textLength
+ : 0;
+ playerCursorPos[playerId] = lastRecordedCharIndex;
+ });
+
+ // Sort players by their last recorded char index
+ const sortedPlayersIds = playersIds.sort(
+ (a, b) => playerCursorPos[b] - playerCursorPos[a],
+ );
+
+ // Assign ranks to players, same rank for players in the same position
+ const playerRanks: PlayerRank = {};
+ let rank = 1;
+ let prevPlayerCursorPos = playerCursorPos[sortedPlayersIds[0]];
+ sortedPlayersIds.forEach(playerId => {
+ const cursorPos = playerCursorPos[playerId];
+ if (cursorPos < prevPlayerCursorPos) {
+ rank++;
+ }
+ playerRanks[playerId] = rank;
+ prevPlayerCursorPos = cursorPos;
+ });
+
+ setPlayerRanks(playerRanks);
+ }, [playerLogsModel]);
+
+ return [playerRanks];
+}
diff --git a/apps/client/src/utils/custom-hooks/index.ts b/apps/client/src/utils/custom-hooks/index.ts
new file mode 100644
index 00000000..f40600e5
--- /dev/null
+++ b/apps/client/src/utils/custom-hooks/index.ts
@@ -0,0 +1 @@
+export * from './compute-max-race-tracks';
diff --git a/apps/client/src/utils/guardedRoute.tsx b/apps/client/src/utils/guardedRoute.tsx
new file mode 100644
index 00000000..20dc3b4c
--- /dev/null
+++ b/apps/client/src/utils/guardedRoute.tsx
@@ -0,0 +1,22 @@
+import { ReactElement } from 'react';
+import { Navigate, useParams } from 'react-router-dom';
+
+import { getSavedPlayerId } from './save-player-id';
+
+interface GuardedRouteProps {
+ component: React.ElementType;
+ path: string;
+}
+
+export function GuardedRoute({
+ component: Component,
+}: GuardedRouteProps): ReactElement | null {
+ const playerId = getSavedPlayerId();
+ const { roomId } = useParams();
+
+ if (!playerId) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/apps/client/src/utils/index.ts b/apps/client/src/utils/index.ts
new file mode 100644
index 00000000..7d09fbb5
--- /dev/null
+++ b/apps/client/src/utils/index.ts
@@ -0,0 +1 @@
+export * from './custom-hooks';
diff --git a/libs/constants/src/lib/constants.ts b/libs/constants/src/lib/constants.ts
index c4525ba7..50e9aa19 100644
--- a/libs/constants/src/lib/constants.ts
+++ b/libs/constants/src/lib/constants.ts
@@ -3,7 +3,7 @@
* Use by,
* - compute timeout util function
*/
-export const AVERAGE_WPM = 50;
+export const AVERAGE_WPM = 40;
/** Number of chars to create the unique part of the general ID.
*
diff --git a/libs/models/src/lib/sockets/protocol-data.ts b/libs/models/src/lib/sockets/protocol-data.ts
index e54a65b8..7562529c 100644
--- a/libs/models/src/lib/sockets/protocol-data.ts
+++ b/libs/models/src/lib/sockets/protocol-data.ts
@@ -4,6 +4,7 @@ import { AppStateModel } from '../state';
import { PlayerId } from './player';
import {
+ informTimeoutSchema,
initialClientDataSchema,
playerJoinSchema,
sendTypeLogSchema,
@@ -49,3 +50,5 @@ export type StartRaceAcceptData = z.infer;
export type SendTypeLogData = z.infer;
export type UpdateTypeLogsData = z.infer;
+
+export type InformTimeoutData = z.infer;
diff --git a/libs/models/src/lib/sockets/protocol-schemas.ts b/libs/models/src/lib/sockets/protocol-schemas.ts
index 93904e08..ee1833ea 100644
--- a/libs/models/src/lib/sockets/protocol-schemas.ts
+++ b/libs/models/src/lib/sockets/protocol-schemas.ts
@@ -70,6 +70,13 @@ export const updateTypeLogsSchema = z.object({
playerLogs: playerLogsCollectionSchema,
});
+/**
+ * Related protocol - {@link SocketProtocols.InformTimeout}
+ */
+export const informTimeoutSchema = z.object({
+ timestamp: z.number(),
+});
+
export type ProtocolSchemaTypes =
| typeof authTokenTransferSchema
| typeof initialClientDataSchema
@@ -78,4 +85,5 @@ export type ProtocolSchemaTypes =
| typeof startRaceRequestSchema
| typeof startRaceAcceptSchema
| typeof sendTypeLogSchema
- | typeof updateTypeLogsSchema;
+ | typeof updateTypeLogsSchema
+ | typeof informTimeoutSchema;
diff --git a/libs/models/src/lib/sockets/protocol-to-schema-map.ts b/libs/models/src/lib/sockets/protocol-to-schema-map.ts
index 165511d5..d4d2b593 100644
--- a/libs/models/src/lib/sockets/protocol-to-schema-map.ts
+++ b/libs/models/src/lib/sockets/protocol-to-schema-map.ts
@@ -1,5 +1,6 @@
import {
authTokenTransferSchema,
+ informTimeoutSchema,
initialClientDataSchema,
initialServerDataSchema,
playerJoinSchema,
@@ -26,4 +27,5 @@ export const protocolToSchemaMap = new Map<
[SocketProtocols.StartRaceAccept, startRaceAcceptSchema],
[SocketProtocols.SendTypeLog, sendTypeLogSchema],
[SocketProtocols.UpdateTypeLogs, updateTypeLogsSchema],
+ [SocketProtocols.InformTimeout, informTimeoutSchema],
]);
diff --git a/libs/models/src/lib/sockets/protocol-to-type-map.ts b/libs/models/src/lib/sockets/protocol-to-type-map.ts
index 66b87d80..aeb44057 100644
--- a/libs/models/src/lib/sockets/protocol-to-type-map.ts
+++ b/libs/models/src/lib/sockets/protocol-to-type-map.ts
@@ -1,4 +1,5 @@
import {
+ InformTimeoutData,
InitialClientData,
InitialServerData,
PlayerJoinData,
@@ -23,6 +24,7 @@ export interface OtherProtocolToTypeMap extends Record {
[SocketProtocols.StartRaceAccept]: StartRaceAcceptData;
[SocketProtocols.SendTypeLog]: SendTypeLogData;
[SocketProtocols.UpdateTypeLogs]: UpdateTypeLogsData;
+ [SocketProtocols.InformTimeout]: InformTimeoutData;
}
export type AllProtocolToTypeMap = InitialProtocolToTypeMap &
diff --git a/libs/util/src/lib/compute-race-duration.spec.ts b/libs/util/src/lib/compute-race-duration.spec.ts
index f93f4eed..259dbc43 100644
--- a/libs/util/src/lib/compute-race-duration.spec.ts
+++ b/libs/util/src/lib/compute-race-duration.spec.ts
@@ -5,8 +5,8 @@ import { computeRaceDuration } from './compute-race-duration';
describe('[Utils] computeRaceDuration', () => {
it.each([
- [M_RACE_TEXT0, 159],
- [M_RACE_TEXT1, 135],
+ [M_RACE_TEXT0, 198],
+ [M_RACE_TEXT1, 168],
])('Calculate timeout timer', (text, time) => {
const timeoutDuration = computeRaceDuration(text);
expect(timeoutDuration).toEqual(time);