mancala/apps/frontend/src/routes/LobyPage.tsx
Halit Aksoy e66c4a3a41 feat(frontend): offer games against the bot
The lobby offers the bot levels after 10 seconds without an opponent, the
home page has a Play with bot menu, and a game against the bot ends with
Play again at the same level.
2026-10-16 14:22:04 +03:00

138 lines
5.3 KiB
TypeScript

import { CommonMancalaGame } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AppHeader from '../components/headerbar/AppHeader';
import PageContainer from '../components/PageContainer';
import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core';
import BotLevelPicker from '../components/BotLevelPicker';
import { Context } from '../context/context';
import { Theme } from '@mancala/core';
import { useTranslation } from 'react-i18next';
const LobyPage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme
}> = ({ context }) => {
let navigate = useNavigate();
const { t } = useTranslation();
// /loby?bot=<level> starts a game against the bot at once instead of searching for an opponent.
const [searchParams] = useSearchParams();
const botParam = searchParams.get("bot");
const botLevel: BotLevel | undefined = isBotLevel(botParam) ? botParam : undefined;
// Set when the server refuses a new game because the user has one in progress.
const [activeGameId, setActiveGameId] = useState<string | undefined>(undefined);
// Shown when no opponent has been found for a while.
const [botOffered, setBotOffered] = useState(false);
const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId);
const onGameStart = (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame;
navigate(`/game/${newGame.id}`)
}
const startBotGame = (level: BotLevel) => {
context.rtmt.sendMessage(channel_new_bot_game, { level });
}
const onCancelNewGameClick = () => {
if (context.rtmt.connectionState === "connected") {
context.rtmt.sendMessage(channel_cancel_new_game, "");
}
navigate("/")
}
useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected);
let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
if (botLevel) {
startBotGame(botLevel);
} else {
context.rtmt.sendMessage(channel_new_game, {});
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
}
return () => {
if (botOfferTimer) clearTimeout(botOfferTimer);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
}
}, []);
return (
<PageContainer>
<AppHeader context={context} />
<main className="wrap">
<section className="search panel" aria-live="polite">
{!activeGameId && <span className="dot" />}
<h1>{activeGameId ? t("GameInProgress") : botLevel ? t("PlayWithBot") : t("SearchingOpponent")}</h1>
{!activeGameId && <p>{`${t("PleaseWait")}...`}</p>}
{!activeGameId && botOffered && (
<div className="offer">
<p>{t("NoOpponentYet")}</p>
<BotLevelPicker context={context} onChoose={startBotGame} />
</div>
)}
<div className="actions">
{activeGameId && (
<button className="btn pri" onClick={() => navigate(`/game/${activeGameId}`)}>{t("Continue")}</button>
)}
<button className="btn ghost" onClick={onCancelNewGameClick}>{t("Cancel")}</button>
</div>
</section>
</main>
<style jsx>{`
.search {
align-self: center;
width: 100%;
max-width: 460px;
margin-top: 8vh;
padding: 32px;
box-sizing: border-box;
border-radius: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.dot {
width: 14px;
height: 14px;
color: var(--accent);
}
h1 {
margin: 0;
font-size: 28px;
letter-spacing: -.02em;
}
p {
margin: 0;
color: var(--muted);
}
.offer {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
`}</style>
</PageContainer>
);
}
export default LobyPage;