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.
This commit is contained in:
parent
32d1ec98e7
commit
e66c4a3a41
36
apps/frontend/src/components/BotLevelMenu.tsx
Normal file
36
apps/frontend/src/components/BotLevelMenu.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu";
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core";
|
||||
import { Context } from "../context/context";
|
||||
import { BotIcon } from "./Icons";
|
||||
import "@szhsin/react-menu/dist/index.css";
|
||||
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||
|
||||
// "Play with bot" in the header: a menu of the levels, the one chosen last marked.
|
||||
const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => {
|
||||
const { t } = useTranslation();
|
||||
const lastLevel = getLastBotLevel(context.storage);
|
||||
const menuButton = (
|
||||
<button className="btn ghost ic-m" aria-label={t("PlayWithBot")}>
|
||||
<BotIcon /><span className="hide-m">{t("PlayWithBot")}</span>
|
||||
</button>
|
||||
);
|
||||
const choose = (level: BotLevel) => {
|
||||
setLastBotLevel(context.storage, level);
|
||||
onChoose(level);
|
||||
};
|
||||
return (
|
||||
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
|
||||
<MenuHeader>{t("ChooseBotLevel")}</MenuHeader>
|
||||
{BOT_LEVELS.map((level) => (
|
||||
<MenuItem key={level} type="checkbox" checked={level === lastLevel} onClick={() => choose(level)}>
|
||||
{t(getBotLevelTextKey(level))}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default BotLevelMenu;
|
||||
34
apps/frontend/src/components/BotLevelPicker.tsx
Normal file
34
apps/frontend/src/components/BotLevelPicker.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core";
|
||||
import { Context } from "../context/context";
|
||||
|
||||
// The four bot levels as buttons; the level chosen last is highlighted and remembered on this device.
|
||||
const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => {
|
||||
const { t } = useTranslation();
|
||||
const lastLevel = getLastBotLevel(context.storage);
|
||||
const choose = (level: BotLevel) => {
|
||||
setLastBotLevel(context.storage, level);
|
||||
onChoose(level);
|
||||
};
|
||||
return (
|
||||
<div className="levels" role="group" aria-label={t("ChooseBotLevel")}>
|
||||
{BOT_LEVELS.map((level) => (
|
||||
<button key={level} className={`btn ${level === lastLevel ? "pri" : "ghost"}`} onClick={() => choose(level)}>
|
||||
{t(getBotLevelTextKey(level))}
|
||||
</button>
|
||||
))}
|
||||
<style jsx>{`
|
||||
.levels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BotLevelPicker;
|
||||
@ -67,3 +67,7 @@ export const PlayIcon = ({ size = 22 }: IconProps) => (
|
||||
export const PauseIcon = ({ size = 22 }: IconProps) => (
|
||||
<svg {...stroke(size, 3)}><path d="M8 5v14M16 5v14" /></svg>
|
||||
);
|
||||
|
||||
export const BotIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
|
||||
);
|
||||
|
||||
@ -9,9 +9,11 @@ const GameOverCard: FunctionComponent<{
|
||||
title: string,
|
||||
reason?: string,
|
||||
score: string,
|
||||
// "New Game" unless given, e.g. "Play again" after a game against the bot.
|
||||
newGameLabel?: string,
|
||||
onNewGame: () => void,
|
||||
onClose: () => void,
|
||||
}> = ({ title, reason, score, onNewGame, onClose }) => {
|
||||
}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
@ -22,7 +24,7 @@ const GameOverCard: FunctionComponent<{
|
||||
{reason && <div className="oreason">{reason}</div>}
|
||||
<div className="oscore">{score}</div>
|
||||
<div className="row">
|
||||
<button className="btn pri" onClick={onNewGame}>{t("NewGame")}</button>
|
||||
<button className="btn pri" onClick={onNewGame}>{newGameLabel ?? t("NewGame")}</button>
|
||||
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@ import { LeaveIcon, PlusIcon } from '../components/Icons';
|
||||
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 { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||
import { Theme, getPlayerDisplayName } from '@mancala/core';
|
||||
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
||||
import { BoardViewModel } from "@mancala/core";
|
||||
import notyf from '../util/Notyf';
|
||||
import swal from 'sweetalert';
|
||||
@ -191,6 +191,14 @@ const GamePage: FunctionComponent<{
|
||||
navigate("/loby")
|
||||
};
|
||||
|
||||
// In a game against the bot, the game over card starts another one at the same level.
|
||||
const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
|
||||
|
||||
const onPlayAgainClick = () => {
|
||||
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
||||
navigate(`/loby?bot=${opponentBotLevel}`)
|
||||
};
|
||||
|
||||
const onPitSelect = (index: number, pit: Pit) => {
|
||||
if (!game || isSpectator || !userKey) {
|
||||
return;
|
||||
@ -386,7 +394,8 @@ const GamePage: FunctionComponent<{
|
||||
title={resultTitle ?? t("GameEnded")}
|
||||
reason={resultReason}
|
||||
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
|
||||
onNewGame={onNewGameClick}
|
||||
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
|
||||
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
|
||||
onClose={() => setGameOverClosed(true)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent, useEffect, useState } from "react";
|
||||
import { LiveGameSummary } from "@mancala/core";
|
||||
import { AuthUser, Theme } from "@mancala/core";
|
||||
import { AuthUser, BotLevel, Theme } from "@mancala/core";
|
||||
import AppHeader from "../components/headerbar/AppHeader";
|
||||
import PageContainer from "../components/PageContainer";
|
||||
import { PlusIcon } from "../components/Icons";
|
||||
import BotLevelMenu from "../components/BotLevelMenu";
|
||||
import ActiveGameBanner from "../components/home/ActiveGameBanner";
|
||||
import LiveGamesSection from "../components/home/LiveGamesSection";
|
||||
import RecentGamesSection from "../components/home/RecentGamesSection";
|
||||
@ -43,9 +44,15 @@ const Home: FunctionComponent<{
|
||||
navigate("/loby")
|
||||
};
|
||||
|
||||
const onBotLevelChosen = (level: BotLevel) => {
|
||||
if(Util.checkConnectionAndMaybeAlert(context, t)) return;
|
||||
navigate(`/loby?bot=${level}`)
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<AppHeader context={context} authUser={authUser}>
|
||||
<BotLevelMenu context={context} onChoose={onBotLevelChosen} />
|
||||
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
|
||||
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
||||
</button>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { CommonMancalaGame } from 'mancala.js';
|
||||
import * as React from 'react';
|
||||
import { FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppHeader from '../components/headerbar/AppHeader';
|
||||
import PageContainer from '../components/PageContainer';
|
||||
import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core';
|
||||
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';
|
||||
@ -19,9 +20,17 @@ const LobyPage: FunctionComponent<{
|
||||
|
||||
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) => {
|
||||
@ -29,6 +38,10 @@ const LobyPage: FunctionComponent<{
|
||||
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, "");
|
||||
@ -38,8 +51,15 @@ const LobyPage: FunctionComponent<{
|
||||
useEffect(() => {
|
||||
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
|
||||
context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected);
|
||||
context.rtmt.sendMessage("new_game", {});
|
||||
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);
|
||||
}
|
||||
@ -51,8 +71,14 @@ const LobyPage: FunctionComponent<{
|
||||
<main className="wrap">
|
||||
<section className="search panel" aria-live="polite">
|
||||
{!activeGameId && <span className="dot" />}
|
||||
<h1>{activeGameId ? t("GameInProgress") : t("SearchingOpponent")}</h1>
|
||||
<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>
|
||||
@ -90,6 +116,14 @@ const LobyPage: FunctionComponent<{
|
||||
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;
|
||||
|
||||
@ -32,12 +32,17 @@ export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"];
|
||||
export const BOT_OFFER_DELAY_MS = 10_000; // R1.1
|
||||
export function isBotId(userId: string | undefined): boolean; // starts with "bot:"
|
||||
export function getBotLevel(userId: string | undefined): BotLevel | undefined;
|
||||
export function isBotLevel(value: unknown): value is BotLevel;
|
||||
export function getBotLevelTextKey(level: BotLevel): string; // e.g. BotLevelMedium
|
||||
export function getLastBotLevel(storage): BotLevel; // Medium until one is chosen (R1.4)
|
||||
export function setLastBotLevel(storage, level: BotLevel): void;
|
||||
```
|
||||
|
||||
- `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for
|
||||
bots (R4.1).
|
||||
- Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with
|
||||
the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `Bot` "Bot" / "Bot", `BotLevelEasy`,
|
||||
the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `ChooseBotLevel` "Choose how strong the bot
|
||||
is" / "Botun ne kadar güçlü olacağını seç", `Bot` "Bot" / "Bot", `BotLevelEasy`,
|
||||
`BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor",
|
||||
"Usta", `PlayAgain` "Play again" / "Tekrar oyna".
|
||||
|
||||
@ -185,10 +190,10 @@ Games being worked on are kept in a set by identity, so a game restored with the
|
||||
`BOT_SEARCH_THREADS` (default: half the processors, at least 1).
|
||||
|
||||
## Web app
|
||||
- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is preselected and saved in
|
||||
local storage (R1.4).
|
||||
- Home page: a "Play with bot" button next to "New Game" opens the picker in a `FloatingPanel`; choosing a level
|
||||
goes to `/loby?bot=<level>` (R1.3).
|
||||
- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is highlighted. It is kept with
|
||||
`getLastBotLevel` / `setLastBotLevel` from core in the app's storage (R1.4).
|
||||
- Home page: a "Play with bot" button next to "New Game" opens `BotLevelMenu`, a menu of the levels like the account
|
||||
menu, with the last level checked; choosing a level goes to `/loby?bot=<level>` (R1.3).
|
||||
- `LobyPage`: with `?bot=<level>`, it sends `new_bot_game` instead of `new_game`. Otherwise, after
|
||||
`BOT_OFFER_DELAY_MS` without a game, it shows `NoOpponentYet` and the picker below the search; choosing a level
|
||||
sends `new_bot_game` (R1.1, R1.2). The existing `on_game_start` and `on_new_game_rejected` handling stays.
|
||||
|
||||
@ -14,7 +14,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
||||
- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.
|
||||
- [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`,
|
||||
`getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests.
|
||||
- [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot
|
||||
- [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot
|
||||
game, bot names everywhere players are shown.
|
||||
- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle.
|
||||
- [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user