first commit

This commit is contained in:
unknown
2023-01-27 20:50:01 +08:00
commit a5e17d8c5d
69 changed files with 12547 additions and 0 deletions

View File

@ -0,0 +1,26 @@
import PropTypes from 'prop-types';
import {
DraughtsSettingsProvider,
DraughtsSettingsProviderProps,
} from './settings/DraughtsSettingsContext';
import {
DraughtsBoardProvider,
DraughtsBoardProviderProps,
} from './board/DraughtsBoardContext';
import { DraughtsGameProvider } from './game/DraughtsGameContext';
export function DraughtsProvider(props) {
return (
<DraughtsSettingsProvider {...props.settings}>
<DraughtsBoardProvider {...props.board}>
<DraughtsGameProvider>{props.children}</DraughtsGameProvider>
</DraughtsBoardProvider>
</DraughtsSettingsProvider>
);
}
DraughtsProvider.propTypes = {
board: PropTypes.shape(DraughtsBoardProviderProps),
children: PropTypes.node.isRequired,
settings: PropTypes.shape(DraughtsSettingsProviderProps),
};

View File

@ -0,0 +1,59 @@
import { HStack, VStack, Text, keyframes } from '@chakra-ui/react';
import { useDraughtsPlayerToMove } from './board/hooks/use-draughts-player-to-move';
import { useDraughtsGame } from './game/DraughtsGameContext';
import { useDraughtsSettings } from './settings/DraughtsSettingsContext';
import { Players } from '@draughts/core';
const glowKeyframe = keyframes`
from {
background-color: rgba(144, 238, 144, 0.3)
}
to {
background-color: rgba(144, 238, 144, 0.6)
}
`;
const glowAnimation = `${glowKeyframe} 1s ease 0s infinite alternate`;
function formatTimer({ seconds, minutes }) {
const formatSeconds = String(seconds).padStart(2, '0');
return `${minutes}:${formatSeconds}`;
}
export function DraughtsGameInfoView() {
const { userPlayer } = useDraughtsSettings();
const { whiteTimer, blackTimer } = useDraughtsGame();
const whiteToMove = useDraughtsPlayerToMove(Players.WHITE);
const blackToMove = useDraughtsPlayerToMove(Players.BLACK);
return (
<VStack justify="space-between">
<HStack
align="center"
justify="space-between"
gap={3}
px={2}
borderRadius="0.2em"
animation={whiteToMove ? glowAnimation : undefined}
>
<Text fontSize="sm">
putih {userPlayer === Players.WHITE ? '(Anda)' : '(AI)'}
</Text>
<time>{formatTimer(whiteTimer)}</time>
</HStack>
<HStack
align="center"
justify="space-between"
gap={3}
px={2}
borderRadius="0.2em"
animation={blackToMove ? glowAnimation : undefined}
>
<Text fontSize="sm">
hitam {userPlayer === Players.BLACK ? '(Anda)' : '(AI)'}
</Text>
<time>{formatTimer(blackTimer)}</time>
</HStack>
</VStack>
);
}

View File

