Merge pull request #16 from jhalitaksoy/fix/refactor

Fix/refactor
This commit is contained in:
Halit Aksoy 2022-07-14 13:43:15 +03:00 committed by GitHub
commit 0976ae668a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 636 additions and 594 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "mancala-frontend", "name": "mancala-frontend",
"version": "0.1.3-beta.12", "version": "0.1.3-beta.12",
"description": "", "description": "Mancala Game Frontend",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "parcel src/index.html", "dev": "parcel src/index.html",

View File

@ -1,8 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { initContext } from './context'; import { initContext } from './context/context';
import Home from './Home'; import Home from './routes/Home';
initContext(); initContext();

View File

@ -1,346 +0,0 @@
import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
import BoardView from "./components/BoardView";
import { context } from "./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,
} from "./channel_names";
import Button from "./components/Button";
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 { Menu, MenuButton, MenuItem } from "@szhsin/react-menu";
import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css";
import { getColorByBrightness } from "./util/ColorUtil";
import { Theme } from "./theme/Theme";
type ConnectionState = "connecting" | "error" | "connected" | "reconnecting";
const Home: FunctionComponent<{ initial?: number }> = ({ initial = 0 }) => {
const [userKey, setUserKey] = useState(undefined);
const [connectionState, setConnetionState] =
useState<ConnectionState>("connecting");
const [searchingOpponent, setSearchingOpponent] = useState<boolean>(false);
const [game, setGame] = useState<CommonMancalaGame>(undefined);
const [crashMessage, setCrashMessage] = useState<string>(undefined);
const [userKeyWhoLeave, setUserKeyWhoLeave] = useState<string>(undefined);
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel>(null);
const [boardId, setBoardId] = useState<string>();
const [pitAnimator, setPitAnimator] = useState<PitAnimator>();
const [theme, setTheme] = useState<Theme | undefined>(undefined);
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);
});
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);
});
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);
});
};
const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
boardViewModel.id = v4();
setBoardId(boardViewModel.id);
setBoardViewModel(boardViewModel);
};
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 && updateBoardViewModel(pitAnimator.getBoardViewModelFromGame(game));
};
}, [boardViewModel]);
const resetGameState = () => {
setGame(undefined);
setCrashMessage(undefined);
setUserKeyWhoLeave(undefined);
};
const newGameClick = () => {
resetGameState();
setSearchingOpponent(true);
context.rtmt.sendMessage("new_game", {});
};
const leaveGame = () => {
context.rtmt.sendMessage(channel_leave_game, {});
};
const getBoardIndex = (index: number) => {
if (userKey === game.player2Id) return index + game.board.pits.length / 2;
return index;
};
const onHoleSelect = (index: number, pit: Pit) => {
//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 renderNewGameButton = () => {
const newGame = (
<Button
context={context}
text={context.texts.NewGame}
color={context.themeManager.theme.holeColor}
onClick={newGameClick}
/>
);
if (userKeyWhoLeave) {
return newGame;
}
if (crashMessage) {
return newGame;
}
if (!game) {
return newGame;
} else if (game.state == "ended") {
return newGame;
}
return <></>;
};
const menuTextColor = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
background: theme?.background,
flex: "1",
minHeight: "400px"
}}
>
{showConnectionState && (
<div
style={{
position: "absolute",
bottom: "0px",
left: "0px",
padding: "15px ",
borderTopRightRadius: "1vw",
minWidth: "10vw",
minHeight: "1vw",
background: context.themeManager.theme.textColor,
color: context.themeManager.theme.textLightColor,
}}
>
{connectionStateText()}
</div>
)}
<div
style={{
padding: "0px 4vw",
background: context.themeManager.theme.appBarBgColor,
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
alignSelf: "stretch",
}}
>
<h1 style={{ color: menuTextColor, margin: "10px 0px" }}>
{context.texts.Mancala}
</h1>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
marginRight: "1vw",
display: "flex",
alignItems: "center",
}}
>
<Menu
menuStyle={{
background: context.themeManager.theme.appBarBgColor,
}}
menuButton={
<span
style={{ color: menuTextColor }}
class="material-symbols-outlined"
>
light_mode
</span>
}
transition
align="end"
>
{context.themeManager.themes.map((theme) => {
return (
<MenuItem
style={{
color: menuTextColor,
}}
onMouseOver={(event) =>
(event.target.style.background =
context.themeManager.theme.background)
}
onMouseOut={(event) =>
(event.target.style.background = "transparent")
}
onClick={() => (context.themeManager.theme = theme)}
>
<div
style={{
borderRadius: "5vw",
background: theme.boardColor,
width: "1vw",
height: "1vw",
marginRight: "1vw",
}}
></div>
{theme.name}
</MenuItem>
);
})}
</Menu>
</div>
{renderNewGameButton()}
{game &&
!userKeyWhoLeave &&
!crashMessage &&
(game?.state === "playing" || game?.state === "initial") && (
<Button
context={context}
color={context.themeManager.theme.holeColor}
text={context.texts.Leave}
onClick={leaveGame}
/>
)}
</div>
</div>
<InfoPanel
context={context}
game={game}
crashMessage={crashMessage}
userKey={userKey}
userKeyWhoLeave={userKeyWhoLeave}
searchingOpponent={searchingOpponent}
/>
{game && boardViewModel && (
<BoardView
userKey={userKey}
game={game}
boardId={boardId}
boardViewModel={boardViewModel}
context={context}
onHoleSelect={onHoleSelect}
/>
)}
</div>
);
};
export default Home;

