├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .eslintignore ├── src ├── components │ ├── Switch │ │ ├── index.tsx │ │ ├── StyledCheckbox.tsx │ │ ├── StyledSwitch.tsx │ │ ├── StyledKnob.tsx │ │ └── Switch.tsx │ ├── GlobalStyle │ │ ├── index.tsx │ │ └── GlobalStyle.tsx │ ├── Grid │ │ ├── index.tsx │ │ ├── StyledCell.tsx │ │ ├── Grid.tsx │ │ └── StyledGrid.tsx │ ├── Tile │ │ ├── index.tsx │ │ ├── Tile.tsx │ │ ├── StyledTile.tsx │ │ └── StyledTileValue.tsx │ ├── Button │ │ ├── index.tsx │ │ ├── Button.tsx │ │ └── StyledButton.tsx │ ├── Control │ │ ├── index.tsx │ │ └── Control.tsx │ ├── GameBoard │ │ ├── index.tsx │ │ └── GameBoard.tsx │ ├── Box │ │ ├── index.tsx │ │ └── StyledBox.tsx │ ├── ScoreBoard │ │ ├── index.tsx │ │ ├── StyledScore.tsx │ │ └── ScoreBoard.tsx │ ├── Text │ │ ├── index.tsx │ │ └── StyledText.tsx │ └── Notification │ │ ├── index.tsx │ │ ├── StyledBackdrop.tsx │ │ ├── StyledModal.tsx │ │ └── Notification.tsx ├── hooks │ ├── useLazyRef.ts │ ├── useScaleControl.ts │ ├── useGameScore.ts │ ├── useGameState.ts │ ├── useTheme.ts │ ├── useArrowKeyPress.ts │ ├── useLocalStorage.ts │ ├── useSwipe.ts │ └── useGameBoard.ts ├── index.tsx ├── themes │ ├── constants.ts │ ├── dark.ts │ ├── default.ts │ └── types.ts ├── utils │ ├── types.ts │ ├── constants.ts │ ├── animation.ts │ ├── rules.ts │ └── common.ts └── App.tsx ├── public └── assets │ ├── favicon.ico │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── favicon.svg ├── .prettierrc.json ├── tsconfig.eslint.json ├── README.md ├── tsconfig.json ├── styled.d.ts ├── .github └── workflows │ └── ghpage.yml ├── .gitignore ├── index.html ├── LICENSE ├── scripts └── generateFavicons.js ├── vite.config.ts ├── .eslintrc.js └── package.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.html -------------------------------------------------------------------------------- /src/components/Switch/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Switch'; 2 | -------------------------------------------------------------------------------- /src/components/GlobalStyle/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './GlobalStyle'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kwrush/react-2048/HEAD/public/assets/favicon.ico -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Grid/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Grid'; 2 | export type { GridProps } from './Grid'; 3 | -------------------------------------------------------------------------------- /src/components/Tile/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Tile'; 2 | export type { TileProps } from './Tile'; 3 | -------------------------------------------------------------------------------- /public/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kwrush/react-2048/HEAD/public/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Button'; 2 | export type { ButtonProps } from './Button'; 3 | -------------------------------------------------------------------------------- /src/components/Control/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Control'; 2 | export type { ControlProps } from './Control'; 3 | -------------------------------------------------------------------------------- /public/assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kwrush/react-2048/HEAD/public/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kwrush/react-2048/HEAD/public/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/components/GameBoard/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './GameBoard'; 2 | export type { GameBoardProps } from './GameBoard'; 3 | -------------------------------------------------------------------------------- /src/components/Box/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './StyledBox'; 2 | export type { StyledBoxProps as BoxProps } from './StyledBox'; 3 | -------------------------------------------------------------------------------- /src/components/ScoreBoard/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './ScoreBoard'; 2 | export type { ScoreBoardProps } from './ScoreBoard'; 3 | -------------------------------------------------------------------------------- /src/components/Text/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './StyledText'; 2 | export type { StyledTextProps as TextProps } from './StyledText'; 3 | -------------------------------------------------------------------------------- /src/components/Notification/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Notification'; 2 | export type { NotificationProps } from './Notification'; 3 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*.ts", 5 | "src/**/*.tsx", 6 | "scripts/**/*.js", 7 | ".eslintrc.js", 8 | "styled.d.ts", 9 | "vite.config.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Switch/StyledCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledCheckbox = styled.input.attrs({ type: 'checkbox' })` 4 | position: absolute; 5 | width: 0; 6 | height: 0; 7 | opacity: 0; 8 | margin: 0; 9 | `; 10 | 11 | export default StyledCheckbox; 12 | -------------------------------------------------------------------------------- /src/components/Switch/StyledSwitch.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledSwitch = styled.div` 4 | position: relative; 5 | width: 40px; 6 | height: 20px; 7 | line-height: 20px; 8 | vertical-align: middle; 9 | border-radius: 16px; 10 | overflow: hidden; 11 | `; 12 | 13 | export default StyledSwitch; 14 | -------------------------------------------------------------------------------- /src/hooks/useLazyRef.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useRef } from 'react'; 2 | 3 | const useLazyRef = (init: () => T) => { 4 | const lazyRef = useRef(); 5 | 6 | if (lazyRef.current == null) { 7 | lazyRef.current = init(); 8 | } 9 | 10 | return lazyRef as MutableRefObject; 11 | }; 12 | 13 | export default useLazyRef; 14 | -------------------------------------------------------------------------------- /src/components/Grid/StyledCell.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledCell = styled.div` 4 | width: 100%; 5 | height: 100%; 6 | background-color: ${({ theme: { palette } }) => palette.tertiary}; 7 | border-radius: ${({ theme: { borderRadius } }) => borderRadius}; 8 | opacity: 0.3; 9 | `; 10 | 11 | export default StyledCell; 12 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | import GlobalStyle from './components/GlobalStyle'; 5 | 6 | const container = document.getElementById('game') as HTMLElement; 7 | const root = createRoot(container); 8 | root.render( 9 | <> 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /src/components/Notification/StyledBackdrop.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledBackdrop = styled.div` 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 100%; 9 | background: ${({ theme: { palette } }) => palette.backdrop}; 10 | opacity: 0.7; 11 | z-index: -1; 12 | `; 13 | 14 | export default StyledBackdrop; 15 | -------------------------------------------------------------------------------- /src/themes/constants.ts: -------------------------------------------------------------------------------- 1 | import { Spacing } from './types'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const SpacingValues: Record = { 5 | s0: '1px', 6 | s1: '2px', 7 | s2: '4px', 8 | s3: '8px', 9 | s4: '12px', 10 | s5: '16px', 11 | s6: '20px', 12 | s7: '24px', 13 | s8: '32px', 14 | s9: '48px', 15 | s10: '64px', 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type Vector = { 2 | r: 0 | 1 | -1; 3 | c: 0 | 1 | -1; 4 | }; 5 | 6 | export enum ArrowKey { 7 | ArrowLeft, 8 | ArrowUp, 9 | ArrowRight, 10 | ArrowDown, 11 | } 12 | 13 | export enum Direction { 14 | Left, 15 | Right, 16 | Up, 17 | Down, 18 | } 19 | 20 | export type ArrowKeyType = keyof typeof ArrowKey; 21 | export type DirectionType = keyof typeof Direction; 22 | -------------------------------------------------------------------------------- /src/components/ScoreBoard/StyledScore.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { fadeOut } from '../../utils/animation'; 3 | 4 | const StyledScore = styled.div` 5 | position: absolute; 6 | left: 0; 7 | bottom: 10px; 8 | width: 100%; 9 | text-align: center; 10 | background: none; 11 | transition: all 0.2s ease-in; 12 | animation-name: ${fadeOut}; 13 | animation-duration: 0.5s; 14 | animation-fill-mode: forwards; 15 | `; 16 | 17 | export default StyledScore; 18 | -------------------------------------------------------------------------------- /src/hooks/useScaleControl.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react'; 2 | import { clamp } from '../utils/common'; 3 | import { MAX_SCALE, MIN_SCALE } from '../utils/constants'; 4 | 5 | const scaleReducer = (s: number, change: number) => 6 | clamp(s + change, MIN_SCALE, MAX_SCALE); 7 | 8 | const useScaleControl = ( 9 | initScale: number, 10 | ): [number, (change: number) => void] => 11 | useReducer(scaleReducer, initScale, (s) => clamp(s, MIN_SCALE, MAX_SCALE)); 12 | 13 | export default useScaleControl; 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [React-2048](https://kwrush.github.io/react-2048/) 2 | 3 | A React implementation of [2048](https://github.com/gabrielecirulli/2048) built with [Typescript](https://www.typescriptlang.org/) and 💅 [styled-components](https://styled-components.com) 4 | 5 | ### Demo 6 | 7 | Try online demo [here](https://kwrush.github.io/react-2048/) 8 | 9 | ### Getting Started 10 | 11 | ```shell 12 | $ npm install 13 | $ npm start 14 | # open http://localhost:3000 15 | ``` 16 | 17 | ### Liscense 18 | 19 | MIT 20 | -------------------------------------------------------------------------------- /src/hooks/useGameScore.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | const useGameScore = (initialBest: number) => { 4 | const [total, setTotal] = useState(0); 5 | const [best, setBest] = useState(initialBest); 6 | 7 | const addScore = useCallback((s: number) => setTotal((t) => t + s), []); 8 | 9 | if (total > best) { 10 | setBest(total); 11 | } 12 | 13 | return { 14 | total, 15 | best, 16 | setTotal, 17 | addScore, 18 | }; 19 | }; 20 | 21 | export default useGameScore; 22 | -------------------------------------------------------------------------------- /src/components/GlobalStyle/GlobalStyle.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import { normalize } from 'styled-normalize'; 3 | 4 | const GlobalStyle = createGlobalStyle` 5 | ${normalize}; 6 | 7 | body { 8 | font-size: 18px; 9 | font-family: 'Roboto', Arial, sans-serif; 10 | /** Disable eslastic scrolling on mobile */ 11 | overflow: hidden; 12 | overscroll-behavior: none; 13 | } 14 | 15 | #game { 16 | width: 100vw; 17 | height: 100vh; 18 | } 19 | `; 20 | 21 | export default GlobalStyle; 22 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { ArrowKeyType, DirectionType, Vector } from './types'; 2 | 3 | export const DIRECTION_MAP: Record = { 4 | ArrowLeft: { r: 0, c: -1 }, 5 | ArrowRight: { r: 0, c: 1 }, 6 | ArrowUp: { r: -1, c: 0 }, 7 | ArrowDown: { r: 1, c: 0 }, 8 | Left: { r: 0, c: -1 }, 9 | Right: { r: 0, c: 1 }, 10 | Up: { r: -1, c: 0 }, 11 | Down: { r: 1, c: 0 }, 12 | }; 13 | 14 | export const GRID_SIZE = 360; 15 | export const MIN_SCALE = 4; 16 | export const MAX_SCALE = 8; 17 | export const SPACING = 8; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "jsx": "react", 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitReturns": true, 12 | "moduleResolution": "node", 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true 16 | }, 17 | "include": ["src/**/*.ts", "src/**/*.tsx", "styled.d.ts", "vite.config.ts"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, PropsWithChildren } from 'react'; 2 | import StyledButton, { StyledButtonProps } from './StyledButton'; 3 | 4 | export interface ButtonProps extends StyledButtonProps { 5 | onClick: () => void; 6 | } 7 | 8 | const Button = forwardRef>( 9 | ({ onClick, disable = false, ...rest }, ref) => ( 10 | 16 | ), 17 | ); 18 | 19 | export default Button; 20 | -------------------------------------------------------------------------------- /styled.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | 3 | export type Color = 4 | | 'transparent' 5 | | 'black' 6 | | 'white' 7 | | 'primary' 8 | | 'secondary' 9 | | 'tertiary' 10 | | 'foreground' 11 | | 'background' 12 | | 'backdrop' 13 | | 'tile2' 14 | | 'tile4' 15 | | 'tile8' 16 | | 'tile16' 17 | | 'tile32' 18 | | 'tile64' 19 | | 'tile128' 20 | | 'tile256' 21 | | 'tile512' 22 | | 'tile1024' 23 | | 'tile2048'; 24 | 25 | export type Palette = Record; 26 | 27 | declare module 'styled-components' { 28 | export interface DefaultTheme { 29 | borderRadius: string; 30 | palette: Palette; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Notification/StyledModal.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { expand } from '../../utils/animation'; 3 | 4 | const StyledModal = styled.div` 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | width: 100%; 9 | height: 100%; 10 | background: none; 11 | z-index: 9999; 12 | animation-name: ${expand}; 13 | animation-duration: 0.2s; 14 | animation-fill-mode: forwards; 15 | border-radius: ${({ theme }) => theme.borderRadius}; 16 | overflow: hidden; 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | justify-content: center; 21 | `; 22 | 23 | export default StyledModal; 24 | -------------------------------------------------------------------------------- /src/utils/animation.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from 'styled-components'; 2 | 3 | export const expand = keyframes` 4 | from { 5 | transform: scale(0.2); 6 | } 7 | 8 | to { 9 | transform: scale(1); 10 | } 11 | `; 12 | 13 | export const pop = keyframes` 14 | 0% { 15 | transform: scale(0.8); 16 | } 17 | 18 | 50% { 19 | transform: scale(1.3); 20 | } 21 | 22 | 100% { 23 | transform: scale(1); 24 | } 25 | `; 26 | 27 | export const fadeOut = keyframes` 28 | from { 29 | transform: translateY(0); 30 | opacity: 0.9; 31 | } 32 | 33 | to { 34 | transform: translateY(-50px); 35 | opacity: 0; 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/components/Tile/Tile.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import StyledTile, { StyledTileProps } from './StyledTile'; 3 | import StyledTileValue from './StyledTileValue'; 4 | 5 | export interface TileProps extends StyledTileProps { 6 | isNew?: boolean; 7 | isMerging?: boolean; 8 | } 9 | 10 | const Tile: FC = ({ 11 | value, 12 | x, 13 | y, 14 | width, 15 | height, 16 | isNew = false, 17 | isMerging = false, 18 | }) => ( 19 | 20 | 21 | {value} 22 | 23 | 24 | ); 25 | 26 | export default Tile; 27 | -------------------------------------------------------------------------------- /src/themes/dark.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from './types'; 2 | import defaultTheme, { defaultPalette } from './default'; 3 | 4 | const theme: Theme = { 5 | ...defaultTheme, 6 | palette: { 7 | ...defaultPalette, 8 | primary: '#ec9050', 9 | secondary: '#222831', 10 | tertiary: '#4c5f7a', 11 | foreground: '#ffffff', 12 | background: '#000000', 13 | backdrop: '#000000', 14 | tile2: '#e0e0e0', 15 | tile4: '#e0e0c0', 16 | tile8: '#f0b080', 17 | tile16: '#f09060', 18 | tile32: '#f07050', 19 | tile64: '#f05030', 20 | tile128: '#e0c070', 21 | tile256: '#e0c060', 22 | tile512: '#e0c050', 23 | tile1024: '#e0c030', 24 | tile2048: '#e0c010', 25 | }, 26 | }; 27 | 28 | export default theme; 29 | -------------------------------------------------------------------------------- /src/themes/default.ts: -------------------------------------------------------------------------------- 1 | import { Palette, Theme } from './types'; 2 | 3 | export const defaultPalette: Palette = { 4 | transparent: 'transparent', 5 | black: '#000000', 6 | white: '#ffffff', 7 | primary: '#776e65', 8 | secondary: '#bbada0', 9 | tertiary: '#eee4da', 10 | foreground: '#ffffff', 11 | background: '#ffffff', 12 | backdrop: '#edc22e', 13 | tile2: '#eeeeee', 14 | tile4: '#eeeecc', 15 | tile8: '#ffbb88', 16 | tile16: '#ff9966', 17 | tile32: '#ff7755', 18 | tile64: '#ff5533', 19 | tile128: '#eecc77', 20 | tile256: '#eecc66', 21 | tile512: '#eecc55', 22 | tile1024: '#eecc33', 23 | tile2048: '#eecc11', 24 | }; 25 | 26 | const theme: Theme = { 27 | borderRadius: '3px', 28 | palette: defaultPalette, 29 | }; 30 | 31 | export default theme; 32 | -------------------------------------------------------------------------------- /.github/workflows/ghpage.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Page 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | deploy: 9 | name: Github Page 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 16 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Lint 24 | run: npm run lint 25 | 26 | - name: Build 27 | run: npm run build 28 | 29 | - name: Deploy Github Page 30 | uses: peaceiris/actions-gh-pages@v3 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | publish_dir: ./dist 34 | -------------------------------------------------------------------------------- /src/themes/types.ts: -------------------------------------------------------------------------------- 1 | export type Spacing = 2 | | 's0' 3 | | 's1' 4 | | 's2' 5 | | 's3' 6 | | 's4' 7 | | 's5' 8 | | 's6' 9 | | 's7' 10 | | 's8' 11 | | 's9' 12 | | 's10'; 13 | 14 | export type Color = 15 | | 'transparent' 16 | | 'black' 17 | | 'white' 18 | | 'primary' 19 | | 'secondary' 20 | | 'tertiary' 21 | | 'foreground' 22 | | 'background' 23 | | 'backdrop' 24 | | 'tile2' 25 | | 'tile4' 26 | | 'tile8' 27 | | 'tile16' 28 | | 'tile32' 29 | | 'tile64' 30 | | 'tile128' 31 | | 'tile256' 32 | | 'tile512' 33 | | 'tile1024' 34 | | 'tile2048'; 35 | 36 | export type Palette = Record; 37 | 38 | export interface Theme { 39 | borderRadius: string; 40 | palette: Palette; 41 | } 42 | 43 | export type ThemeName = 'default' | 'dark'; 44 | -------------------------------------------------------------------------------- /src/components/Grid/Grid.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from 'react'; 2 | import { createIndexArray } from '../../utils/common'; 3 | import StyledCell from './StyledCell'; 4 | import StyledGrid, { StyledGridProps } from './StyledGrid'; 5 | 6 | export type GridProps = StyledGridProps; 7 | 8 | const Grid: FC = ({ width, height, rows, cols, spacing }) => { 9 | const Cells = useMemo(() => { 10 | const cells = createIndexArray(rows * cols); 11 | return cells.map((c) => ); 12 | }, [rows, cols]); 13 | 14 | return ( 15 | 22 | {Cells} 23 | 24 | ); 25 | }; 26 | 27 | export default React.memo(Grid); 28 | -------------------------------------------------------------------------------- /src/components/Notification/Notification.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import StyledBackdrop from './StyledBackdrop'; 3 | import StyledModal from './StyledModal'; 4 | import Button from '../Button'; 5 | import Box from '../Box'; 6 | import Text from '../Text'; 7 | 8 | export interface NotificationProps { 9 | win: boolean; 10 | onClose: () => void; 11 | } 12 | 13 | const Notification: FC = ({ win, onClose }) => ( 14 | 15 | 16 | 17 | 18 | {win ? 'You win! Continue?' : 'Oops...Game Over!'} 19 | 20 | 21 | 22 | 23 | ); 24 | 25 | export default Notification; 26 | -------------------------------------------------------------------------------- /src/components/Tile/StyledTile.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { getTileFontSize } from '../../utils/common'; 3 | 4 | export interface StyledTileProps { 5 | width: number; 6 | height: number; 7 | value: number; 8 | x: number; 9 | y: number; 10 | } 11 | 12 | const StyledTile = styled.div.attrs( 13 | ({ width, height, value, x, y }) => ({ 14 | style: { 15 | width: `${width}px`, 16 | height: `${height}px`, 17 | fontSize: `${getTileFontSize(width, height, value)}px`, 18 | transform: `${`translate(${x}px, ${y}px)`}`, 19 | }, 20 | }), 21 | )` 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | display: flex; 26 | justify-content: center; 27 | transition: transform 0.15s ease-in-out; 28 | background: none; 29 | `; 30 | 31 | export default StyledTile; 32 | -------------------------------------------------------------------------------- /src/hooks/useGameState.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react'; 2 | 3 | export type GameStatus = 'win' | 'lost' | 'continue' | 'restart' | 'running'; 4 | 5 | export type GameState = { 6 | status: GameStatus; 7 | pause: boolean; 8 | }; 9 | 10 | const gameStateReducer = ( 11 | state: GameState, 12 | nextStatus: GameStatus, 13 | ): GameState => { 14 | switch (nextStatus) { 15 | case 'win': 16 | case 'lost': 17 | return { status: nextStatus, pause: true }; 18 | case 'continue': 19 | case 'restart': 20 | case 'running': 21 | return { status: nextStatus, pause: false }; 22 | default: 23 | return state; 24 | } 25 | }; 26 | 27 | const useGameState = ( 28 | initState: GameState, 29 | ): [GameState, (nextStatus: GameStatus) => void] => 30 | useReducer(gameStateReducer, initState); 31 | 32 | export default useGameState; 33 | -------------------------------------------------------------------------------- /src/components/Grid/StyledGrid.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export interface StyledGridProps { 4 | spacing: number; 5 | rows: number; 6 | cols: number; 7 | width: number; 8 | height: number; 9 | } 10 | 11 | const StyledGrid = styled.div` 12 | box-sizing: border-box; 13 | display: grid; 14 | width: ${({ width }) => `${width}px`}; 15 | height: ${({ height }) => `${height}px`}; 16 | grid-template-rows: ${({ rows }) => `repeat(${rows}, 1fr)`}; 17 | grid-template-columns: ${({ cols }) => `repeat(${cols}, 1fr)`}; 18 | grid-gap: ${({ spacing }) => `${spacing}px ${spacing}px`}; 19 | background-color: ${({ theme: { palette } }) => palette.secondary}; 20 | border-radius: ${({ theme: { borderRadius } }) => borderRadius}; 21 | border: ${({ spacing, theme: { palette } }) => 22 | `${spacing}px solid ${palette.secondary}`}; 23 | `; 24 | 25 | export default StyledGrid; 26 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react'; 2 | import { Theme, ThemeName } from '../themes/types'; 3 | import defaultTheme from '../themes/default'; 4 | import darkTheme from '../themes/dark'; 5 | 6 | export type ThemeEntity = { 7 | name: ThemeName; 8 | value: Theme; 9 | }; 10 | 11 | const isThemeName = (t: string): t is ThemeName => 12 | t === 'default' || t === 'dark'; 13 | 14 | const getTheme = (name: ThemeName): ThemeEntity => 15 | name === 'default' 16 | ? { name: 'default', value: defaultTheme } 17 | : { name: 'dark', value: darkTheme }; 18 | 19 | const themeReducer = (theme: ThemeEntity, nextThemeName: string) => 20 | isThemeName(nextThemeName) ? getTheme(nextThemeName) : theme; 21 | 22 | const useTheme = ( 23 | initialThemeName: ThemeName, 24 | ): [ThemeEntity, (nextTheme: string) => void] => 25 | useReducer(themeReducer, initialThemeName, getTheme); 26 | 27 | export default useTheme; 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # ========================= 18 | # Operating System Files 19 | # ========================= 20 | 21 | # OSX 22 | # ========================= 23 | 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | # Icon must end with two \r 29 | Icon 30 | 31 | 32 | # Thumbnails 33 | ._* 34 | 35 | # Files that might appear on external disk 36 | .Spotlight-V100 37 | .Trashes 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | # npm and bower folders 47 | **/npm_modules/ 48 | **/node_modules/ 49 | **/bower_components/ 50 | 51 | # vscode config 52 | **/.vscode/ 53 | 54 | **/dist/ -------------------------------------------------------------------------------- /src/components/Button/StyledButton.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export interface StyledButtonProps { 4 | disable?: boolean; 5 | mini?: boolean; 6 | } 7 | 8 | const getMiniProps = () => css` 9 | width: 24px; 10 | height: 24px; 11 | font-size: 12px; 12 | line-height: 24px; 13 | padding: 0; 14 | `; 15 | 16 | const StyledButton = styled.button` 17 | outline: none; 18 | border: none; 19 | padding: 8px 16px; 20 | line-height: 1.75; 21 | margin: 0; 22 | white-space: nowrap; 23 | ${({ mini }) => mini && getMiniProps}; 24 | border-radius: ${({ theme }) => theme.borderRadius}; 25 | background: ${({ theme: { palette } }) => palette.primary}; 26 | color: ${({ theme: { palette } }) => palette.foreground}; 27 | opacity: ${({ disable }) => disable && 0.7}; 28 | cursor: ${({ disable }) => (disable ? 'not-allowed' : 'pointer')}; 29 | `; 30 | 31 | export default StyledButton; 32 | -------------------------------------------------------------------------------- /src/components/Text/StyledText.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { Color } from '../../themes/types'; 3 | 4 | export interface StyledTextProps { 5 | as?: 'p' | 'span'; 6 | color?: Color; 7 | fontSize?: number; 8 | fontWeight?: 'bold' | 'normal'; 9 | textTransform?: 'capitalize' | 'lowercase' | 'uppercase' | 'none'; 10 | } 11 | 12 | const getFontStyle = ({ 13 | as, 14 | textTransform, 15 | fontSize = 14, 16 | fontWeight, 17 | }: StyledTextProps) => css` 18 | margin: ${as === 'p' && 0}; 19 | line-height: ${as === 'p' ? 2 : 1.5}; 20 | text-transform: ${textTransform}; 21 | font-size: ${fontSize}px; 22 | font-weight: ${fontWeight}; 23 | `; 24 | 25 | const StyledText = styled.span` 26 | line-height: 1.25; 27 | white-space: nowrap; 28 | color: ${({ theme: { palette }, color }) => color && palette[color]}; 29 | ${getFontStyle}; 30 | `; 31 | 32 | export default StyledText; 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 20 | React 2048 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/hooks/useArrowKeyPress.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { DIRECTION_MAP } from '../utils/constants'; 3 | import { ArrowKey, ArrowKeyType, Vector } from '../utils/types'; 4 | 5 | const isArrowKey = (key: string): key is ArrowKeyType => 6 | Object.keys(ArrowKey).includes(key); 7 | 8 | // Rather than returning the direction, we pass the direction to the given callback 9 | // so that keydown event won't make React rerender until the callback changes some states 10 | const useArrowKeyPress = (cb: (dir: Vector) => void) => { 11 | const onKeyDown = useCallback( 12 | ({ key }: KeyboardEvent) => { 13 | if (isArrowKey(key)) { 14 | cb(DIRECTION_MAP[key]); 15 | } 16 | }, 17 | [cb], 18 | ); 19 | 20 | useEffect(() => { 21 | window.addEventListener('keydown', onKeyDown); 22 | return () => { 23 | window.removeEventListener('keydown', onKeyDown); 24 | }; 25 | }, [onKeyDown]); 26 | }; 27 | 28 | export default useArrowKeyPress; 29 | -------------------------------------------------------------------------------- /src/components/Tile/StyledTileValue.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { pop, expand } from '../../utils/animation'; 3 | import { getTileColor } from '../../utils/common'; 4 | 5 | export interface StyledTileValueProps { 6 | isNew: boolean; 7 | isMerging: boolean; 8 | value: number; 9 | } 10 | 11 | const StyledTileValue = styled.div` 12 | width: 100%; 13 | height: 100%; 14 | font-size: inherit; 15 | display: flex; 16 | text-align: center; 17 | flex-direction: column; 18 | justify-content: center; 19 | border-radius: ${({ theme }) => theme.borderRadius}; 20 | background-color: ${({ theme: { palette }, value }) => 21 | palette[getTileColor(value)]}; 22 | animation-name: ${({ isMerging, isNew }) => 23 | isMerging ? pop : isNew ? expand : ''}; 24 | animation-duration: 0.18s; 25 | animation-fill-mode: forwards; 26 | color: ${({ theme: { palette }, value }) => 27 | value > 4 ? palette.foreground : palette.primary}; 28 | user-select: none; 29 | `; 30 | 31 | export default StyledTileValue; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kai Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /scripts/generateFavicons.js: -------------------------------------------------------------------------------- 1 | const { favicons } = require('favicons'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | 5 | // Only generate a few types of icons 6 | const templates = [ 7 | 'favicon.ico', 8 | 'apple-touch-icon.png', 9 | 'android-chrome-192x192.png', 10 | 'android-chrome-512x512.png', 11 | ]; 12 | 13 | async function generate() { 14 | const source = path.resolve(__dirname, '../public/assets/favicon.svg'); 15 | 16 | const config = { 17 | path: '/assets', 18 | appName: 'React 2048', 19 | appShortName: '2048', 20 | icons: { 21 | appleStartup: false, 22 | windows: false, 23 | yandex: false, 24 | }, 25 | }; 26 | 27 | const response = await favicons(source, config); 28 | 29 | for (const image of response.images) { 30 | if (!templates.includes(image.name)) continue; 31 | 32 | const imageDir = path.resolve(__dirname, `../public/assets/${image.name}`); 33 | fs.writeFile(imageDir, image.contents, (err) => { 34 | if (err) return console.error(err.message); 35 | console.log(`Save image <${image.name}> in file.`); 36 | }); 37 | } 38 | } 39 | 40 | generate(); 41 | -------------------------------------------------------------------------------- /src/utils/rules.ts: -------------------------------------------------------------------------------- 1 | import type { Cell, Tile } from '../hooks/useGameBoard'; 2 | import { clamp } from './common'; 3 | import { DIRECTION_MAP } from './constants'; 4 | 5 | export const isWin = (tiles: Tile[]) => 6 | tiles.some(({ value }) => value === 2048); 7 | 8 | export const canGameContinue = (grid: Cell[][], tiles: Tile[]) => { 9 | const totalRows = grid.length; 10 | const totalCols = grid[0].length; 11 | // We can always continue the game when there're empty cells, 12 | if (tiles.length < totalRows * totalCols) return true; 13 | 14 | const dirs = [ 15 | DIRECTION_MAP.Left, 16 | DIRECTION_MAP.Right, 17 | DIRECTION_MAP.Up, 18 | DIRECTION_MAP.Down, 19 | ]; 20 | 21 | for (let ind = 0; ind < tiles.length; ind++) { 22 | const { r, c, value } = tiles[ind]; 23 | for (let d = 0; d < dirs.length; d++) { 24 | const dir = dirs[d]; 25 | const nextRow = clamp(r + dir.r, 0, totalRows - 1); 26 | const nextCol = clamp(c + dir.c, 0, totalCols - 1); 27 | 28 | if (nextRow !== r || nextCol !== c) { 29 | const tile = grid[nextRow][nextCol]; 30 | if (tile == null || tile.value === value) return true; 31 | } 32 | } 33 | } 34 | return false; 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/Switch/StyledKnob.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { Color } from '../../themes/types'; 3 | 4 | export interface StyledKnobProps { 5 | active: boolean; 6 | knobColor: Color; 7 | activeColor: Color; 8 | inactiveColor: Color; 9 | } 10 | 11 | const getKnobBackground = ({ 12 | active, 13 | activeColor, 14 | inactiveColor, 15 | }: StyledKnobProps) => css` 16 | background-color: ${({ theme: { palette } }) => 17 | palette[active ? activeColor : inactiveColor]}; 18 | `; 19 | 20 | const StyledKnob = styled.span` 21 | position: relative; 22 | box-sizing: border-box; 23 | display: block; 24 | width: 40px; 25 | height: 20px; 26 | cursor: pointer; 27 | transition: background-color 0.2s ease-in; 28 | ${getKnobBackground}; 29 | 30 | ::after { 31 | content: ''; 32 | position: absolute; 33 | top: 2px; 34 | left: 2px; 35 | border-radius: 100%; 36 | width: 16px; 37 | height: 16px; 38 | transition: all 0.2s ease-in; 39 | background-color: ${({ theme: { palette }, knobColor }) => 40 | palette[knobColor]}; 41 | transform: ${({ active }) => active && 'translateX(20px)'}; 42 | } 43 | `; 44 | 45 | export default StyledKnob; 46 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | const useLocalStorage = ( 4 | name: string, 5 | initialValue: T, 6 | ): [T, (newValue: T) => void] => { 7 | const getValue = (): T => { 8 | try { 9 | const item = window.localStorage.getItem(name); 10 | return item != null ? JSON.parse(item) : initialValue; 11 | } catch (error: any) { 12 | // eslint-disable-next-line no-console 13 | console.error( 14 | `Cannot get localStorage by the given name ${name}:`, 15 | error.message, 16 | ); 17 | return initialValue; 18 | } 19 | }; 20 | 21 | const [storedValue, setStoredValue] = useState(getValue); 22 | 23 | const setValue = useCallback( 24 | (newValue: T) => { 25 | try { 26 | window.localStorage.setItem(name, JSON.stringify(newValue)); 27 | setStoredValue(newValue); 28 | } catch (error: any) { 29 | // eslint-disable-next-line no-console 30 | console.error( 31 | `Cannot set localStorage by the given name ${name}:`, 32 | error.message, 33 | ); 34 | } 35 | }, 36 | [name], 37 | ); 38 | 39 | return [storedValue, setValue]; 40 | }; 41 | 42 | export default useLocalStorage; 43 | -------------------------------------------------------------------------------- /src/components/Switch/Switch.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import StyledCheckbox from './StyledCheckbox'; 3 | import StyledKnob from './StyledKnob'; 4 | import StyledSwitch from './StyledSwitch'; 5 | import Box from '../Box'; 6 | import Text from '../Text'; 7 | 8 | export interface SwitchProps { 9 | checked: boolean; 10 | activeValue: string; 11 | inactiveValue: string; 12 | title?: string; 13 | onChange: (newValue: string) => void; 14 | } 15 | 16 | const Switch: FC = ({ 17 | checked, 18 | activeValue, 19 | inactiveValue, 20 | title, 21 | onChange, 22 | }) => ( 23 | 24 | {title && ( 25 | 26 | 27 | {title} 28 | 29 | 30 | )} 31 | { 33 | e.preventDefault(); 34 | onChange(checked ? inactiveValue : activeValue); 35 | }} 36 | > 37 | 41 | 47 | 48 | 49 | ); 50 | 51 | export default Switch; 52 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { defineConfig } from 'vite'; 3 | import type { PluginOption } from 'vite'; 4 | import react from '@vitejs/plugin-react'; 5 | import { VitePWA } from 'vite-plugin-pwa'; 6 | import type { VitePWAOptions } from 'vite-plugin-pwa'; 7 | /* eslint-enable import/no-extraneous-dependencies */ 8 | 9 | const pwaOptions: Partial = { 10 | registerType: 'autoUpdate', 11 | manifest: { 12 | id: '/react-2048/', 13 | name: 'React 2048', 14 | short_name: 'React 2048', 15 | description: 'A React clone of 2048 game', 16 | theme_color: '#ffffff', 17 | icons: [ 18 | { 19 | src: '/react-2048/assets/android-chrome-192x192.png', 20 | sizes: '192x192', 21 | type: 'image/png', 22 | }, 23 | { 24 | src: '/react-2048/assets/android-chrome-512x512.png', 25 | sizes: '512x512', 26 | type: 'image/png', 27 | }, 28 | ], 29 | }, 30 | }; 31 | 32 | export default defineConfig(({ mode }) => { 33 | const isProd = mode === 'production'; 34 | const plugins: PluginOption[] = [react()]; 35 | 36 | if (isProd) { 37 | plugins.push(VitePWA(pwaOptions)); 38 | } 39 | 40 | return { 41 | base: './', 42 | server: { 43 | port: 3000, 44 | }, 45 | plugins, 46 | build: { 47 | emptyOutDir: true, 48 | }, 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'airbnb-typescript', 8 | 'airbnb/hooks', 9 | 'plugin:import/recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 12, 19 | sourceType: 'module', 20 | project: './tsconfig.eslint.json', 21 | }, 22 | plugins: ['react', 'import', '@typescript-eslint'], 23 | rules: { 24 | 'no-nested-ternary': 'off', 25 | 'jsx-a11y/accessible-emoji': 'off', 26 | 'react/prop-types': 'off', 27 | 'react/jsx-props-no-spreading': 'off', 28 | '@typescript-eslint/explicit-module-boundary-types': 'off', 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | '@typescript-eslint/no-implicit-any-catch': [ 31 | 'error', 32 | { allowExplicitAny: true }, 33 | ], 34 | }, 35 | overrides: [ 36 | { 37 | files: ['*.js'], 38 | rules: { 39 | 'import/no-extraneous-dependencies': [ 40 | 'error', 41 | { devDependencies: true }, 42 | ], 43 | '@typescript-eslint/no-var-requires': 'off', 44 | }, 45 | }, 46 | { 47 | files: ['*.ts', '*.tsx'], 48 | rules: { 49 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 50 | }, 51 | }, 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/ScoreBoard/ScoreBoard.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useRef, useState } from 'react'; 2 | import Box from '../Box'; 3 | import Text from '../Text'; 4 | import StyledScore from './StyledScore'; 5 | 6 | export interface ScoreBoardProps { 7 | title: string; 8 | total: number; 9 | } 10 | 11 | const ScoreBoard: FC = ({ total, title }) => { 12 | const totalRef = useRef(total); 13 | const [score, setScore] = useState(() => total - totalRef.current); 14 | 15 | useEffect(() => { 16 | setScore(total - totalRef.current); 17 | totalRef.current = total; 18 | }, [total]); 19 | 20 | return ( 21 | 31 | 37 | {title} 38 | 39 | 40 | {total} 41 | 42 | {score > 0 && ( 43 | // Assign a different key to let React render the animation from beginning 44 | 45 | 46 | +{score} 47 | 48 | 49 | )} 50 | 51 | ); 52 | }; 53 | 54 | export default ScoreBoard; 55 | -------------------------------------------------------------------------------- /public/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../themes/types'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle 4 | let _tileIndex = 0; 5 | 6 | // eslint-disable-next-line no-plusplus 7 | export const nextTileIndex = () => _tileIndex++; 8 | 9 | export const resetTileIndex = () => { 10 | _tileIndex = 0; 11 | }; 12 | 13 | // https://en.wikipedia.org/wiki/Fisher–Yates_shuffle 14 | export const shuffle = (arr: T[]) => { 15 | const shuffled = arr.slice(0); 16 | for (let i = shuffled.length - 1; i > 0; i--) { 17 | const j = Math.floor(Math.random() * (i + 1)); 18 | [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 19 | } 20 | 21 | return shuffled; 22 | }; 23 | 24 | export const getId = (ind: number) => `${ind}_${Date.now()}`; 25 | 26 | export const clamp = (d: number, min: number, max: number) => 27 | Math.max(Math.min(max, d), min); 28 | 29 | export const getTileFontSize = (w: number, h: number, v: number) => { 30 | const factor = v >= 1024 ? 2.8 : 2; 31 | return Math.min(w, h) / factor; 32 | }; 33 | 34 | export const getTileColor = (v: number) => `tile${clamp(v, 2, 2048)}` as Color; 35 | 36 | export const calcSegmentSize = ( 37 | length: number, 38 | segmentNum: number, 39 | spacing: number, 40 | ) => (length - (segmentNum + 1) * spacing) / segmentNum; 41 | 42 | export const calcTileSize = ( 43 | gridSize: number, 44 | rows: number, 45 | cols: number, 46 | spacing: number, 47 | ) => ({ 48 | width: calcSegmentSize(gridSize, cols, spacing), 49 | height: calcSegmentSize(gridSize, rows, spacing), 50 | }); 51 | 52 | export const calcLocation = (l: number, c: number, spacing: number) => 53 | (spacing + l) * c + spacing; 54 | 55 | export const createIndexArray = (len: number) => Array.from(Array(len).keys()); 56 | 57 | export const create2DArray = (rows: number, cols: number) => 58 | Array.from({ length: rows }, () => Array.from(Array(cols).values())); 59 | -------------------------------------------------------------------------------- /src/hooks/useSwipe.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect, useRef } from 'react'; 2 | import { DIRECTION_MAP } from '../utils/constants'; 3 | import { Vector } from '../utils/types'; 4 | 5 | const isTouchDevice = () => 'ontouchstart' in window; 6 | 7 | // Similar to useArrowKeyPress, use callback to let hook user decide when to rerender 8 | const useSwipe = ( 9 | ref: RefObject, 10 | cb: (dir: Vector) => void, 11 | threshold = 3, 12 | ) => { 13 | const posRef = useRef({ x: 0, y: 0 }); 14 | 15 | const onTouchStart = useCallback(({ changedTouches }: TouchEvent) => { 16 | posRef.current = { 17 | x: changedTouches[0].clientX, 18 | y: changedTouches[0].clientY, 19 | }; 20 | }, []); 21 | 22 | const onTouchEnd = useCallback( 23 | ({ changedTouches }: TouchEvent) => { 24 | if (changedTouches?.length > 0) { 25 | const { 26 | current: { x, y }, 27 | } = posRef; 28 | const cx = changedTouches[0].clientX; 29 | const cy = changedTouches[0].clientY; 30 | const dx = cx - x; 31 | const dy = cy - y; 32 | 33 | if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > threshold) { 34 | cb(cx > x ? DIRECTION_MAP.Right : DIRECTION_MAP.Left); 35 | } else if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > threshold) { 36 | cb(cy > y ? DIRECTION_MAP.Down : DIRECTION_MAP.Up); 37 | } 38 | } 39 | }, 40 | [cb, threshold], 41 | ); 42 | 43 | useEffect(() => { 44 | const el = ref.current; 45 | if (isTouchDevice()) { 46 | el?.addEventListener('touchstart', onTouchStart); 47 | el?.addEventListener('touchend', onTouchEnd); 48 | } 49 | 50 | return () => { 51 | if (isTouchDevice()) { 52 | el?.removeEventListener('touchstart', onTouchStart); 53 | el?.removeEventListener('touchend', onTouchEnd); 54 | } 55 | }; 56 | }, [onTouchEnd, onTouchStart, ref]); 57 | }; 58 | 59 | export default useSwipe; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-2048", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "A React clone of 2048 game", 6 | "main": "src/index.js", 7 | "scripts": { 8 | "build": "npm run tsc && vite build", 9 | "start": "vite --host", 10 | "preview": "vite preview --port=3000 --host", 11 | "genicons": "node scripts/generateFavicons.js", 12 | "lint": "eslint . --ext .js,.ts,.tsx --max-warnings 0", 13 | "format": "prettier --write .", 14 | "tsc": "tsc -p tsconfig.json --noEmit", 15 | "deploy": "npm run build && gh-pages -d dist", 16 | "prepare": "husky install" 17 | }, 18 | "keywords": [ 19 | "React", 20 | "2048", 21 | "Typescript", 22 | "style-components", 23 | "pwa" 24 | ], 25 | "author": "Kai Wang", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/kwrush/react-2048.git" 29 | }, 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/kwrush/react-2048/issues" 33 | }, 34 | "dependencies": { 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "styled-components": "^5.3.6", 38 | "styled-normalize": "^8.0.7" 39 | }, 40 | "devDependencies": { 41 | "@types/react": "^18.0.21", 42 | "@types/react-dom": "^18.0.6", 43 | "@types/styled-components": "^5.1.26", 44 | "@typescript-eslint/eslint-plugin": "^5.12.0", 45 | "@typescript-eslint/parser": "^5.12.0", 46 | "@vitejs/plugin-react": "^2.1.0", 47 | "eslint": "^8.25.0", 48 | "eslint-config-airbnb": "^19.0.4", 49 | "eslint-config-airbnb-typescript": "^17.0.0", 50 | "eslint-config-prettier": "^8.5.0", 51 | "eslint-plugin-import": "^2.26.0", 52 | "eslint-plugin-jsx-a11y": "^6.6.1", 53 | "eslint-plugin-react": "^7.31.10", 54 | "eslint-plugin-react-hooks": "^4.6.0", 55 | "favicons": "^7.0.1", 56 | "gh-pages": "^4.0.0", 57 | "http-server": "^14.1.1", 58 | "husky": "^7.0.4", 59 | "prettier": "^2.3.2", 60 | "typescript": "^4.5.4", 61 | "vite": "^3.1.8", 62 | "vite-plugin-pwa": "^0.13.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/Control/Control.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { MAX_SCALE, MIN_SCALE } from '../../utils/constants'; 3 | import Box from '../Box'; 4 | import Button from '../Button'; 5 | import Text from '../Text'; 6 | 7 | export interface ControlProps { 8 | rows: number; 9 | cols: number; 10 | onReset: () => void; 11 | onChangeRow: (newRow: number) => void; 12 | onChangeCol: (newCol: number) => void; 13 | } 14 | 15 | const Control: FC = ({ 16 | rows, 17 | cols, 18 | onReset, 19 | onChangeRow, 20 | onChangeCol, 21 | }) => ( 22 | 23 | 28 | 29 | 30 | 31 | rows 32 | 33 | 34 | 41 | 42 | 43 | {rows} 44 | 45 | 46 | 53 | 54 | 55 | 56 | 57 | cols 58 | 59 | 60 | 67 | 68 | 69 | {cols} 70 | 71 | 72 | 79 | 80 | 81 | 82 | 83 | ); 84 | 85 | export default React.memo(Control); 86 | -------------------------------------------------------------------------------- /src/components/GameBoard/GameBoard.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useRef, useState } from 'react'; 2 | import useArrowKeyPress from '../../hooks/useArrowKeyPress'; 3 | import type { Tile } from '../../hooks/useGameBoard'; 4 | import type { GameStatus } from '../../hooks/useGameState'; 5 | import useSwipe from '../../hooks/useSwipe'; 6 | import { calcLocation, calcTileSize } from '../../utils/common'; 7 | import { Vector } from '../../utils/types'; 8 | import Box from '../Box'; 9 | import Grid from '../Grid'; 10 | import Notification from '../Notification'; 11 | import TileComponent from '../Tile'; 12 | 13 | export interface GameBoardProps { 14 | tiles?: Tile[]; 15 | gameStatus: GameStatus; 16 | rows: number; 17 | cols: number; 18 | boardSize: number; 19 | spacing: number; 20 | onMove: (dir: Vector) => void; 21 | onMovePending: () => void; 22 | onMergePending: () => void; 23 | onCloseNotification: (currentStatus: GameStatus) => void; 24 | } 25 | 26 | const GameBoard: FC = ({ 27 | tiles, 28 | gameStatus, 29 | rows, 30 | cols, 31 | boardSize, 32 | spacing, 33 | onMove, 34 | onMovePending, 35 | onMergePending, 36 | onCloseNotification, 37 | }) => { 38 | const [{ width: tileWidth, height: tileHeight }, setTileSize] = useState(() => 39 | calcTileSize(boardSize, rows, cols, spacing), 40 | ); 41 | const boardRef = useRef(null); 42 | useArrowKeyPress(onMove); 43 | useSwipe(boardRef, onMove); 44 | 45 | useEffect(() => { 46 | setTileSize(calcTileSize(boardSize, rows, cols, spacing)); 47 | }, [boardSize, cols, rows, spacing]); 48 | 49 | return ( 50 | 51 | 58 | 68 | {tiles?.map(({ r, c, id, value, isMerging, isNew }) => ( 69 | 79 | ))} 80 | 81 | {(gameStatus === 'win' || gameStatus === 'lost') && ( 82 | onCloseNotification(gameStatus)} 85 | /> 86 | )} 87 | 88 | ); 89 | }; 90 | 91 | export default React.memo(GameBoard); 92 | -------------------------------------------------------------------------------- /src/components/Box/StyledBox.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { SpacingValues } from '../../themes/constants'; 3 | import { Color, Spacing } from '../../themes/types'; 4 | 5 | export type Length = string | 0; 6 | export type BoxSpacing = Spacing | 0; 7 | 8 | /** 9 | * inline -> width, left, right 10 | * block -> height, top, bottom 11 | * start -> left in inline, top in block 12 | * end -> right in inline, bottom in block 13 | */ 14 | export interface StyledBoxProps { 15 | position?: 'relative' | 'absolute' | 'fixed' | 'static' | 'sticky'; 16 | boxSizing?: 'border-box' | 'content-box'; 17 | top?: BoxSpacing; 18 | left?: BoxSpacing; 19 | right?: BoxSpacing; 20 | bottom?: BoxSpacing; 21 | padding?: BoxSpacing; 22 | margin?: BoxSpacing; 23 | paddingInline?: BoxSpacing; 24 | paddingInlineStart?: BoxSpacing; 25 | paddingInlineEnd?: BoxSpacing; 26 | paddingBlock?: BoxSpacing; 27 | paddingBlockStart?: BoxSpacing; 28 | paddingBlockEnd?: BoxSpacing; 29 | marginInline?: BoxSpacing; 30 | marginInlineStart?: BoxSpacing; 31 | marginInlineEnd?: BoxSpacing; 32 | marginBlock?: BoxSpacing; 33 | marginBlockStart?: BoxSpacing; 34 | marginBlockEnd?: BoxSpacing; 35 | inlineSize?: Length; 36 | blockSize?: Length; 37 | minInlineSize?: Length; 38 | minBlockSize?: Length; 39 | maxInlineSize?: Length; 40 | maxBlockSize?: Length; 41 | flexDirection?: 'row' | 'column'; // omit other properties 42 | justifyContent?: 43 | | 'start' 44 | | 'end' 45 | | 'center' 46 | | 'space-between' 47 | | 'space-evenly' 48 | | 'space-around'; 49 | alignItems?: 'center' | 'start' | 'end' | 'stretch'; 50 | background?: Color; 51 | borderRadius?: Length; 52 | } 53 | 54 | const getBoxSizeStyles = ({ 55 | position, 56 | boxSizing, 57 | top, 58 | left, 59 | right, 60 | bottom, 61 | inlineSize, 62 | blockSize, 63 | minBlockSize, 64 | minInlineSize, 65 | maxBlockSize, 66 | maxInlineSize, 67 | padding, 68 | margin, 69 | paddingBlock, 70 | paddingInline, 71 | marginBlock, 72 | marginInline, 73 | marginBlockStart = marginBlock, 74 | marginBlockEnd = marginBlock, 75 | marginInlineStart = marginInline, 76 | marginInlineEnd = marginInline, 77 | paddingBlockStart = paddingBlock, 78 | paddingBlockEnd = paddingBlock, 79 | paddingInlineStart = paddingInline, 80 | paddingInlineEnd = paddingInline, 81 | }: StyledBoxProps) => css` 82 | position: ${position}; 83 | box-sizing: ${boxSizing}; 84 | top: ${top}; 85 | left: ${left}; 86 | right: ${right}; 87 | bottom: ${bottom}; 88 | width: ${inlineSize}; 89 | height: ${blockSize}; 90 | min-width: ${minInlineSize}; 91 | min-height: ${minBlockSize}; 92 | max-width: ${maxInlineSize}; 93 | max-height: ${maxBlockSize}; 94 | padding: ${padding && SpacingValues[padding]}; 95 | margin: ${margin && SpacingValues[margin]}; 96 | padding-top: ${paddingBlockStart && SpacingValues[paddingBlockStart]}; 97 | padding-bottom: ${paddingBlockEnd && SpacingValues[paddingBlockEnd]}; 98 | padding-left: ${paddingInlineStart && SpacingValues[paddingInlineStart]}; 99 | padding-right: ${paddingInlineEnd && SpacingValues[paddingInlineEnd]}; 100 | margin-top: ${marginBlockStart && SpacingValues[marginBlockStart]}; 101 | margin-bottom: ${marginBlockEnd && SpacingValues[marginBlockEnd]}; 102 | margin-left: ${marginInlineStart && SpacingValues[marginInlineStart]}; 103 | margin-right: ${marginInlineEnd && SpacingValues[marginInlineEnd]}; 104 | `; 105 | 106 | const StyledBox = styled.div` 107 | display: flex; 108 | flex-direction: ${({ flexDirection = 'row' }) => flexDirection}; 109 | align-items: center; 110 | justify-content: ${({ justifyContent }) => { 111 | if (justifyContent === 'start' || justifyContent === 'end') { 112 | return `flex-${justifyContent}`; 113 | } 114 | return justifyContent; 115 | }}; 116 | align-items: ${({ alignItems }) => alignItems}; 117 | background-color: ${({ theme: { palette }, background = 'background' }) => 118 | palette[background]}; 119 | border-radius: ${({ theme, borderRadius }) => 120 | borderRadius ?? theme.borderRadius}; 121 | color: ${({ theme: { palette } }) => palette.foreground}; 122 | ${getBoxSizeStyles} 123 | `; 124 | 125 | export default StyledBox; 126 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, useEffect } from 'react'; 2 | import { ThemeProvider } from 'styled-components'; 3 | import Box from './components/Box'; 4 | import Control from './components/Control/Control'; 5 | import GameBoard from './components/GameBoard'; 6 | import ScoreBoard from './components/ScoreBoard'; 7 | import Switch from './components/Switch'; 8 | import Text from './components/Text'; 9 | import useGameBoard from './hooks/useGameBoard'; 10 | import useGameScore from './hooks/useGameScore'; 11 | import useGameState, { GameStatus } from './hooks/useGameState'; 12 | import useScaleControl from './hooks/useScaleControl'; 13 | import { GRID_SIZE, MIN_SCALE, SPACING } from './utils/constants'; 14 | import useLocalStorage from './hooks/useLocalStorage'; 15 | import { ThemeName } from './themes/types'; 16 | import useTheme from './hooks/useTheme'; 17 | import { canGameContinue, isWin } from './utils/rules'; 18 | 19 | export type Configuration = { 20 | theme: ThemeName; 21 | bestScore: number; 22 | rows: number; 23 | cols: number; 24 | }; 25 | 26 | const APP_NAME = 'react-2048'; 27 | 28 | const App: FC = () => { 29 | const [gameState, setGameStatus] = useGameState({ 30 | status: 'running', 31 | pause: false, 32 | }); 33 | 34 | const [config, setConfig] = useLocalStorage(APP_NAME, { 35 | theme: 'default', 36 | bestScore: 0, 37 | rows: MIN_SCALE, 38 | cols: MIN_SCALE, 39 | }); 40 | 41 | const [{ name: themeName, value: themeValue }, setTheme] = useTheme( 42 | config.theme, 43 | ); 44 | 45 | const [rows, setRows] = useScaleControl(config.rows); 46 | const [cols, setCols] = useScaleControl(config.cols); 47 | 48 | const { total, best, addScore, setTotal } = useGameScore(config.bestScore); 49 | 50 | const { tiles, grid, onMove, onMovePending, onMergePending } = useGameBoard({ 51 | rows, 52 | cols, 53 | gameState, 54 | addScore, 55 | }); 56 | 57 | const onResetGame = useCallback(() => { 58 | setGameStatus('restart'); 59 | }, [setGameStatus]); 60 | 61 | const onCloseNotification = useCallback( 62 | (currentStatus: GameStatus) => { 63 | setGameStatus(currentStatus === 'win' ? 'continue' : 'restart'); 64 | }, 65 | [setGameStatus], 66 | ); 67 | 68 | if (gameState.status === 'restart') { 69 | setTotal(0); 70 | setGameStatus('running'); 71 | } else if (gameState.status === 'running' && isWin(tiles)) { 72 | setGameStatus('win'); 73 | } else if (gameState.status !== 'lost' && !canGameContinue(grid, tiles)) { 74 | setGameStatus('lost'); 75 | } 76 | 77 | useEffect(() => { 78 | setGameStatus('restart'); 79 | }, [rows, cols, setGameStatus]); 80 | 81 | useEffect(() => { 82 | setConfig({ rows, cols, bestScore: best, theme: themeName }); 83 | }, [rows, cols, best, themeName, setConfig]); 84 | 85 | return ( 86 | 87 | 94 | 99 | 100 | 107 | 108 | 113 | 114 | 115 | 2048 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 131 | 132 | 144 | 145 | 146 | ✨ Join tiles with the same value to get 2048 147 | 148 | 149 | 🕹️ Play with arrow keys or swipe 150 | 151 | 152 | 153 | 154 | 155 | ); 156 | }; 157 | 158 | export default App; 159 | -------------------------------------------------------------------------------- /src/hooks/useGameBoard.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react'; 2 | import { 3 | clamp, 4 | createIndexArray, 5 | nextTileIndex, 6 | getId, 7 | resetTileIndex, 8 | shuffle, 9 | create2DArray, 10 | } from '../utils/common'; 11 | import { Vector } from '../utils/types'; 12 | import type { GameState } from './useGameState'; 13 | import useLazyRef from './useLazyRef'; 14 | 15 | export interface Location { 16 | r: number; 17 | c: number; 18 | } 19 | 20 | export interface Tile extends Location { 21 | index: number; // self increment index 22 | id: string; 23 | isNew: boolean; 24 | isMerging: boolean; 25 | canMerge: boolean; 26 | value: number; 27 | } 28 | 29 | export type Cell = Tile | undefined; 30 | 31 | export type GameBoardParams = { 32 | rows: number; 33 | cols: number; 34 | gameState: GameState; 35 | addScore: (score: number) => void; 36 | }; 37 | 38 | const createNewTile = (r: number, c: number): Tile => { 39 | const index = nextTileIndex(); 40 | const id = getId(index); 41 | return { 42 | index, 43 | id, 44 | r, 45 | c, 46 | isNew: true, 47 | canMerge: false, 48 | isMerging: false, 49 | value: Math.random() > 0.99 ? 4 : 2, 50 | }; 51 | }; 52 | 53 | const getEmptyCellsLocation = (grid: Cell[][]) => 54 | grid.flatMap((row, r) => 55 | row.flatMap((cell, c) => (cell == null ? { r, c } : [])), 56 | ); 57 | 58 | const createNewTilesInEmptyCells = ( 59 | emptyCells: Location[], 60 | tilesNumber: number, 61 | ) => { 62 | const actualTilesNumber = 63 | emptyCells.length < tilesNumber ? emptyCells.length : tilesNumber; 64 | 65 | if (!actualTilesNumber) return []; 66 | 67 | return shuffle(emptyCells) 68 | .slice(0, actualTilesNumber) 69 | .map(({ r, c }) => createNewTile(r, c)); 70 | }; 71 | 72 | const createTraversalMap = (rows: number, cols: number, dir: Vector) => { 73 | const rowsMap = createIndexArray(rows); 74 | const colsMap = createIndexArray(cols); 75 | return { 76 | // Always start from the last cell in the moving direction 77 | rows: dir.r > 0 ? rowsMap.reverse() : rowsMap, 78 | cols: dir.c > 0 ? colsMap.reverse() : colsMap, 79 | }; 80 | }; 81 | 82 | const sortTiles = (tiles: Tile[]) => 83 | [...tiles].sort((t1, t2) => t1.index - t2.index); 84 | 85 | const mergeAndCreateNewTiles = (grid: Cell[][]) => { 86 | const tiles: Tile[] = []; 87 | let score = 0; 88 | const rows = grid.length; 89 | const cols = grid[0].length; 90 | 91 | const newGrid = grid.map((row) => 92 | row.map((tile) => { 93 | if (tile != null) { 94 | const { canMerge, value, index, ...rest } = tile; 95 | const newValue = canMerge ? 2 * value : value; 96 | const mergedTile = { 97 | ...rest, 98 | index, 99 | value: newValue, 100 | isMerging: canMerge, 101 | canMerge: false, 102 | isNew: false, 103 | }; 104 | 105 | tiles.push(mergedTile); 106 | 107 | if (canMerge) { 108 | score += newValue; 109 | } 110 | 111 | return mergedTile; 112 | } 113 | 114 | return tile; 115 | }), 116 | ); 117 | 118 | const emptyCells = getEmptyCellsLocation(newGrid); 119 | const newTiles = createNewTilesInEmptyCells( 120 | emptyCells, 121 | Math.ceil((rows * cols) / 16), 122 | ); 123 | newTiles.forEach((tile) => { 124 | newGrid[tile.r][tile.c] = tile; 125 | tiles.push(tile); 126 | }); 127 | 128 | return { 129 | grid: newGrid, 130 | tiles, 131 | score, 132 | }; 133 | }; 134 | 135 | const moveInDirection = (grid: Cell[][], dir: Vector) => { 136 | const newGrid = grid.slice(0); 137 | const totalRows = newGrid.length; 138 | const totalCols = newGrid[0].length; 139 | const tiles: Tile[] = []; 140 | const moveStack: number[] = []; 141 | 142 | const traversal = createTraversalMap(totalRows, totalCols, dir); 143 | traversal.rows.forEach((row) => { 144 | traversal.cols.forEach((col) => { 145 | const tile = newGrid[row][col]; 146 | if (tile != null) { 147 | const pos = { 148 | currRow: row, 149 | currCol: col, 150 | // clamp to ensure next row and col are still in the grid 151 | nextRow: clamp(row + dir.r, 0, totalRows - 1), 152 | nextCol: clamp(col + dir.c, 0, totalCols - 1), 153 | }; 154 | 155 | while (pos.nextRow !== pos.currRow || pos.nextCol !== pos.currCol) { 156 | const { nextRow, nextCol } = pos; 157 | const nextTile = newGrid[nextRow][nextCol]; 158 | if (nextTile != null) { 159 | // Move to the next cell if the tile inside has the same value and not been merged 160 | if (nextTile.value === tile.value && !nextTile.canMerge) { 161 | pos.currRow = nextRow; 162 | pos.currCol = nextCol; 163 | } 164 | break; 165 | } 166 | // We keep moving to the next cell until the cell contains a tile 167 | pos.currRow = nextRow; 168 | pos.currCol = nextCol; 169 | pos.nextRow = clamp(nextRow + dir.r, 0, totalRows - 1); 170 | pos.nextCol = clamp(nextCol + dir.c, 0, totalCols - 1); 171 | } 172 | 173 | const { currRow, currCol } = pos; 174 | const currentTile = newGrid[currRow][currCol]; 175 | // If the tile has been moved 176 | if (currRow !== row || currCol !== col) { 177 | const updatedTile = { 178 | ...tile, 179 | r: currRow, 180 | c: currCol, 181 | canMerge: tile.value === currentTile?.value, 182 | isNew: false, 183 | isMerging: false, 184 | }; 185 | newGrid[currRow][currCol] = updatedTile; 186 | newGrid[row][col] = undefined; 187 | tiles.push(updatedTile); 188 | moveStack.push(updatedTile.index); 189 | } else if (currentTile != null) { 190 | tiles.push({ ...currentTile, isNew: false, isMerging: false }); 191 | } 192 | } 193 | }); 194 | }); 195 | 196 | return { 197 | tiles, 198 | grid: newGrid, 199 | moveStack, 200 | }; 201 | }; 202 | 203 | const createInitialTiles = (grid: Cell[][]) => { 204 | const emptyCells = getEmptyCellsLocation(grid); 205 | const rows = grid.length; 206 | const cols = grid[0].length; 207 | return createNewTilesInEmptyCells(emptyCells, Math.ceil((rows * cols) / 8)); 208 | }; 209 | 210 | const resetGameBoard = (rows: number, cols: number) => { 211 | // Index restarts from 0 on reset 212 | resetTileIndex(); 213 | const grid = create2DArray(rows, cols); 214 | const newTiles = createInitialTiles(grid); 215 | 216 | newTiles.forEach((tile) => { 217 | grid[tile.r][tile.c] = tile; 218 | }); 219 | 220 | return { 221 | grid, 222 | tiles: newTiles, 223 | }; 224 | }; 225 | 226 | const useGameBoard = ({ rows, cols, gameState, addScore }: GameBoardParams) => { 227 | const gridMapRef = useLazyRef(() => { 228 | const grid = create2DArray(rows, cols); 229 | const tiles = createInitialTiles(grid); 230 | tiles.forEach((tile) => { 231 | grid[tile.r][tile.c] = tile; 232 | }); 233 | 234 | return { grid, tiles }; 235 | }); 236 | 237 | const [tiles, setTiles] = useState(gridMapRef.current.tiles); 238 | const pendingStackRef = useRef([]); 239 | const pauseRef = useRef(gameState.pause); 240 | 241 | const onMove = useCallback( 242 | (dir: Vector) => { 243 | if (pendingStackRef.current.length === 0 && !pauseRef.current) { 244 | const { 245 | tiles: newTiles, 246 | moveStack, 247 | grid, 248 | } = moveInDirection(gridMapRef.current.grid, dir); 249 | gridMapRef.current = { grid, tiles: newTiles }; 250 | pendingStackRef.current = moveStack; 251 | 252 | // No need to update when no tile moves 253 | if (moveStack.length > 0) { 254 | setTiles(sortTiles(newTiles)); 255 | } 256 | } 257 | }, 258 | [gridMapRef], 259 | ); 260 | 261 | const onMovePending = useCallback(() => { 262 | pendingStackRef.current.pop(); 263 | if (pendingStackRef.current.length === 0) { 264 | const { 265 | tiles: newTiles, 266 | score, 267 | grid, 268 | } = mergeAndCreateNewTiles(gridMapRef.current.grid); 269 | gridMapRef.current = { grid, tiles: newTiles }; 270 | addScore(score); 271 | pendingStackRef.current = newTiles 272 | .filter((tile) => tile.isMerging || tile.isNew) 273 | .map((tile) => tile.index); 274 | setTiles(sortTiles(newTiles)); 275 | } 276 | }, [addScore, gridMapRef]); 277 | 278 | const onMergePending = useCallback(() => { 279 | pendingStackRef.current.pop(); 280 | }, []); 281 | 282 | if (pauseRef.current !== gameState.pause) { 283 | pauseRef.current = gameState.pause; 284 | } 285 | 286 | if (gameState.status === 'restart') { 287 | const { grid, tiles: newTiles } = resetGameBoard(rows, cols); 288 | gridMapRef.current = { grid, tiles: newTiles }; 289 | pendingStackRef.current = []; 290 | setTiles(newTiles); 291 | } 292 | 293 | return { 294 | tiles, 295 | grid: gridMapRef.current.grid, 296 | onMove, 297 | onMovePending, 298 | onMergePending, 299 | }; 300 | }; 301 | 302 | export default useGameBoard; 303 | --------------------------------------------------------------------------------