@ -0,0 +1,51 @@
import { createContext, useCallback, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { Board, Pieces, Players } from '@draughts/core';
export const DraughtsBoardContext = createContext();
export const useDraughtsBoard = () => useContext(DraughtsBoardContext);
export function DraughtsBoardProvider(props) {
const [lastMove, setLastMove] = useState(null);
const [board, setBoard] = useState(
new Board(props.position, props.playerToMove)
);
const doMove = useCallback((move) => {
setBoard((board) => {
return board.doMove(move);
});
setLastMove(move.path.at(-1));
}, []);
const resetBoard = useCallback(() => {
setBoard(new Board(props.position, props.playerToMove));
setLastMove(null);
}, [props.position, props.playerToMove]);
return (
<DraughtsBoardContext.Provider
value={{
board,
doMove,
lastMove,
resetBoard,
}}
>
{props.children}
</DraughtsBoardContext.Provider>
);
}
export const DraughtsBoardProviderProps = {
playerToMove: PropTypes.oneOf(Object.values(Players)),
position: PropTypes.arrayOf(
PropTypes.arrayOf(PropTypes.oneOf(Object.values(Pieces)))
).isRequired,
};
DraughtsBoardProvider.propTypes = {
children: PropTypes.node.isRequired,
...DraughtsBoardProviderProps,
};

View File

@ -0,0 +1,6 @@
import { useDraughtsBoard } from '../DraughtsBoardContext';
export function useDraughtsPlayerToMove(player) {
const { board } = useDraughtsBoard();
return player === board.playerToMove;
}

View File

@ -0,0 +1,17 @@
import { useMemo } from 'react';
import { useDraughtsBoard } from '../DraughtsBoardContext';
import { useDraughtsSettings } from '../../settings/DraughtsSettingsContext';
import { Players, GameStates } from '@draughts/core';
export function useDraughtsWinner() {
const { board } = useDraughtsBoard();
const { userPlayer } = useDraughtsSettings();
const winner = useMemo(() => {
if (board.state === GameStates.WHITE_WON) return Players.WHITE;
if (board.state === GameStates.BLACK_WON) return Players.BLACK;
return Players.NONE;
}, [board.state]);
return { userWon: winner === userPlayer, winner };
}

View File

@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndProvider } from 'react-dnd';
import { Grid, GridItem } from '@chakra-ui/react';
import { TouchBackend } from 'react-dnd-touch-backend';
import { useDraughtsBoard } from '../DraughtsBoardContext';
import { useDraughtsSettings } from '../../settings/DraughtsSettingsContext';
import { DraughtsCell } from './DraughtsCell';
import { DraughtsGameOverModal } from './DraughtsGameOverModal';
import { Players } from '@draughts/core';
function isTouchDevice() {
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
);
}
export function DraughtsBoard() {
const { board } = useDraughtsBoard();
const { userPlayer } = useDraughtsSettings();
const backend = useMemo(() => {
if (typeof window === 'undefined') return HTML5Backend;
return isTouchDevice() ? TouchBackend : HTML5Backend;
}, []);
const rows = useMemo(() => {
const entries = board.position.map((row, rowIndex) => ({
row,
rowIndex,
}));
if (userPlayer === Players.WHITE) {
return entries;
}
return entries.reverse();
}, [userPlayer, board.position]);
return (
<DndProvider backend={backend}>
<DraughtsGameOverModal />
<Grid
templateRows="repeat(8, 1fr)"
templateColumns="repeat(8, 1fr)"
w="100%"
h="100%"
style={{ aspectRatio: 1 }}
>
{rows.map(({ row, rowIndex }) =>
row.map((piece, colIndex) => (
// eslint-disable-next-line react/no-array-index-key
<GridItem key={`${rowIndex}:${colIndex}:${piece}`}>
<DraughtsCell
piece={piece}
rowIndex={rowIndex}
colIndex={colIndex}
/>
</GridItem>
))
)}
</Grid>
</DndProvider>
);
}

View File

@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import { useDrop } from 'react-dnd';
import { Box, Square } from '@chakra-ui/react';
import { useDraughtsBoard } from '../DraughtsBoardContext';
import { DraughtsPiece } from './DraughtsPiece';
import { compareCells, Pieces } from '@draughts/core';
DraughtsCell.propTypes = {
colIndex: PropTypes.number.isRequired,
piece: PropTypes.oneOf(Object.values(Pieces)).isRequired,
rowIndex: PropTypes.number.isRequired,
};
export function DraughtsCell(props) {
const { board, doMove, lastMove } = useDraughtsBoard();
const isDark = (props.rowIndex + props.colIndex) % 2 === 0;
const currentCell = { col: props.colIndex, row: props.rowIndex };
const isLastMove = compareCells(currentCell, lastMove);
const validMovePredicate = (start) => (move) => {
const startMove = move.path.at(0);
const endMove = move.path.at(-1);
return compareCells(startMove, start) && compareCells(endMove, currentCell);
};
const [{ isOver, canDrop }, dropReference] = useDrop(
() => ({
accept: 'piece',
canDrop: (start) => board.moves.some(validMovePredicate(start)),
collect: (monitor) => ({
canDrop: !!monitor.canDrop(),
isOver: !!monitor.isOver(),
}),
drop: (start) => {
doMove(board.moves.find(validMovePredicate(start)));
},
}),
[board.moves, doMove]
);
let bg = 'gray.300';
if (canDrop) {
bg = 'lightblue';
} else if ((canDrop && isOver) || isLastMove) {
bg = 'lightgreen';
} else if (isDark) {
bg = 'gray.500';
}
return (
<Square
ref={dropReference}
pos="relative"
h="100%"
p="0.2em"
bg={bg}
userSelect="none"
>
<Box
pos="absolute"
top="0.1rem"
right="0.1rem"
fontSize="0.4rem"
opacity={0.4}
userSelect="none"
>
{props.rowIndex}, {props.colIndex}
</Box>
{props.piece !== Pieces.NONE && (
<DraughtsPiece
piece={props.piece}
rowIndex={props.rowIndex}
colIndex={props.colIndex}
/>
)}
</Square>
);
}

