Merge pull request #27 from jhalitaksoy/feature/game-id

Feature/game
This commit is contained in:
Halit Aksoy 2022-07-31 01:26:32 +03:00 committed by GitHub
commit 3ff2b657a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 687 additions and 363 deletions

View File

@ -20,6 +20,7 @@
"mancala.js": "^0.0.2-beta.3", "mancala.js": "^0.0.2-beta.3",
"react": "^16.0.0 || ^17.0.0 || ^18.0.0", "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6",
"styled-jsx": "^5.0.2", "styled-jsx": "^5.0.2",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },

View File

@ -1,9 +1,6 @@
import { initContext } from './context/context';
initContext();
import * as React from 'react'; import * as React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
const container = document.getElementById('main'); const container = document.getElementById('main');
const root = createRoot(container!); const root = createRoot(container!);
import Home from './routes/Home'; import MancalaApp from './MancalaApp';
root.render(<Home/>); root.render(<MancalaApp/>);

101
src/MancalaApp.tsx Normal file
View File

@ -0,0 +1,101 @@
import * as React from 'react';
import { FunctionComponent, useState } from 'react';
import {
BrowserRouter,
Routes,
Route,
} from "react-router-dom";
import FloatingPanel from './components/FloatingPanel';
import GamePage from './routes/GamePage';
import Home from './routes/Home';
import { initContext } from './context/context';
import { RTMTWS } from './rtmt/rtmt_websocket';
import { getColorByBrightness } from './util/ColorUtil';
import { ConnectionState } from './models/ConnectionState';
import { Theme } from './theme/Theme';
import LobyPage from './routes/LobyPage';
const context = initContext();
const MancalaApp: FunctionComponent = () => {
const [userKey, setUserKey] = useState<string | undefined>(undefined);
const [connectionState, setConnetionState] = useState<ConnectionState>("connecting");
const [theme, setTheme] = useState<Theme>(context.themeManager.theme);
const onConnectionDone = () => {
setConnetionState("connected");
};
const onConnectionLost = () => {
connectToServer("reconnecting");
};
const onConnectionError = (event: Event) => {
setConnetionState("error");
};
const connectToServer = async (connectionState: ConnectionState) => {
setConnetionState(connectionState);
const userKey = await context.userKeyStore.getUserKey();
setUserKey(userKey);
const rtmtws = context.rtmt as RTMTWS;
if (rtmtws) {
rtmtws.initWebSocket(
userKey,
onConnectionDone,
onConnectionLost,
onConnectionError
);
} else {
console.error("context.rtmt is not RTMTWS");
}
};
React.useEffect(() => {
connectToServer("connecting");
context.themeManager.onThemeChange = (theme: Theme) => {
setTheme(theme);
}
return () => {
// todo: dispose rtmt.dispose
//context.rtmt.dispose();
};
}, []);
const showConnectionState = connectionState != "connected";
const floatingPanelColor = context.themeManager.theme.boardColor;
const connectionStateText = () => {
const map: { [key: string]: string } = {
connecting: context.texts.Connecting,
connected: context.texts.Connected,
error: context.texts.CannotConnect,
reconnecting: context.texts.ConnectingAgain,
};
return map[connectionState];
};
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<>
<BrowserRouter>
<Routes>
<Route index element={<Home context={context} theme={theme} userKey={userKey} />} />
<Route path="/" >
<Route path="game" >
<Route path=":gameId" element={<GamePage context={context} theme={theme} userKey={userKey} connectionState={connectionState} />} ></Route>
</Route>
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
</Route>
</Route>
</Routes>
</BrowserRouter>
<FloatingPanel context={context} color={floatingPanelColor} visible={showConnectionState}>
<span style={{ color: textColorOnBoard }}>{connectionStateText()}</span>
</FloatingPanel>
</>
);
}
export default MancalaApp;

View File