View File

@ -9,7 +9,7 @@ import {
GAME_STEP_DOUBLE_STONE_IN_PIT, GAME_STEP_DOUBLE_STONE_IN_PIT,
} from "mancala.js"; } from "mancala.js";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { Context } from "../context"; import { Context } from "../context/context";
import BoardViewModelFactory from "../factory/BoardViewModelFactory"; import BoardViewModelFactory from "../factory/BoardViewModelFactory";
import { PitViewModelFactory } from "../factory/PitViewModelFactory"; import { PitViewModelFactory } from "../factory/PitViewModelFactory";
import { getColorByBrightness } from "../util/ColorUtil"; import { getColorByBrightness } from "../util/ColorUtil";
@ -19,14 +19,14 @@ const animationUpdateInterval = 300;
export default class PitAnimator { export default class PitAnimator {
context: Context; context: Context;
game: MancalaGame; game: MancalaGame | undefined;
oldGame: MancalaGame; oldGame: MancalaGame | undefined;
currentIntervalID: number; currentIntervalID: number;
onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void; onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void;
boardViewModel: BoardViewModel; boardViewModel: BoardViewModel | undefined;
oldBoardViewModel: BoardViewModel; oldBoardViewModel: BoardViewModel | undefined;
animationIndex: number = 0; animationIndex: number = 0;
currentHistoryItem: HistoryItem; currentHistoryItem: HistoryItem | undefined;
constructor( constructor(
context: Context, context: Context,
@ -55,7 +55,7 @@ export default class PitAnimator {
onGameMoveAnimationStart() { onGameMoveAnimationStart() {
this.stopCurrentAnimation(); this.stopCurrentAnimation();
if (this.game.history.length > 0) { if (this.game && this.oldGame && this.game.history.length > 0) {
const lastHistoryItem = this.game.history[this.game.history.length - 1]; const lastHistoryItem = this.game.history[this.game.history.length - 1];
if (lastHistoryItem.gameSteps.length > 0) { if (lastHistoryItem.gameSteps.length > 0) {
this.animationIndex = 0; this.animationIndex = 0;
@ -68,6 +68,7 @@ export default class PitAnimator {
} }
onAnimate() { onAnimate() {
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));
@ -91,9 +92,10 @@ export default class PitAnimator {
boardViewModel: BoardViewModel, boardViewModel: BoardViewModel,
gameStep: GameStep gameStep: GameStep
) { ) {
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 one stone move case, TODO : beautify it later //This is one stone move case, TODO: beautify it later
if (this.getGameMoveStepCount(this.currentHistoryItem) === 1) { if (this.getGameMoveStepCount(this.currentHistoryItem) === 1) {
const previousPitIndex = gameStep.index - 1; const previousPitIndex = gameStep.index - 1;
if (previousPitIndex > 0) { if (previousPitIndex > 0) {
@ -172,7 +174,7 @@ export default class PitAnimator {
const theme = this.context.themeManager.theme; const theme = this.context.themeManager.theme;
const stoneCount = pit.stoneCount; const stoneCount = pit.stoneCount;
const stoneColor = theme.stoneColor; const stoneColor = theme.stoneColor;
const pitColor = theme.holeColor; const pitColor = theme.pitColor;
const id = pit.index.toString(); const id = pit.index.toString();
return PitViewModelFactory.create({ return PitViewModelFactory.create({
id, id,
@ -185,15 +187,15 @@ export default class PitAnimator {
public resetAnimationState() { public resetAnimationState() {
this.animationIndex = -1; this.animationIndex = -1;
this.currentHistoryItem = null; this.currentHistoryItem = undefined;
this.boardViewModel = null; this.boardViewModel = undefined;
this.oldBoardViewModel = null; this.oldBoardViewModel = undefined;
} }
public reset() { public reset() {
this.resetAnimationState(); this.resetAnimationState();
this.game = null; this.game = undefined;
this.oldGame = null; this.oldGame = undefined;
} }
public dispose() { public dispose() {

View File

@ -1,208 +0,0 @@
import { Bank, MancalaGame, Pit } from "mancala.js";
import * as React from "react";
import { FunctionComponent, useState } from "react";
import { Context } from "../context";
import { getColorByBrightness } from "../util/ColorUtil";
import BoardViewModel from "../viewmodel/BoardViewModel";
import PitViewModel from "../viewmodel/PitViewModel";
const BallView: FunctionComponent<{ color: string }> = ({ color }) => {
return (
<div
style={{
background: color,
margin: "1px",
width: "1vw",
height: "1vw",
borderRadius: "10vw",
transition: "background-color 0.5s",
}}
></div>
);
};
function range(size: number) {
var ans = [];
for (let i = 0; i < size; i++) {
ans.push(i);
}
return ans;
}
const HoleView: FunctionComponent<{
pitViewModel: PitViewModel;
onClick: () => void;
}> = ({ pitViewModel, onClick }) => {
const balls = [...range(pitViewModel.stoneCount)].map((i) => (
<BallView color={pitViewModel.stoneColor} />
));
return (
<div
onClick={onClick}
style={{
background: pitViewModel.pitColor,
margin: "5px",
padding: "5px",
borderRadius: "10vw",
transition: "background-color 0.5s",
display: "flex",
alignItems: "center",
alignContent: "center",
justifyContent: "center",
justifyItems: "center",
flexWrap: "wrap",
}}
>
{balls}
</div>
);
};
const StoreView: FunctionComponent<{
context: Context;
pitViewModel: PitViewModel;
gridColumn: string;
gridRow: string;
}> = ({ context, pitViewModel, gridColumn, gridRow }) => {
const balls = [...range(pitViewModel.stoneCount)].map((i) => (
<BallView color={pitViewModel.stoneColor} />
));
const textColor = getColorByBrightness(
pitViewModel.pitColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<div
style={{
gridColumn: gridColumn,
gridRow: gridRow,
background: pitViewModel.pitColor,
margin: "5px",
borderRadius: "10vw",
display: "flex",
alignItems: "center",
justifyContent: "center",
alignContent: "center",
flexWrap: "wrap",
position: "relative",
}}
>
{balls}
<span
style={{
position: "absolute",
bottom: "2vw",
fontFamily: "monospace",
fontWeight: "bold",
fontSize: "2vw",
color: textColor,
}}
>
{balls.length}
</span>
</div>
);
};
const BoardView: FunctionComponent<{
game?: MancalaGame;
context: Context;
boardId: string;
boardViewModel: BoardViewModel;
userKey: string;
onHoleSelect: (index: number, hole: Pit) => void;
}> = ({ game, context, boardId, boardViewModel, userKey, onHoleSelect }) => {
const createPitView = (pitViewModel: PitViewModel, onClick: () => void) => {
return <HoleView pitViewModel={pitViewModel} onClick={onClick} />;
};
const player1Pits = game?.board.player1Pits.map((pit) => {
const pitViewModel = boardViewModel.pits[pit.index];
return createPitView(pitViewModel, () => {
if (game.turnPlayerId === game.player1Id)
onHoleSelect(game.board.player1Pits.indexOf(pit), pit);
});
});
const player2Pits = game!!.board.player2Pits.map((pit) => {
const pitViewModel = boardViewModel.pits[pit.index];
return createPitView(pitViewModel, () => {
if (game.turnPlayerId === game.player2Id)
onHoleSelect(game.board.player2Pits.indexOf(pit), pit);
});
});
const isUserTurn = game.checkIsPlayerTurn(userKey);
const theme = context.themeManager.theme;
const player1BankViewModel =
boardViewModel.pits[game.board.player1BankIndex()];
const player2BankViewModel =
boardViewModel.pits[game.board.player2BankIndex()];
const player1Bank = (
<StoreView
context={context}
pitViewModel={player1BankViewModel}
gridColumn="1 / 2"
gridRow="1 / 3"
/>
);
const player2Bank = (
<StoreView
context={context}
pitViewModel={player2BankViewModel}
gridColumn="8 / 9"
gridRow="1 / 3"
/>
);
return (
<div
style={{
margin: "1vw",
padding: "2vw",
display: "grid",
gridTemplateColumns: "repeat(8, 11vw)",
gridTemplateRows: "repeat(2, 11vw)",
borderRadius: "3vw",
transition: "background-color 0.5s",
background: theme.boardColor,
}}
>
{userKey === game.player2Id ? (
<>
<StoreView
context={context}
pitViewModel={player1BankViewModel}
gridColumn="1 / 2"
gridRow="1 / 3"
/>
<StoreView
context={context}
pitViewModel={player2BankViewModel}
gridColumn="8 / 9"
gridRow="1 / 3"
/>
{player1Pits.reverse()}
{player2Pits}
</>
) : (
<>
<StoreView
context={context}
pitViewModel={player1BankViewModel}
gridColumn="8 / 9"
gridRow="1 / 3"
/>
<StoreView
context={context}
pitViewModel={player2BankViewModel}
gridColumn="1 / 2"
gridRow="1 / 3"
/>
{player2Pits.reverse()}
{player1Pits}
</>
)}
</div>
);
};
export default BoardView;

View File

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Context } from "../context"; import { Context } from "../context/context";
import { getColorByBrightness } from "../util/ColorUtil"; import { getColorByBrightness } from "../util/ColorUtil";
const Button: FunctionComponent<{ const Button: FunctionComponent<{

View File

@ -0,0 +1,27 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../context/context";
const FloatingPanel: FunctionComponent<{
context: Context;
color: string;
visible: boolean;
}> = (props) => {
if(!props.visible) return <></>
return (
<div style={{
position: "absolute",
bottom: "0px",
left: "0px",
padding: "15px",
borderTopRightRadius: "1vw",
minWidth: "10vw",
minHeight: "1vw",
backgroundColor: props.color,
}}>
{props.children}
</div>
)
};
export default FloatingPanel;

View File

@ -0,0 +1,132 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { MancalaGame } from "mancala.js";
import { Context } from "../context/context";
import { getColorByBrightness } from "../util/ColorUtil";
import Button from "./Button";
import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css";
function renderNewGameButton(
context: Context, game:
MancalaGame | undefined,
onNewGameClick: () => void,
userKeyWhoLeave: string | undefined,
crashMessage: string | undefined): JSX.Element {
const newGame = (
<Button
context={context}
text={context.texts.NewGame}
color={context.themeManager.theme.pitColor}
onClick={onNewGameClick}
/>
);
if (userKeyWhoLeave) {
return newGame;
}
if (crashMessage) {
return newGame;
}
if (!game) {
return newGame;
} else if (game.state == "ended") {
return newGame;
}
return <></>;
};
const HeaderBar: FunctionComponent<{
context: Context,
game?: MancalaGame,
userKeyWhoLeave?: string,
crashMessage?: string,
onNewGameClick: () => void,
onLeaveGameClick: () => void
}> = (props) => {
const { context, game, userKeyWhoLeave, crashMessage, onNewGameClick, onLeaveGameClick } = props;
const textColor = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<div style={{
padding: "0px 4vw",
background: context.themeManager.theme.appBarBgColor,
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
alignSelf: "stretch",
}}>
<h1 style={{ color: textColor, margin: "10px 0px" }}>
{context.texts.Mancala}
</h1>
<div style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}} >
<div style={{
marginRight: "1vw",
display: "flex",
alignItems: "center",
}}>
<ThemeSwitchMenu context={context} textColor={textColor} />
</div>
{renderNewGameButton(context, game, onNewGameClick, userKeyWhoLeave, crashMessage)}
{game &&
!userKeyWhoLeave &&
!crashMessage &&
(game?.state === "playing" || game?.state === "initial") && (
<Button
context={context}
color={context.themeManager.theme.pitColor}
text={context.texts.Leave}
onClick={onLeaveGameClick} />
)}
</div>
</div>)
}
export default HeaderBar;
const ThemeSwitchMenu: FunctionComponent<{ context: Context, textColor: string }> = (props) => {
const { context, textColor } = props;
const menuButton = <span
style={{ color: textColor }}
className="material-symbols-outlined">
light_mode
</span>;
const menuItems = context.themeManager.themes.map((theme, index) => {
return (<MenuItem
key={index}
style={{ color: textColor }}
//@ts-ignore
onMouseOver={(event) => (event.target.style.background =
context.themeManager.theme.background)}
//@ts-ignore
onMouseOut={(event) => (event.target.style.background = "transparent")}
onClick={() => (context.themeManager.theme = theme)}>
<div style={{
borderRadius: "5vw",
background: theme.boardColor,
width: "1vw",
height: "1vw",
marginRight: "1vw",
}} />
{theme.name}
</MenuItem>);
})
return (<Menu
menuStyle={{
background: context.themeManager.theme.appBarBgColor,
}}
menuButton={menuButton}
transition
align="end">
{menuItems}
</Menu>);
}

View File

@ -1,17 +1,17 @@
import { MancalaGame } from "mancala.js"; import { MancalaGame } from "mancala.js";
import * as React from "react"; import * as React from "react";
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Context } from "../context"; import { Context } from "../context/context";
import { getColorByBrightness } from "../util/ColorUtil"; import { getColorByBrightness } from "../util/ColorUtil";
function getInfoPanelTextByGameState(params: { function getInfoPanelTextByGameState(params: {
context: Context; context: Context;
game: MancalaGame; game?: MancalaGame;
crashMessage: string; crashMessage?: string;
userKey: string; userKey?: string;
userKeyWhoLeave: string; userKeyWhoLeave?: string;
searchingOpponent: boolean; searchingOpponent: boolean;
}): string { }): string | undefined {
const { const {
context, context,
game, game,
@ -42,9 +42,9 @@ function getInfoPanelTextByGameState(params: {
return context.texts.GameEnded + " " + whoWon; return context.texts.GameEnded + " " + whoWon;
} else { } else {
if (game) { if (game) {
return game.checkIsPlayerTurn(userKey) return userKey ? game.checkIsPlayerTurn(userKey)
? context.texts.YourTurn ? context.texts.YourTurn
: context.texts.OpponentTurn; : context.texts.OpponentTurn : undefined;
} }
} }
return undefined; return undefined;
@ -70,10 +70,10 @@ const InfoPanelContainer: FunctionComponent<{
const InfoPanel: FunctionComponent<{ const InfoPanel: FunctionComponent<{
context: Context; context: Context;
game: MancalaGame; game?: MancalaGame;
crashMessage: string; crashMessage?: string;
userKey: string; userKey?: string;
userKeyWhoLeave: string; userKeyWhoLeave?: string;
searchingOpponent: boolean; searchingOpponent: boolean;
}> = ({ }> = ({
context, context,
@ -83,7 +83,7 @@ const InfoPanel: FunctionComponent<{
userKeyWhoLeave, userKeyWhoLeave,
searchingOpponent, searchingOpponent,
}) => { }) => {
const isUserTurn = game?.checkIsPlayerTurn(userKey); const isUserTurn = userKey ? game?.checkIsPlayerTurn(userKey) : false;
const containerColor = isUserTurn const containerColor = isUserTurn
? context.themeManager.theme.playerTurnColor ? context.themeManager.theme.playerTurnColor
: context.themeManager.theme.boardColor; : context.themeManager.theme.boardColor;

View File

@ -0,0 +1,20 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Theme } from "../theme/Theme";
const PageContainer: FunctionComponent<{theme: Theme}> = (props) => {
return (
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
background: props.theme?.background,
flex: "1",
minHeight: "400px"
}}>
{props.children}
</div>
);
}
export default PageContainer;

View File

@ -0,0 +1,72 @@
import * as React from "react";
import { MancalaGame, Pit } from "mancala.js";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import BoardViewModel from "../../viewmodel/BoardViewModel";
import PitViewModel from "../../viewmodel/PitViewModel";
import PitView from "./PitView";
import StoreView from "./StoreView";
const BoardView: FunctionComponent<{
game: MancalaGame;
context: Context;
boardId: string;
boardViewModel: BoardViewModel;
userKey: string;
onPitSelect: (index: number, pit: Pit) => void;
}> = ({ game, context, boardId, boardViewModel, userKey, onPitSelect: onPitSelect }) => {
const createPitView = (key: any, pitViewModel: PitViewModel, onClick: () => void) => {
return <PitView key={key} pitViewModel={pitViewModel} onClick={onClick} />;
};
const player1Pits = game?.board.player1Pits.map((pit, index) => {
const pitViewModel = boardViewModel.pits[pit.index];
return createPitView(index, pitViewModel, () => {
if (game.turnPlayerId === game.player1Id && userKey === game.player1Id)
onPitSelect(game.board.player1Pits.indexOf(pit), pit);
});
});
const player2Pits = game?.board.player2Pits.map((pit, index) => {
const pitViewModel = boardViewModel.pits[pit.index];
return createPitView(index, pitViewModel, () => {
if (game.turnPlayerId === game.player2Id && userKey === game.player2Id)
onPitSelect(game.board.player2Pits.indexOf(pit), pit);
});
});
const theme = context.themeManager.theme;
const player1BankViewModel =
boardViewModel.pits[game.board.player1BankIndex()];
const player2BankViewModel =
boardViewModel.pits[game.board.player2BankIndex()];
const isPlayer2 = userKey === game?.player2Id;
return (
<div
style={{
margin: "1vw",
padding: "2vw",
display: "grid",
gridTemplateColumns: "repeat(8, 11vw)",
gridTemplateRows: "repeat(2, 11vw)",
borderRadius: "3vw",
transition: "background-color 0.5s",
background: theme.boardColor,
}}
>
<StoreView
context={context}
pitViewModel={isPlayer2 ? player2BankViewModel : player1BankViewModel}
gridColumn="8 / 9"
gridRow="1 / 3"
/>
<StoreView
context={context}
pitViewModel={isPlayer2 ? player1BankViewModel : player2BankViewModel}
gridColumn="1 / 2"
gridRow="1 / 3"
/>
{isPlayer2 ? player1Pits?.reverse() : player2Pits?.reverse()}
{isPlayer2 ? player2Pits : player1Pits}
</div>
);
};
export default BoardView;

View File

@ -0,0 +1,38 @@
import * as React from "react";
import { FunctionComponent } from "react";
import Util from "../../util/Util";
import PitViewModel from "../../viewmodel/PitViewModel";
import StoneView from "./StoneView";
const PitView: FunctionComponent<{
pitViewModel: PitViewModel;
onClick: () => void;
}> = ({ pitViewModel, onClick }) => {
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
<StoneView key={index} color={pitViewModel.stoneColor} />
));
return (
<div
onClick={onClick}
style={{
background: pitViewModel.pitColor,
margin: "5px",
padding: "5px",
borderRadius: "10vw",
transition: "background-color 0.5s",
display: "flex",
alignItems: "center",
alignContent: "center",
justifyContent: "center",
justifyItems: "center",
flexWrap: "wrap",
}}
>
{stones}
</div>
);
};
export default PitView;

View File

@ -0,0 +1,19 @@
import * as React from "react";
import { FunctionComponent } from "react";
const StoneView: FunctionComponent<{ color: string }> = ({ color }) => {
return (
<div
style={{
background: color,
margin: "1px",
width: "1vw",
height: "1vw",
borderRadius: "10vw",
transition: "background-color 0.5s",
}}
/>
);
};
export default StoneView;

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import { getColorByBrightness } from "../../util/ColorUtil";
import Util from "../../util/Util";
import PitViewModel from "../../viewmodel/PitViewModel";
import StoneView from "./StoneView";
const StoreView: FunctionComponent<{
context: Context;
pitViewModel: PitViewModel;
gridColumn: string;
gridRow: string;
}> = ({ context, pitViewModel, gridColumn, gridRow }) => {
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
<StoneView key={index} color={pitViewModel.stoneColor} />
));
const textColor = getColorByBrightness(
pitViewModel.pitColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<div
style={{
gridColumn: gridColumn,
gridRow: gridRow,
background: pitViewModel.pitColor,
margin: "5px",
borderRadius: "10vw",
display: "flex",
alignItems: "center",
justifyContent: "center",
alignContent: "center",
flexWrap: "wrap",
position: "relative",
}}
>
{stones}
<span
style={{
position: "absolute",
bottom: "2vw",
fontFamily: "monospace",
fontWeight: "bold",
fontSize: "2vw",
color: textColor,
}}
>
{stones.length}
</span>
</div>
);
};
export default StoreView;

View File

@ -1,8 +1,8 @@
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 { UserKeyStore, UserKeyStoreImpl } from "../store/key_store";
import ThemeManager from "./theme/ThemeManager"; import ThemeManager from "../theme/ThemeManager";
export type Context = { export type Context = {
rtmt: RTMT; rtmt: RTMT;

220
src/routes/Home.tsx Normal file
View File

@ -0,0 +1,220 @@
import * as React 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,
} 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 { Theme } from "../theme/Theme";
import HeaderBar from "../components/HeaderBar";
import FloatingPanel from "../components/FloatingPanel";
import PageContainer from "../components/PageContainer";
type ConnectionState = "connecting" | "error" | "connected" | "reconnecting";
const Home: FunctionComponent<{ initial?: number }> = ({ initial = 0 }) => {
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);
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);
});
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);
});
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);
});
};
const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
boardViewModel.id = v4();
setBoardId(boardViewModel.id);
setBoardViewModel(boardViewModel);
};
const resetGameState = () => {
setGame(undefined);
setCrashMessage(undefined);
setUserKeyWhoLeave(undefined);
};
const getBoardIndex = (index: number) => {
if(!game) return -1;
if (userKey === game.player2Id) return index + game.board.pits.length / 2;
return index;
};
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 = () => {
resetGameState();
setSearchingOpponent(true);
context.rtmt.sendMessage("new_game", {});
};
const onLeaveGameClick = () => {
context.rtmt.sendMessage(channel_leave_game, {});
};
const onPitSelect = (index: number, pit: Pit) => {
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
);
return (
<PageContainer theme={theme!}>
<FloatingPanel context={context} color={context.themeManager.theme.boardColor} visible={showConnectionState}>
<span style={{ color: textColorOnBoard }}>{connectionStateText()}</span>
</FloatingPanel>
<HeaderBar
context={context}
game={game}
userKeyWhoLeave={userKeyWhoLeave}
crashMessage={crashMessage}
onNewGameClick={onNewGameClick}
onLeaveGameClick={onLeaveGameClick} />
<InfoPanel
context={context}
game={game}
crashMessage={crashMessage}
userKey={userKey}
userKeyWhoLeave={userKeyWhoLeave}
searchingOpponent={searchingOpponent} />
{game && boardViewModel && userKey && (
<BoardView
userKey={userKey}
game={game}
boardId={boardId}
boardViewModel={boardViewModel}
context={context}
onPitSelect={onPitSelect} />
)}
</PageContainer>
);
};
export default Home;

View File

@ -19,7 +19,7 @@ const darkTheme: Theme = {
textLightColor: "#AAAAAA", textLightColor: "#AAAAAA",
playerTurnColor: colors.tertiary, playerTurnColor: colors.tertiary,
boardColor: colors.secondary, boardColor: colors.secondary,
holeColor: colors.tertiary, pitColor: colors.tertiary,
pitSelectedColor: colors.secondary, pitSelectedColor: colors.secondary,
stoneColor: "#252525", stoneColor: "#252525",
stoneLightColor: "#252525", stoneLightColor: "#252525",

View File

@ -9,7 +9,7 @@ const greyTheme: Theme = {
textLightColor: "#EEEEEE", textLightColor: "#EEEEEE",
playerTurnColor: "#84b8a6", playerTurnColor: "#84b8a6",
boardColor: "#4D606E", boardColor: "#4D606E",
holeColor: "#D3D4D8", pitColor: "#D3D4D8",
pitSelectedColor: "#8837fa", pitSelectedColor: "#8837fa",
stoneColor: "#393E46", stoneColor: "#393E46",
stoneLightColor: "#EEEEEE", stoneLightColor: "#EEEEEE",

View File

@ -11,7 +11,7 @@ const lightTheme: Theme = {
textLightColor: "#EBEBEB", textLightColor: "#EBEBEB",
playerTurnColor: "#6B6B6B", playerTurnColor: "#6B6B6B",
boardColor: "#9B9B9B", boardColor: "#9B9B9B",
holeColor: "#B8B8B8", pitColor: "#B8B8B8",
pitSelectedColor: "#9B9B9B", pitSelectedColor: "#9B9B9B",
stoneColor: "#5B5B5B", stoneColor: "#5B5B5B",
stoneLightColor: "#3B3B3B", stoneLightColor: "#3B3B3B",

View File

@ -7,7 +7,7 @@ export type Theme = {
appBarBgColor: string; appBarBgColor: string;
playerTurnColor: string; playerTurnColor: string;
boardColor: string; boardColor: string;
holeColor: string; pitColor: string;
pitSelectedColor: string; pitSelectedColor: string;
stoneColor: string; stoneColor: string;
stoneLightColor: string; stoneLightColor: string;

10
src/util/Util.ts Normal file
View File

@ -0,0 +1,10 @@
export default class Util {
public static range(size: number) {
var ans : number[] = [];
for (let i = 0; i < size; i++) {
ans.push(i);
}
return ans;
}
}