View File

@ -0,0 +1,13 @@
const { Icon } = require('@chakra-ui/react');
export function DraughtsCrown(props) {
return (
<Icon viewBox="0 0 230 200" {...props}>
<path
d="m9,51 72,21 37-64 37,64 72-21-33,99H42
m75-74a15,29 0 1,0 2,0m71,86-11,33H57l-11-33"
fill="gold"
/>
</Icon>
);
}

View File

@ -0,0 +1,82 @@
import { useEffect } from 'react';
import {
Modal,
Button,
Text,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
Divider,
} from '@chakra-ui/react';
import { useDraughtsBoard } from '../DraughtsBoardContext';
import { useDraughtsWinner } from '../hooks/use-draughts-winner';
import { useDraughtsGame } from '../../game/DraughtsGameContext';
import { useDraughtsSettings } from '../../settings/DraughtsSettingsContext';
import { formatPlayer, Players, GameStates } from '@draughts/core';
function capitalizeFirstLetter(string) {
return string.at(0).toUpperCase() + string.slice(1);
}
function formatGameOverMessage(winner) {
if (winner === Players.NONE) return 'The game was a draw.';
// const winnerFormatted = capitalizeFirstLetter(formatPlayer(winner));
// return `${winnerFormatted} won the game.`;
}
export function DraughtsGameOverModal() {
const { isOpen, onOpen, onClose } = useDisclosure();
const { restartGame } = useDraughtsGame();
const { board } = useDraughtsBoard();
const { settingsModal } = useDraughtsSettings();
const { userWon, winner } = useDraughtsWinner();
useEffect(() => {
if (board.state === GameStates.PLAYING) return;
onOpen();
return onClose;
}, [board.state, onOpen, onClose]);
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{userWon ? 'Wooo! Anda Menang. 🎉' : 'Semoga lain kali lebih beruntung... 💪'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text>{formatGameOverMessage(winner)}</Text>
<Divider marginY={5} />
<Text>
Mengapa bukan kesulitan komputer yang berbeda, Main Lagi Bosku, Anda Masih Kuat, Bertenaga?
</Text>
</ModalBody>
<ModalFooter>
<Button
mr={3}
colorScheme="blue"
onClick={() => {
onClose();
settingsModal.onOpen();
}}
>
Setting Game
</Button>
<Button
onClick={() => {
restartGame();
onClose();
}}
>
Main Lagi
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,64 @@
import { useDrag } from 'react-dnd';
import { Center } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useDraughtsBoard } from '../DraughtsBoardContext';
import { useDraughtsSettings } from '../../settings/DraughtsSettingsContext';
import { DraughtsCrown } from './DraughtsCrown';
import {
compareCells,
pieceIsPlayer,
pieceIsQueen,
Pieces,
Players,
} from '@draughts/core';
export function DraughtsPiece(props) {
const { board } = useDraughtsBoard();
const { userPlayer } = useDraughtsSettings();
const isWhite = pieceIsPlayer(props.piece, Players.WHITE);
const activePlayer =
pieceIsPlayer(props.piece, userPlayer) &&
pieceIsPlayer(props.piece, board.playerToMove);
const currentCell = { col: props.colIndex, row: props.rowIndex };
const canDrag =
activePlayer &&
board.moves.some((move) => compareCells(move.path.at(0), currentCell));
const [, dragRef] = useDrag(
() => ({
canDrag: () => canDrag,
isDragging: (monitor) => compareCells(monitor.getItem(), currentCell),
item: currentCell,
type: 'piece',
}),
[board, canDrag, currentCell]
);
return (
<Center
ref={dragRef}
zIndex="1"
w="100%"
h="100%"
opacity={canDrag ? 1 : 0.8}
borderWidth="0.2em"
borderStyle="solid"
borderColor={isWhite ? 'gray.400' : 'gray.600'}
borderRadius="50%"
bgColor={isWhite ? 'yellow.50' : 'gray.900'}
>
{pieceIsQueen(props.piece) && (
<DraughtsCrown w="70%" h="70%" opacity="0.6" userSelect="none" />
)}
</Center>
);
}
DraughtsPiece.propTypes = {
colIndex: PropTypes.number,
piece: PropTypes.oneOf(Object.values(Pieces)).isRequired,
rowIndex: PropTypes.number,
};

View File

@ -0,0 +1,37 @@
import { createContext, useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import { useDraughtsBoard } from '../board/DraughtsBoardContext';
import { useDraughtsTimer } from './timer/use-draughts-timer';
import { useDraughtsComputer } from './computer/use-draughts-computer';
import { Players } from '@draughts/core';
export const DraughtsGameContext = createContext();
export const useDraughtsGame = () => useContext(DraughtsGameContext);
export function DraughtsGameProvider(props) {
const { resetBoard } = useDraughtsBoard();
const [whiteTimer, resetWhiteTimer] = useDraughtsTimer(Players.WHITE);
const [blackTimer, resetBlackTimer] = useDraughtsTimer(Players.BLACK);
useDraughtsComputer();
const restartGame = useCallback(() => {
resetBoard();
resetWhiteTimer();
resetBlackTimer();
}, [resetBoard, resetWhiteTimer, resetBlackTimer]);
return (
<DraughtsGameContext.Provider
value={{ blackTimer, restartGame, whiteTimer }}
>
{props.children}
</DraughtsGameContext.Provider>
);
}
DraughtsGameProvider.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
import { useDraughtsSettings } from '../../settings/DraughtsSettingsContext';
import { useDraughtsBoard } from '../../board/DraughtsBoardContext';
import { GameStates } from '@draughts/core';
export function useDraughtsComputer() {
const { board, doMove } = useDraughtsBoard();
const { userPlayer, computerDifficulty } = useDraughtsSettings();
const workerRef = useRef();
useEffect(() => {
if (board.state !== GameStates.PLAYING) return;
if (board.playerToMove === userPlayer) return;
workerRef.current = new Worker(new URL('worker.js', import.meta.url));
const handleWorkerMessage = (event) => {
doMove(event.data);
};
workerRef.current.addEventListener('message', handleWorkerMessage);
workerRef.current.postMessage({ board, computerDifficulty });
return () => {
workerRef.current.removeEventListener('message', handleWorkerMessage);
workerRef.current.terminate();
};
}, [board, userPlayer, computerDifficulty, doMove]);
return null;
}

View File

@ -0,0 +1,40 @@
import { ComputerDifficulty } from '../../settings/constants/computer-difficulty';
import { alphaBetaMove } from '@draughts/computer';
import { Board } from '@draughts/core';
const getRandomMove = (board) => {
return board.moves.at(Math.floor(Math.random() * board.moves.length));
};
const getComputerMove = (board, difficulty) => {
if (difficulty === ComputerDifficulty.MEDIUM) {
return alphaBetaMove(board, 3);
}
if (difficulty === ComputerDifficulty.HARD) {
return alphaBetaMove(board, 6);
}
return getRandomMove(board);
};
const getComputerTimeout = (board) => {
return 200 + board.moves.length * Math.floor(Math.random() * 200);
};
addEventListener('message', (event) => {
const startTime = Date.now();
const board = new Board(
event.data.board.position,
event.data.board.playerToMove,
event.data.board.firstMove
);
const move = getComputerMove(board, event.data.computerDifficulty);
const endTime = Date.now();
const elapsedTime = startTime - endTime;
const waitTime = Math.max(0, getComputerTimeout(board) - elapsedTime);
setTimeout(() => {
postMessage(move);
}, waitTime);
});

View File

@ -0,0 +1,43 @@
import { useState, useEffect, useMemo } from 'react';
import { useDraughtsBoard } from '../../board/DraughtsBoardContext';
import { GameStates } from '@draughts/core';
const TIMER_TICK = 100;
const INITIAL_TIME = 5 * 60 * 1000;
export function useDraughtsTimer(player) {
const { board } = useDraughtsBoard();
const [timer, setTimer] = useState(INITIAL_TIME);
useEffect(() => {
if (board.state !== GameStates.PLAYING) return;
if (board.firstMove) return;
if (board.playerToMove !== player) return;
const interval = setInterval(() => {
setTimer((timer) => {
const updatedTime = timer - TIMER_TICK;
return updatedTime >= 0 ? updatedTime : 0;
});
}, TIMER_TICK);
return () => {
clearInterval(interval);
setTimer((timer) => timer + 1000);
};
}, [player, board, setTimer]);
const timerInfo = useMemo(() => {
const totalSeconds = Math.floor(timer / 1000);
return {
complete: timer === 0,
millis: timer % 1000,
minutes: Math.floor(totalSeconds / 60),
seconds: totalSeconds % 60,
};
}, [timer]);
const resetTimer = () => setTimer(INITIAL_TIME);
return [timerInfo, resetTimer];
}

View File

@ -0,0 +1,41 @@
import { createContext, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useDisclosure } from '@chakra-ui/react';
import { ComputerDifficulty } from './constants/computer-difficulty';
import { Players } from '@draughts/core';
export const DraughtsSettingsContext = createContext();
export const useDraughtsSettings = () => useContext(DraughtsSettingsContext);
export function DraughtsSettingsProvider(props) {
const settingsModal = useDisclosure();
const [userPlayer, setUserPlayer] = useState(props.userPlayer);
const [computerDifficulty, setComputerDifficulty] = useState(
props.computerDifficulty
);
const updateSettings = (newSettings) => {
setUserPlayer(newSettings.userPlayer);
setComputerDifficulty(newSettings.computerDifficulty);
};
return (
<DraughtsSettingsContext.Provider
value={{ computerDifficulty, settingsModal, updateSettings, userPlayer }}
>
{props.children}
</DraughtsSettingsContext.Provider>
);
}
export const DraughtsSettingsProviderProps = {
computerDifficulty: PropTypes.oneOf(Object.values(ComputerDifficulty)),
userPlayer: PropTypes.oneOf(Object.values(Players)),
};
DraughtsSettingsProvider.propTypes = {
children: PropTypes.node.isRequired,
...DraughtsSettingsProviderProps,
};

View File

@ -0,0 +1,5 @@
export const ComputerDifficulty = {
EASY: 'e',
HARD: 'h',
MEDIUM: 'm',
};

View File

@ -0,0 +1,30 @@
import { SettingsIcon, RepeatIcon } from '@chakra-ui/icons';
import { Button, VStack } from '@chakra-ui/react';
import { useDraughtsGame } from '../../game/DraughtsGameContext';
import { useDraughtsSettings } from '../DraughtsSettingsContext';
import { DraughtsSettingsModal } from './DraughtsSettingsModal';
export function DraughtsMenuView() {
const { restartGame } = useDraughtsGame();
const { settingsModal } = useDraughtsSettings();
return (
<VStack>
<DraughtsSettingsModal />
<Button
onClick={() => settingsModal.onOpen()}
rightIcon={<SettingsIcon />}
size="xs"
>
Buka Setting
</Button>
<Button
onClick={() => restartGame()}
rightIcon={<RepeatIcon />}
size="xs"
>
Mulai Lagi
</Button>
</VStack>
);
}

View File

@ -0,0 +1,94 @@
import { useState } from 'react';
import {
Modal,
Button,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
FormControl,
FormLabel,
Radio,
RadioGroup,
HStack,
Select,
Divider,
} from '@chakra-ui/react';
import { useDraughtsGame } from '../../game/DraughtsGameContext';
import { useDraughtsSettings } from '../DraughtsSettingsContext';
import { ComputerDifficulty } from '../constants/computer-difficulty';
import { Players } from '@draughts/core';
export function DraughtsSettingsModal() {
const { restartGame } = useDraughtsGame();
const {
userPlayer,
updateSettings,
computerDifficulty,
settingsModal: { isOpen, onClose },
} = useDraughtsSettings();
const [computerDifficultySelection, setComputerDifficultySelection] =
useState(computerDifficulty);
const [userPlayerSelection, setUserPlayerSelection] = useState(userPlayer);
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Settings</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl as="fieldset">
<FormLabel as="legend">Pilih Warna</FormLabel>
<RadioGroup
onChange={setUserPlayerSelection}
value={userPlayerSelection}
>
<HStack>
<Radio value={`${Players.WHITE}`}>Putih</Radio>
<Radio value={`${Players.BLACK}`}>Hitam</Radio>
</HStack>
</RadioGroup>
</FormControl>
<Divider marginY={5} />
<FormControl>
<FormLabel htmlFor="computerDifficulty">
Computer difficulty
</FormLabel>
<Select
id="computerDifficulty"
onChange={(event) => {
setComputerDifficultySelection(event.target.value);
}}
value={computerDifficultySelection}
>
<option value={ComputerDifficulty.EASY}>Easy</option>
<option value={ComputerDifficulty.MEDIUM}>Medium</option>
<option value={ComputerDifficulty.HARD}>Hard</option>
</Select>
</FormControl>
</ModalBody>
<ModalFooter>
<Button mr={3} colorScheme="blue" onClick={onClose}>
Kembali Ke Permainan
</Button>
<Button
onClick={() => {
restartGame();
updateSettings({
computerDifficulty: computerDifficultySelection,
userPlayer: userPlayerSelection,
});
onClose();
}}
>
Mulai Permainan Baru
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}