@ -42,7 +42,7 @@ export default class PitAnimator {
this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game)); this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game));
} }
public setUpdatedGame(game: MancalaGame, forceClear = false) { public setUpdatedGame(game: MancalaGame) {
this.resetAnimationState(); this.resetAnimationState();
if (!this.game) { if (!this.game) {
this.setNewGame(game); this.setNewGame(game);
@ -68,7 +68,7 @@ export default class PitAnimator {
} }
onAnimate() { onAnimate() {
if(!this.currentHistoryItem || !this.game || !this.oldBoardViewModel) return; if (!this.currentHistoryItem || !this.game || !this.oldBoardViewModel) return;
if (this.animationIndex === this.currentHistoryItem.gameSteps.length) { if (this.animationIndex === this.currentHistoryItem.gameSteps.length) {
this.clearCurrentInterval(); this.clearCurrentInterval();
this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game)); this.onBoardViewModelUpdate?.(this.getBoardViewModelFromGame(this.game));
@ -92,7 +92,7 @@ export default class PitAnimator {
boardViewModel: BoardViewModel, boardViewModel: BoardViewModel,
gameStep: GameStep gameStep: GameStep
) { ) {
if(!this.currentHistoryItem || !this.game) return; if (!this.currentHistoryItem || !this.game) return;
const pitViewModel = boardViewModel.pits[index]; const pitViewModel = boardViewModel.pits[index];
if (this.animationIndex === 0) { if (this.animationIndex === 0) {
//This is one stone move case, TODO: beautify it later //This is one stone move case, TODO: beautify it later

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
import { Context } from '../context/context';
import { LoadingState } from '../models/LoadingState';
import { getColorByBrightness } from '../util/ColorUtil';
import CircularPanel from './CircularPanel';
const LoadingComponent: FunctionComponent<{ context: Context, loadingState: LoadingState<any> }> = ({ context, loadingState }) => {
if (loadingState.isUnset() || loadingState.isLoaded()) {
return <></>
}
if (loadingState.isLoading() || loadingState.isError()) {
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const text = loadingState.isLoading() ? context.texts.Loading +"..." : loadingState.errorMessage;
return (
<CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${text}`}</h4>
</CircularPanel>
);
}
return <></>
}
export default LoadingComponent;

15
src/const/config.ts Normal file
View File

@ -0,0 +1,15 @@
export const useLocalServer = false;
export type Server = {
serverAdress: string;
wsServerAdress: string;
};
export const server: Server = useLocalServer ? {
serverAdress: "http://localhost:5000",
wsServerAdress: "ws://localhost:5000",
} : {
serverAdress: "https://segin.one/mancala-backend-beta",
wsServerAdress: "wss://segin.one/mancala-backend-beta/",
};

View File

@ -22,6 +22,8 @@ export type Texts = {
PleaseWait : string, PleaseWait : string,
GameDraw : string, GameDraw : string,
Anonymous: string, Anonymous: string,
GameNotFound: string,
Loading: string,
} }
export const EnUs: Texts = { export const EnUs: Texts = {
@ -47,6 +49,8 @@ export const EnUs: Texts = {
PleaseWait : "Please Wait", PleaseWait : "Please Wait",
GameDraw : "Draw", GameDraw : "Draw",
Anonymous: "Anonymous", Anonymous: "Anonymous",
GameNotFound: "Game Not Found",
Loading: "Loading",
} }
export const TrTr: Texts = { export const TrTr: Texts = {
@ -71,5 +75,7 @@ export const TrTr: Texts = {
SearchingOpponent: "Rakip Aranıyor", SearchingOpponent: "Rakip Aranıyor",
PleaseWait: "Lütfen Bekleyin", PleaseWait: "Lütfen Bekleyin",
GameDraw : "Berabere", GameDraw : "Berabere",
Anonymous: "Anonim" Anonymous: "Anonim",
GameNotFound: "Oyun Bulunamadı",
Loading: "Yükleniyor",
} }

View File

@ -1,7 +1,10 @@
import { server } from "../const/config";
import { Texts, TrTr } from "../const/texts"; import { Texts, TrTr } from "../const/texts";
import { RTMT } from "../rtmt/rtmt"; import { RTMT } from "../rtmt/rtmt";
import { RTMTWS } from "../rtmt/rtmt_websocket"; import { RTMTWS } from "../rtmt/rtmt_websocket";
import { UserKeyStore, UserKeyStoreImpl } from "../store/key_store"; import { HttpServiceImpl } from "../service/HttpService";
import { GameStore, GameStoreImpl } from "../store/GameStore";
import { UserKeyStore, UserKeyStoreImpl } from "../store/KeyStore";
import ThemeManager from "../theme/ThemeManager"; import ThemeManager from "../theme/ThemeManager";
export type Context = { export type Context = {
@ -9,11 +12,14 @@ export type Context = {
userKeyStore: UserKeyStore; userKeyStore: UserKeyStore;
texts: Texts; texts: Texts;
themeManager: ThemeManager; themeManager: ThemeManager;
gameStore: GameStore;
}; };
export const initContext = () => { export const initContext = (): Context => {
const rtmt = new RTMTWS(); const rtmt = new RTMTWS();
const userKeyStore = new UserKeyStoreImpl(); const httpService = new HttpServiceImpl(server.serverAdress);
const userKeyStore = new UserKeyStoreImpl({ httpService });
const gameStore = new GameStoreImpl({ httpService });
const texts = TrTr; const texts = TrTr;
const themeManager = new ThemeManager(); const themeManager = new ThemeManager();
return { return {
@ -21,7 +27,6 @@ export const initContext = () => {
userKeyStore: userKeyStore, userKeyStore: userKeyStore,
texts: texts, texts: texts,
themeManager, themeManager,
gameStore
}; };
}; };
export const context: Context = initContext();

View File

@ -0,0 +1 @@
export type ConnectionState = "connecting" | "error" | "connected" | "reconnecting";

View File

@ -0,0 +1,46 @@
export type LoadingStateType = "unset" | "loading" | "loaded" | "error";
export class LoadingState<T> {
state: LoadingStateType;
errorMessage?: string;
value?: T;
constructor(props: { state?: LoadingStateType, errorMessage?: string, value?: T }) {
this.state = props.state ? props.state : "unset";
this.errorMessage = props.errorMessage;
this.value = props.value;
}
public static Unset<T>() {
return new LoadingState<T>({ state: "unset" });
}
public static Loading<T>() {
return new LoadingState<T>({ state: "loading" });
}
public static Error<T>(props: { errorMessage: string }) {
const { errorMessage } = props;
return new LoadingState<T>({ state: "error", errorMessage });
}
public static Loaded<T>(props: { value?: T }) {
const { value } = props;
return new LoadingState<T>({ state: "loaded", value });
}
public isUnset() : boolean {
return this.state === "unset";
}
public isLoading() : boolean {
return this.state === "loading";
}
public isError() : boolean {
return this.state === "error";
}
public isLoaded() : boolean {
return this.state === "loaded";
}
}

244
src/routes/GamePage.tsx Normal file
View File

@ -0,0 +1,244 @@
import { CommonMancalaGame, MancalaGame, Pit } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { v4 } from 'uuid';
import PitAnimator from '../animation/PitAnimator';
import BoardToolbar from '../components/board/BoardToolbar';
import BoardView from '../components/board/BoardView';
import Button from '../components/Button';
import HeaderBar from '../components/headerbar/HeaderBar';
import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import InfoPanel from '../components/InfoPanel';
import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer';
import Row from '../components/Row';
import UserStatus from '../components/UserStatus';
import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move } from '../const/channel_names';
import { Context } from '../context/context';
import useWindowDimensions from '../hooks/useWindowDimensions';
import { ConnectionState } from '../models/ConnectionState';
import { GameMove } from '../models/GameMove';
import { LoadingState } from '../models/LoadingState';
import { UserConnectionInfo } from '../models/UserConnectionInfo';
import { Theme } from '../theme/Theme';
import { getColorByBrightness } from '../util/ColorUtil';
import BoardViewModel from '../viewmodel/BoardViewModel';
import Center from '../components/Center';
const GamePage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme,
connectionState: ConnectionState
}> = ({ context, userKey, theme, connectionState }) => {
let params = useParams<{ gameId: string }>();
const [game, setGame] = useState<MancalaGame | undefined>(undefined);
const [crashMessage, setCrashMessage] = useState<string | undefined>(undefined);
const [userKeyWhoLeave, setUserKeyWhoLeave] = useState<string | undefined>(undefined);
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel | undefined>(undefined);
const [boardId, setBoardId] = useState<string>("-1");
const [pitAnimator, setPitAnimator] = useState<PitAnimator | undefined>(undefined);
// It is a flag for ongoing action such as send game move.
// We have to block future actions if there is an ongoing action.
const [hasOngoingAction, setHasOngoingAction] = useState<boolean>(false);
const [isOpponentOnline, setIsOpponentOnline] = useState<boolean>(false);
const { height, width } = useWindowDimensions();
const navigate = useNavigate();
const [gameLoadingState, setLoadingStateGame] = useState<LoadingState<MancalaGame>>(LoadingState.Unset());
const onGameUpdate = (pitAnimator: PitAnimator, message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame;
const mancalaGame = MancalaGame.createFromMancalaGame(newGame);
setGame(mancalaGame);
pitAnimator.setUpdatedGame(mancalaGame);
setHasOngoingAction(false);
}
const onGameCrashed = (message: any) => {
const newCrashMessage = message as string;
console.error("on_game_crash");
console.error(newCrashMessage);
setCrashMessage(newCrashMessage);
}
const onGameUserLeave = (message: any) => {
const userKeyWhoLeave = message;
setUserKeyWhoLeave(userKeyWhoLeave);
setHasOngoingAction(false);
};
const onUserConnectionChange = (message: any) => {
const userConnectionInfo = message as UserConnectionInfo;
//todo: change this when implementing watch the game feature
setIsOpponentOnline(userConnectionInfo.isOnline);
};
const listenMessages = (pitAnimator: PitAnimator) : () => void => {
const _onGameUpdate = (message: object) => onGameUpdate(pitAnimator, message);
context.rtmt.listenMessage(channel_on_game_update, _onGameUpdate);
context.rtmt.listenMessage(channel_on_game_crashed, onGameCrashed);
context.rtmt.listenMessage(channel_on_game_user_leave, onGameUserLeave);
context.rtmt.listenMessage(channel_on_user_connection_change, onUserConnectionChange)
return () => {
context.rtmt.unlistenMessage(channel_on_game_update, _onGameUpdate);
context.rtmt.unlistenMessage(channel_on_game_crashed, onGameCrashed);
context.rtmt.unlistenMessage(channel_on_game_user_leave, onGameUserLeave);
context.rtmt.unlistenMessage(channel_on_user_connection_change, onUserConnectionChange);
}
};
const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
boardViewModel.id = v4();
setBoardId(boardViewModel.id);
setBoardViewModel(boardViewModel);
};
const getBoardIndex = (index: number) => {
if (!game) return -1;
if (userKey === game.player2Id) return index + game.board.pits.length / 2;
return index;
};
const getOpponentId = () => game?.player1Id === userKey ? game?.player2Id : game?.player1Id;
const checkHasAnOngoingAction = () => hasOngoingAction;
const onLeaveGameClick = () => {
context.rtmt.sendMessage(channel_leave_game, {});
};
const onNewGameClick = () => {
navigate("/loby")
};
const onPitSelect = (index: number, pit: Pit) => {
if (checkHasAnOngoingAction()) {
return;
}
setHasOngoingAction(true);
if (!boardViewModel) return;
//TODO : stoneCount comes from view model!
if (pit.stoneCount === 0) {
//TODO : warn user
return;
}
boardViewModel.pits[getBoardIndex(index)].pitColor =
context.themeManager.theme.pitSelectedColor;
updateBoardViewModel(boardViewModel);
const gameMove: GameMove = { index: index };
context.rtmt.sendMessage(channel_game_move, gameMove);
};
React.useEffect(() => {
let pitAnimator: PitAnimator | undefined;
let unlistenMessages: ()=>void;
setLoadingStateGame(LoadingState.Loading())
context.gameStore.get(params.gameId!!).then((game) => {
if (game) {
setGame(game);
setHasOngoingAction(false);
pitAnimator = new PitAnimator(context, updateBoardViewModel);
pitAnimator.setNewGame(game);
setPitAnimator(pitAnimator);
unlistenMessages = listenMessages(pitAnimator);
setLoadingStateGame(LoadingState.Loaded({ value: game }))
} else {
setLoadingStateGame(LoadingState.Error({ errorMessage: context.texts.GameNotFound }))
}
})
return () => {
unlistenMessages?.();
pitAnimator?.dispose();
};
}, []);
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const renderNewGameBtn = userKeyWhoLeave || !game || (game && game.state == "ended");
const showBoardView = game && boardViewModel && userKey && true;
const opponentUser = { id: getOpponentId() || "0", name: "Anonymous", isOnline: isOpponentOnline, isAnonymous: true };
const user = { id: userKey || "1", name: "Anonymous", isOnline: connectionState === "connected", isAnonymous: true };
const isMobile = width < 600;
return (
<PageContainer theme={theme!}>
<HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={context.texts.Mancala} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<Button
context={context}
color={context.themeManager.theme.pitColor}
text={renderNewGameBtn ? context.texts.NewGame : context.texts.Leave}
onClick={renderNewGameBtn ? onNewGameClick : onLeaveGameClick} />
</Row>
</HeaderBar>
<BoardToolbar style={{ justifyContent: "center" }} visible={showBoardView && isMobile || false}>
<InfoPanel
style={{ marginTop: "0.5rem", marginBottom: "0.5rem" }}
context={context}
game={game}
crashMessage={crashMessage}
userKey={userKey}
userKeyWhoLeave={userKeyWhoLeave} />
</BoardToolbar>
<BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
<UserStatus style={{
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="left" user={opponentUser} visible={showBoardView || false} />
<InfoPanel
style={{
marginTop: "0.5rem", marginBottom: "0.5rem",
}}
context={context}
game={game}
crashMessage={crashMessage}
userKey={userKey}
userKeyWhoLeave={userKeyWhoLeave}
visible={!isMobile} />
<UserStatus style={{
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="right" user={user} visible={showBoardView || false} />
</BoardToolbar>
{showBoardView && (
<BoardView
userKey={userKey}
game={game}
boardId={boardId}
boardViewModel={boardViewModel}
context={context}
onPitSelect={onPitSelect} />
)}
<Center>
<LoadingComponent context={context} loadingState={gameLoadingState}></LoadingComponent>
</Center>
</PageContainer>
);
}
export default GamePage;

View File

@ -1,302 +1,54 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react"; import { FunctionComponent, useEffect, useState } from "react";
import BoardView from "../components/board/BoardView";
import { context } from "../context/context";
import { RTMTWS } from "../rtmt/rtmt_websocket";
import {
channel_game_move,
channel_leave_game,
channel_on_game_crashed,
channel_on_game_start,
channel_on_game_update,
channel_on_game_user_leave,
channel_on_user_connection_change,
} from "../const/channel_names";
import InfoPanel from "../components/InfoPanel";
import { CommonMancalaGame, MancalaGame, Pit } from "mancala.js";
import { GameMove } from "../models/GameMove";
import PitAnimator from "../animation/PitAnimator";
import BoardViewModel from "../viewmodel/BoardViewModel";
import { v4 } from "uuid";
import { getColorByBrightness } from "../util/ColorUtil"; import { getColorByBrightness } from "../util/ColorUtil";
import { Theme } from "../theme/Theme"; import { Theme } from "../theme/Theme";
import HeaderBar from "../components/headerbar/HeaderBar"; import HeaderBar from "../components/headerbar/HeaderBar";
import FloatingPanel from "../components/FloatingPanel";
import PageContainer from "../components/PageContainer"; import PageContainer from "../components/PageContainer";
import Row from "../components/Row"; import Row from "../components/Row";
import HeaderbarIcon from "../components/headerbar/HeaderbarIcon"; import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle"; import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu"; import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
import Button from "../components/Button"; import Button from "../components/Button";
import BoardToolbar from "../components/board/BoardToolbar"; import { Context } from "../context/context";
import UserStatus from "../components/UserStatus"; import { Link, useNavigate } from "react-router-dom";
import Center from "../components/Center";
import CircularPanel from "../components/CircularPanel";
import useWindowDimensions from "../hooks/useWindowDimensions";
import { UserConnectionInfo } from "../models/UserConnectionInfo";
type ConnectionState = "connecting" | "error" | "connected" | "reconnecting"; const Home: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme,
}> = ({ context, userKey, theme }) => {
const Home: FunctionComponent<{ initial?: number }> = ({ initial = 0 }) => { const navigate = useNavigate();
const [userKey, setUserKey] = useState<string | undefined>(undefined);
const [connectionState, setConnetionState] =
useState<ConnectionState>("connecting");
const [searchingOpponent, setSearchingOpponent] = useState<boolean>(false);
const [game, setGame] = useState<CommonMancalaGame | undefined>(undefined);
const [crashMessage, setCrashMessage] = useState<string | undefined>(undefined);
const [userKeyWhoLeave, setUserKeyWhoLeave] = useState<string | undefined>(undefined);
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel | undefined>(undefined);
const [boardId, setBoardId] = useState<string>("-1");
const [pitAnimator, setPitAnimator] = useState<PitAnimator | undefined>(undefined);
const [theme, setTheme] = useState<Theme | undefined>(undefined);
// It is a flag for ongoing action such as send game move.
// We have to block future actions if there is an ongoing action.
const [hasOngoingAction, setHasOngoingAction] = useState<boolean>(false);
const { height, width } = useWindowDimensions();
const [isOpponentOnline, setIsOpponentOnline] = useState<boolean>(false);
const onConnectionDone = () => {
setConnetionState("connected");
};
const onConnectionLost = () => {
connectToServer("reconnecting");
};
const onConnectionError = (event: Event) => {
setConnetionState("error");
};
const connectToServer = (connectionState: ConnectionState) => {
setConnetionState(connectionState);
context.userKeyStore.getUserKey((userKey: string) => {
setUserKey(userKey);
const rtmtws = context.rtmt as RTMTWS;
if (rtmtws) {
rtmtws.initWebSocket(
userKey,
onConnectionDone,
onConnectionLost,
onConnectionError
);
} else {
console.error("context.rtmt is not RTMTWS");
}
});
};
const listenMessages = (pitAnimator: PitAnimator) => {
context.rtmt.listenMessage(channel_on_game_start, (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame;
const mancalaGame = MancalaGame.createFromMancalaGame(newGame);
setSearchingOpponent(false);
setGame(mancalaGame);
pitAnimator.setNewGame(mancalaGame);
setHasOngoingAction(false);
});
context.rtmt.listenMessage(channel_on_game_update, (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame;
const mancalaGame = MancalaGame.createFromMancalaGame(newGame);
setGame(mancalaGame);
pitAnimator.setUpdatedGame(mancalaGame);
setHasOngoingAction(false);
});
context.rtmt.listenMessage(channel_on_game_crashed, (message: any) => {
const newCrashMessage = message as string;
console.error("on_game_crash");
console.error(newCrashMessage);
setCrashMessage(newCrashMessage);
});
context.rtmt.listenMessage(channel_on_game_user_leave, (message: any) => {
const userKeyWhoLeave = message;
setUserKeyWhoLeave(userKeyWhoLeave);
setHasOngoingAction(false);
});
context.rtmt.listenMessage(channel_on_user_connection_change, (message: any) => {
const userConnectionInfo = message as UserConnectionInfo;
//todo: change this when implementing watch the game feature
setIsOpponentOnline(userConnectionInfo.isOnline);
})
};
const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
boardViewModel.id = v4();
setBoardId(boardViewModel.id);
setBoardViewModel(boardViewModel);
};
const resetGameState = () => {
setGame(undefined);
setCrashMessage(undefined);
setUserKeyWhoLeave(undefined);
setHasOngoingAction(false);
};
const getBoardIndex = (index: number) => {
if (!game) return -1;
if (userKey === game.player2Id) return index + game.board.pits.length / 2;
return index;
};
const getOpponentId = () => game?.player1Id === userKey ? game?.player2Id : game?.player1Id;
const checkHasAnOngoingAction = () => hasOngoingAction;
React.useEffect(() => {
setTheme(context.themeManager.theme);
const pitAnimator = new PitAnimator(context, updateBoardViewModel);
setPitAnimator(pitAnimator);
listenMessages(pitAnimator);
connectToServer("connecting");
return () => {
pitAnimator.dispose();
};
}, []);
React.useEffect(() => {
context.themeManager.onThemeChange = (theme) => {
setTheme(theme);
pitAnimator && game && updateBoardViewModel(pitAnimator.getBoardViewModelFromGame(game));
};
}, [boardViewModel]);
const onNewGameClick = () => { const onNewGameClick = () => {
resetGameState(); navigate("/loby")
setSearchingOpponent(true);
context.rtmt.sendMessage("new_game", {});
}; };
const onLeaveGameClick = () => {
context.rtmt.sendMessage(channel_leave_game, {});
};
const onPitSelect = (index: number, pit: Pit) => {
if (checkHasAnOngoingAction()) {
return;
}
setHasOngoingAction(true);
if (!boardViewModel) return;
//TODO : stoneCount comes from view model!
if (pit.stoneCount === 0) {
//TODO : warn user
return;
}
boardViewModel.pits[getBoardIndex(index)].pitColor =
context.themeManager.theme.pitSelectedColor;
updateBoardViewModel(boardViewModel);
const gameMove: GameMove = { index: index };
context.rtmt.sendMessage(channel_game_move, gameMove);
};
const showConnectionState = connectionState != "connected";
const connectionStateText = () => {
let map: { [key: string]: string } = {
connecting: context.texts.Connecting,
connected: context.texts.Connected,
error: context.texts.CannotConnect,
reconnecting: context.texts.ConnectingAgain,
};
return map[connectionState];
};
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const textColorOnAppBar = getColorByBrightness( const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor, context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor, context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor context.themeManager.theme.textLightColor
); );
const renderNewGameBtn = userKeyWhoLeave || !game || (game && game.state == "ended");
const showBoardView = game && boardViewModel && userKey && true;
const opponentUser = { id: getOpponentId() || "0", name: "Anonymous", isOnline: isOpponentOnline, isAnonymous: true };
const user = { id: userKey || "1", name: "Anonymous", isOnline: connectionState === "connected", isAnonymous: true };
const isMobile = width < 600;
return ( return (
<PageContainer theme={theme!}> <PageContainer theme={theme!}>
<FloatingPanel context={context} color={context.themeManager.theme.boardColor} visible={showConnectionState}>
<span style={{ color: textColorOnBoard }}>{connectionStateText()}</span>
</FloatingPanel>
<HeaderBar color={theme?.appBarBgColor}> <HeaderBar color={theme?.appBarBgColor}>
<Row> <Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon /> <HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={context.texts.Mancala} color={textColorOnAppBar} /> <HeaderbarTitle title={context.texts.Mancala} color={textColorOnAppBar} />
</Link>
</Row> </Row>
<Row> <Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} /> <ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<Button <Button
context={context} context={context}
color={context.themeManager.theme.pitColor} color={context.themeManager.theme.pitColor}
text={renderNewGameBtn ? context.texts.NewGame : context.texts.Leave} text={context.texts.NewGame}
onClick={renderNewGameBtn ? onNewGameClick : onLeaveGameClick} /> onClick={onNewGameClick} />
</Row> </Row>
</HeaderBar> </HeaderBar>
<BoardToolbar style={{ justifyContent: "center" }} visible={showBoardView && isMobile || false}>
<InfoPanel
style={{ marginTop: "0.5rem", marginBottom: "0.5rem" }}
context={context}
game={game}
crashMessage={crashMessage}
userKey={userKey}
userKeyWhoLeave={userKeyWhoLeave} />
</BoardToolbar>
<BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
<UserStatus style={{
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="left" user={opponentUser} visible={showBoardView || false} />
<InfoPanel
style={{
marginTop: "0.5rem", marginBottom: "0.5rem",
}}
context={context}
game={game}
crashMessage={crashMessage}
userKey={userKey}
userKeyWhoLeave={userKeyWhoLeave}
visible={!isMobile} />
<UserStatus style={{
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="right" user={user} visible={showBoardView || false} />
</BoardToolbar>
{showBoardView ? (
<BoardView
userKey={userKey}
game={game}
boardId={boardId}
boardViewModel={boardViewModel}
context={context}
onPitSelect={onPitSelect} />
) : searchingOpponent && (
<Center>
<CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${context.texts.SearchingOpponent} ${context.texts.PleaseWait}...`}</h4>
</CircularPanel>
</Center>
)}
</PageContainer> </PageContainer>
); );
}; };

72
src/routes/LobyPage.tsx Normal file
View File

@ -0,0 +1,72 @@
import { CommonMancalaGame, MancalaGame } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Center from '../components/Center';
import CircularPanel from '../components/CircularPanel';
import HeaderBar from '../components/headerbar/HeaderBar';
import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import PageContainer from '../components/PageContainer';
import Row from '../components/Row';
import { channel_on_game_start } from '../const/channel_names';
import { Context } from '../context/context';
import { Theme } from '../theme/Theme';
import { getColorByBrightness } from '../util/ColorUtil';
const LobyPage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme
}> = ({ context, userKey, theme }) => {
let navigate = useNavigate();
useEffect(() => {
listenMessages();
context.rtmt.sendMessage("new_game", {});
}, []);
const listenMessages = () => {
context.rtmt.listenMessage(channel_on_game_start, (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame;
navigate(`/game/${newGame.id}`)
});
}
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<PageContainer theme={theme!}>
<HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={context.texts.Mancala} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
</Row>
</HeaderBar>
<Center>
<CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${context.texts.SearchingOpponent} ${context.texts.PleaseWait}...`}</h4>
</CircularPanel>
</Center>
</PageContainer>
);
}
export default LobyPage;

View File

@ -4,4 +4,5 @@ export type OnMessage = (message : Object) => any
export interface RTMT{ export interface RTMT{
sendMessage : (channel : string, message : Object) => any sendMessage : (channel : string, message : Object) => any
listenMessage : (channel : string, callback : OnMessage) => any listenMessage : (channel : string, callback : OnMessage) => any
unlistenMessage : (channel : string, callback : OnMessage) => any
} }

View File

@ -1,12 +1,12 @@
import { decode, encode } from "./encode_decode_message"; import { decode, encode } from "./encode_decode_message";
import { Bytes, OnMessage, RTMT } from "./rtmt"; import { Bytes, OnMessage, RTMT } from "./rtmt";
import { server } from "../service/http_service";
import { channel_ping, channel_pong } from "../const/channel_names"; import { channel_ping, channel_pong } from "../const/channel_names";
import { server } from "../const/config";
const PING_INTERVAL = 15000, PING_INTERVAL_BUFFER_TIME = 1000; const PING_INTERVAL = 15000, PING_INTERVAL_BUFFER_TIME = 1000;
export class RTMTWS implements RTMT { export class RTMTWS implements RTMT {
private messageChannels: Map<String, OnMessage>; private messageChannels: Map<String, OnMessage | undefined>;
private ws: WebSocket; private ws: WebSocket;
private pingTimeout?: number = undefined; private pingTimeout?: number = undefined;
@ -69,10 +69,16 @@ export class RTMTWS implements RTMT {
this.ws.send(data); this.ws.send(data);
} }
// todo: support multible listeners
listenMessage(channel: string, callback: OnMessage) { listenMessage(channel: string, callback: OnMessage) {
this.messageChannels.set(channel, callback); this.messageChannels.set(channel, callback);
} }
// todo: support multible listeners
unlistenMessage(channel : string, callback : OnMessage) {
this.messageChannels.set(channel, undefined);
}
onWebSocketMessage(rtmt: RTMTWS, event: MessageEvent) { onWebSocketMessage(rtmt: RTMTWS, event: MessageEvent) {
const { channel, message } = decode(event.data); const { channel, message } = decode(event.data);
rtmt.onMessage(channel, message); rtmt.onMessage(channel, message);

View File

@ -0,0 +1,23 @@
import { server } from "../const/config";
export interface HttpService {
get: (route: string) => Promise<Response>;
}
export class HttpServiceImpl implements HttpService {
public serverAdress: string;
constructor(serverAdress: string) {
this.serverAdress = serverAdress;
}
public async get(route: string): Promise<Response> {
const url = server.serverAdress + route;
const requestOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
};
const response = await fetch(url, requestOptions);
return response;
}
}

View File

@ -1,30 +0,0 @@
export type Server = {
serverAdress: string;
wsServerAdress: string;
};
export const server: Server = {
serverAdress: "https://segin.one/mancala-backend-beta",
wsServerAdress: "wss://segin.one/mancala-backend-beta/",
};
const useLocal = false;
if (useLocal) {
server.serverAdress = "http://localhost:5000";
server.wsServerAdress = "ws://localhost:5000";
}
interface HttpService {
get: (route: string, succes: () => any, error: () => any) => any;
}
class HttpServiceImpl implements HttpService {
public serverAdress: string;
constructor(serverAdress: string) {
this.serverAdress = serverAdress;
}
public get(route: string, succes: () => any, error: () => any): any {}
}

25
src/store/GameStore.ts Normal file
View File

@ -0,0 +1,25 @@
import { CommonMancalaGame, MancalaGame } from "mancala.js";
import { HttpService } from "../service/HttpService";
export interface GameStore {
get(id: string): Promise<MancalaGame | undefined>;
}
export class GameStoreImpl implements GameStore {
httpService: HttpService;
constructor(props: { httpService: HttpService }) {
this.httpService = props.httpService;
}
async get(id: string): Promise<MancalaGame | undefined> {
try {
const response = await this.httpService.get(`/game/${id}`);
const json = await response.json();
return MancalaGame.createFromMancalaGame(json as CommonMancalaGame);
} catch (error) {
// todo check error
Promise.resolve(undefined);
}
}
}

53
src/store/KeyStore.ts Normal file
View File

@ -0,0 +1,53 @@
import { HttpService } from "../service/HttpService"
const user_key = "user_key"
export interface UserKeyStore {
getUserKey: () => Promise<string>;
}
export class UserKeyStoreImpl implements UserKeyStore {
private httpService: HttpService;
private keyStoreHttp: UserKeyStore;
private keyStoreLocalStorage = new UserKeyStoreLocalStorage()
constructor(props: { httpService: HttpService }) {
this.httpService = props.httpService;
this.keyStoreHttp = new UserKeyStoreLocalHttp({ httpService: this.httpService });
}
public async getUserKey(): Promise<string> {
const maybeUserKey = await this.keyStoreLocalStorage.getUserKey();
if (maybeUserKey === undefined) {
const userKey = await this.keyStoreHttp.getUserKey()
this.keyStoreLocalStorage.storeUserKey(userKey)
return Promise.resolve(userKey);
} else {
return Promise.resolve(maybeUserKey);
}
}
}
export class UserKeyStoreLocalHttp implements UserKeyStore {
httpService: HttpService;
constructor(params: { httpService: HttpService }) {
this.httpService = params.httpService;
}
public async getUserKey(): Promise<string> {
const response = await this.httpService.get("/register/")
return response.text();
}
}
export class UserKeyStoreLocalStorage {
public getUserKey(): Promise<string | undefined> {
const userKey = localStorage.getItem(user_key)
return Promise.resolve(userKey === null ? undefined : userKey)
}
public storeUserKey(userKey: string): void {
localStorage.setItem(user_key, userKey)
}
}

View File

@ -1,51 +0,0 @@
import { server } from "../service/http_service"
const key = "user_key"
export type OnUserKeyFound = (userKey: string) => any
export interface UserKeyStore {
getUserKey: (callback: OnUserKeyFound) => any
}
export class UserKeyStoreImpl implements UserKeyStore {
private keyStoreHttp = new UserKeyStoreLocalHttp()
private keyStoreLocalStoreage = new UserKeyStoreLocalStoreage()
public getUserKey(callback: (userKey: string) => any): any {
this.keyStoreLocalStoreage.getUserKey((userKey)=>{
if(userKey){
callback(userKey)
}else{
this.keyStoreHttp.getUserKey((userKey)=>{
this.keyStoreLocalStoreage.storeUserKey(userKey)
callback(userKey)
})
}
})
}
}
export class UserKeyStoreLocalHttp implements UserKeyStore {
public getUserKey(callback: (userKey: string) => any): any {
const url = server.serverAdress + "/register/"
const requestOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
};
fetch(url, requestOptions)
.then(response => response.text())
.then(data => callback(data));
}
}
export class UserKeyStoreLocalStoreage implements UserKeyStore {
public getUserKey(callback: (userKey: string) => any): any {
const userKey = localStorage.getItem(key)
callback(userKey)
}
public storeUserKey(userKey : string) : any {
localStorage.setItem(key, userKey)
}
}

View File

@ -171,6 +171,13 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.8.tgz#822146080ac9c62dac0823bb3489622e0bc1cbdf" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.8.tgz#822146080ac9c62dac0823bb3489622e0bc1cbdf"
integrity sha512-RSKRfYX20dyH+elbJK2uqAkVyucL+xXzhqlMD5/ZXx+dAAwpyB7HsvnHe/ZUGOF+xLr5Wx9/JoXVTj6BQE2/oA== integrity sha512-RSKRfYX20dyH+elbJK2uqAkVyucL+xXzhqlMD5/ZXx+dAAwpyB7HsvnHe/ZUGOF+xLr5Wx9/JoXVTj6BQE2/oA==
"@babel/runtime@^7.7.6":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a"
integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.18.6": "@babel/template@^7.18.6":
version "7.18.6" version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31"
@ -1446,6 +1453,13 @@ has-flag@^4.0.0:
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
history@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==
dependencies:
"@babel/runtime" "^7.7.6"
htmlnano@^2.0.0: htmlnano@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/htmlnano/-/htmlnano-2.0.2.tgz#3e3170941e2446a86211196d740272ebca78f878" resolved "https://registry.yarnpkg.com/htmlnano/-/htmlnano-2.0.2.tgz#3e3170941e2446a86211196d740272ebca78f878"
@ -1744,6 +1758,21 @@ react-refresh@^0.9.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf"
integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ== integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==
react-router-dom@6:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d"
integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==
dependencies:
history "^5.2.0"
react-router "6.3.0"
react-router@6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557"
integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==
dependencies:
history "^5.2.0"
react-transition-state@^1.1.4: react-transition-state@^1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/react-transition-state/-/react-transition-state-1.1.4.tgz#113224eaa27e0ff81661305e44d5e0348cdf61ac" resolved "https://registry.yarnpkg.com/react-transition-state/-/react-transition-state-1.1.4.tgz#113224eaa27e0ff81661305e44d5e0348cdf61ac"
@ -1756,7 +1785,7 @@ react-transition-state@^1.1.4:
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
regenerator-runtime@^0.13.7: regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
version "0.13.9" version "0.13.9"
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz" resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz"
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==