├── public ├── dawn.hdr ├── favicon.ico ├── vercel.svg ├── thirteen.svg └── next.svg ├── src ├── utils │ ├── isDev.ts │ ├── upperCaseFirstLetter.ts │ └── socket.ts ├── server │ ├── disconnect.ts │ ├── resetGame.ts │ ├── makeMove.ts │ ├── fetchPlayers.ts │ ├── sendMessage.ts │ ├── cameraMove.ts │ └── joinRoom.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── index.tsx │ └── api │ │ └── socket.ts ├── components │ ├── Toast.tsx │ ├── Loader.tsx │ ├── StatusBar.tsx │ ├── Opponent.tsx │ ├── GameOverScreen.tsx │ ├── Sidebar.tsx │ ├── History.tsx │ ├── MiniMap.tsx │ ├── Chat.tsx │ ├── GameCreation.tsx │ └── Board.tsx ├── state │ ├── history.ts │ ├── game.ts │ └── player.ts ├── models │ ├── King.tsx │ ├── Queen.tsx │ ├── Bishop.tsx │ ├── Knight.tsx │ ├── Pawn.tsx │ ├── Rook.tsx │ ├── Border.tsx │ ├── Tile.tsx │ └── index.tsx ├── styles │ └── global.css └── logic │ ├── pieces │ ├── bishop.ts │ ├── rook.ts │ ├── knight.ts │ ├── queen.ts │ ├── king.ts │ ├── pawn.ts │ └── index.ts │ └── board.ts ├── README.md ├── next.config.js ├── .prettierrc.yml ├── .eslintrc ├── .gitignore ├── tsconfig.json └── package.json /public/dawn.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwrn/3d-chess/HEAD/public/dawn.hdr -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwrn/3d-chess/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/utils/isDev.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NEXT_PUBLIC_CHEATS === `true` 2 | -------------------------------------------------------------------------------- /src/utils/upperCaseFirstLetter.ts: -------------------------------------------------------------------------------- 1 | export const uppercaseFirstLetter = (str: string): string => { 2 | return str.charAt(0).toUpperCase() + str.slice(1) 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Multiplayer 3d chess game built with react-three-fiber and socket.io. 2 | 3 | Live demo: https://chess-in-3d.herokuapp.com/ 4 | 5 | ![Imgur](https://i.imgur.com/r9tBfim.png) 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | compiler: { 5 | emotion: true, 6 | }, 7 | } 8 | 9 | module.exports = nextConfig 10 | -------------------------------------------------------------------------------- /src/server/disconnect.ts: -------------------------------------------------------------------------------- 1 | import type { MyServer, MySocket } from '@/pages/api/socket' 2 | 3 | export const disconnect = (socket: MySocket, io: MyServer): void => { 4 | socket.on(`disconnecting`, (data) => { 5 | console.log(`disconnecting`) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src/server/resetGame.ts: -------------------------------------------------------------------------------- 1 | import type { MyServer, MySocket } from '@/pages/api/socket' 2 | 3 | export const resetGame = (socket: MySocket, io: MyServer): void => { 4 | socket.on(`resetGame`, (data: { room: string }) => { 5 | io.sockets.in(data.room).emit(`gameReset`, true) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react' 2 | 3 | import type { AppProps } from 'next/app' 4 | import '../styles/global.css' 5 | import 'react-toastify/dist/ReactToastify.css' 6 | 7 | export default function App({ Component, pageProps }: AppProps): ReactElement { 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /src/server/makeMove.ts: -------------------------------------------------------------------------------- 1 | import type { MakeMoveClient } from '@/components/Board' 2 | import type { MyServer, MySocket } from '@/pages/api/socket' 3 | 4 | export const makeMove = (socket: MySocket, io: MyServer): void => { 5 | socket.on(`makeMove`, (data: MakeMoveClient) => { 6 | io.sockets.in(data.room).emit(`moveMade`, data.movingTo) 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /src/server/fetchPlayers.ts: -------------------------------------------------------------------------------- 1 | import type { MyServer, MySocket } from '@/pages/api/socket' 2 | 3 | export const fetchPlayers = (socket: MySocket, io: MyServer): void => { 4 | socket.on(`fetchPlayers`, (data: { room: string }) => { 5 | const players = io.sockets.adapter.rooms.get(data.room)?.size || 0 6 | io.sockets.in(data.room).emit(`playersInRoom`, players) 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /src/server/sendMessage.ts: -------------------------------------------------------------------------------- 1 | import type { MessageClient } from '@/components/Chat' 2 | import type { MyServer, MySocket } from '@/pages/api/socket' 3 | import type { Message } from '@/state/player' 4 | 5 | export const sendMessage = (socket: MySocket, io: MyServer): void => { 6 | socket.on(`createdMessage`, (data: MessageClient) => { 7 | const send: Message = data.message 8 | io.sockets.in(data.room).emit(`newIncomingMessage`, send) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 81 2 | tabWidth: 2 3 | useTabs: false 4 | semi: false 5 | singleQuote: true 6 | quoteProps: "consistent" 7 | jsxSingleQuote: false 8 | trailingComma: "all" 9 | bracketSpacing: true 10 | bracketSameLine: false 11 | arrowParens: "always" 12 | requirePragma: false 13 | insertPragma: false 14 | proseWrap: "never" 15 | htmlWhitespaceSensitivity: "css" 16 | vueIndentScriptAndStyle: false 17 | endOfLine: "lf" 18 | embeddedLanguageFormatting: "auto" -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react' 2 | 3 | import Document, { Html, Head, Main, NextScript } from 'next/document' 4 | 5 | class MyDocument extends Document { 6 | public render(): ReactElement { 7 | return ( 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | ) 16 | } 17 | } 18 | export default MyDocument 19 | -------------------------------------------------------------------------------- /src/server/cameraMove.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '@/logic/pieces' 2 | import type { MyServer, MySocket } from '@/pages/api/socket' 3 | 4 | export type CameraMove = { 5 | position: [number, number, number] 6 | room: string 7 | color: Color 8 | } 9 | 10 | export const cameraMove = (socket: MySocket, io: MyServer): void => { 11 | socket.on(`cameraMove`, (data: CameraMove) => { 12 | io.sockets.in(data.room).emit(`cameraMoved`, data) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React from 'react' 3 | 4 | import { ToastContainer } from 'react-toastify' 5 | 6 | export const Toast: FC = () => { 7 | return ( 8 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "unused-imports" 4 | ], 5 | "extends": [ 6 | "@eyecuelab/react" 7 | ], 8 | "rules": { 9 | "max-len": "off", 10 | "max-lines": "off", 11 | "unused-imports/no-unused-imports-ts": "warn", 12 | "unused-imports/no-unused-vars-ts": "warn", 13 | "@typescript-eslint/ban-ts-comment": "off", 14 | "no-console": "warn", 15 | // "react/no-unknown-property": ["error", { "ignore": ["css", "intensity", "position"] }] 16 | "react/no-unknown-property": ["off"] 17 | 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/state/history.ts: -------------------------------------------------------------------------------- 1 | import create from 'zustand' 2 | 3 | import type { History } from '@/components/History' 4 | 5 | export const useHistoryState = create<{ 6 | history: History[] 7 | reset: VoidFunction 8 | addItem: (item: History) => void 9 | undo: VoidFunction 10 | }>((set) => ({ 11 | history: [] as History[], 12 | reset: () => set({ history: [] }), 13 | addItem: (item) => set((state) => ({ history: [...state.history, item] })), 14 | undo: () => set((state) => ({ history: state.history.slice(0, -1) })), 15 | })) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | .env 15 | .env.local 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | .pnpm-debug.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React from 'react' 3 | 4 | import { css } from '@emotion/react' 5 | 6 | export const Loader: FC = () => { 7 | return ( 8 |
23 |

Loading

24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/King.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React from 'react' 3 | 4 | import { useGLTF } from '@react-three/drei' 5 | import type * as THREE from 'three' 6 | import type { GLTF } from 'three-stdlib' 7 | 8 | type GLTFResult = GLTF & { 9 | nodes: { 10 | Object001004: THREE.Mesh 11 | } 12 | materials: { 13 | [`Object001_mtl.003`]: THREE.MeshStandardMaterial 14 | } 15 | } 16 | 17 | export const KingComponent: FC = () => { 18 | const { nodes } = useGLTF(`/king.gltf`) as unknown as GLTFResult 19 | return 20 | } 21 | 22 | useGLTF.preload(`/king.gltf`) 23 | -------------------------------------------------------------------------------- /src/models/Queen.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React from 'react' 3 | 4 | import { useGLTF } from '@react-three/drei' 5 | import type * as THREE from 'three' 6 | import type { GLTF } from 'three-stdlib' 7 | 8 | type GLTFResult = GLTF & { 9 | nodes: { 10 | Object001003: THREE.Mesh 11 | } 12 | materials: { 13 | [`Object001_mtl.003`]: THREE.MeshStandardMaterial 14 | } 15 | } 16 | 17 | export const QueenComponent: FC = () => { 18 | const { nodes } = useGLTF(`/queen.gltf`) as unknown as GLTFResult 19 | return 20 | } 21 | 22 | useGLTF.preload(`/queen.gltf`) 23 | -------------------------------------------------------------------------------- /src/models/Bishop.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React from 'react' 3 | 4 | import { useGLTF } from '@react-three/drei' 5 | import type * as THREE from 'three' 6 | import type { GLTF } from 'three-stdlib' 7 | 8 | type GLTFResult = GLTF & { 9 | nodes: { 10 | Object001002: THREE.Mesh 11 | } 12 | materials: { 13 | [`Object001_mtl.003`]: THREE.MeshStandardMaterial 14 | } 15 | } 16 | 17 | export const BishopComponent: FC = () => { 18 | const { nodes } = useGLTF(`/bishop.gltf`) as unknown as GLTFResult 19 | return 20 | } 21 | 22 | useGLTF.preload(`/bishop.gltf`) 23 | -------------------------------------------------------------------------------- /src/models/Knight.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React from 'react' 3 | 4 | import { useGLTF } from '@react-three/drei' 5 | import type * as THREE from 'three' 6 | import type { GLTF } from 'three-stdlib' 7 | 8 | type GLTFResult = GLTF & { 9 | nodes: { 10 | Object001005: THREE.Mesh 11 | } 12 | materials: { 13 | [`Object001_mtl.003`]: THREE.MeshStandardMaterial 14 | } 15 | } 16 | 17 | export const KnightComponent: FC = () => { 18 | const { nodes } = useGLTF(`/knight.gltf`) as unknown as GLTFResult 19 | return 20 | } 21 | 22 | useGLTF.preload(`/knight.gltf`) 23 | -------------------------------------------------------------------------------- /src/models/Pawn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import type { FC } from 'react' 3 | 4 | import { useGLTF } from '@react-three/drei' 5 | import type * as THREE from 'three' 6 | import type { GLTF } from 'three-stdlib' 7 | 8 | type GLTFResult = GLTF & { 9 | nodes: { 10 | Object001: THREE.Mesh 11 | } 12 | materials: { 13 | [`Object001_mtl.003`]: THREE.MeshStandardMaterial 14 | } 15 | } 16 | 17 | export const PawnModel: FC = () => { 18 | const ref = useRef(null) 19 | const { nodes } = useGLTF(`/pawn.gltf`) as unknown as GLTFResult 20 | return 21 | } 22 | 23 | useGLTF.preload(`/pawn.gltf`) 24 | -------------------------------------------------------------------------------- /src/models/Rook.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React, { useRef } from 'react' 3 | 4 | import { useGLTF } from '@react-three/drei' 5 | import type * as THREE from 'three' 6 | import type { GLTF } from 'three-stdlib' 7 | 8 | type GLTFResult = GLTF & { 9 | nodes: { 10 | Object001001: THREE.Mesh 11 | } 12 | materials: { 13 | [`Object001_mtl.003`]: THREE.MeshStandardMaterial 14 | } 15 | } 16 | 17 | export const RookComponent: FC = () => { 18 | const ref = useRef(null) 19 | const { nodes } = useGLTF(`/rook.gltf`) as unknown as GLTFResult 20 | return 21 | } 22 | 23 | useGLTF.preload(`/rook.gltf`) 24 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | padding: 0; 4 | margin: 0; 5 | font-family: Arial, Helvetica, sans-serif; 6 | } 7 | 8 | button { 9 | cursor: pointer; 10 | border: none; 11 | outline: none; 12 | padding: 8px 10px; 13 | display: flex; 14 | justify-content: center; 15 | border-radius: 4px; 16 | background: #000000; 17 | color: #cfcfcf; 18 | } 19 | 20 | html { 21 | background-color: black; 22 | } 23 | 24 | input { 25 | width: 100%; 26 | background: none; 27 | border: none; 28 | border-bottom: 1px solid #ffffff51; 29 | color: white; 30 | outline: none; 31 | } 32 | 33 | input::placeholder { 34 | color: #ffffff98; 35 | } -------------------------------------------------------------------------------- /src/models/Border.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | export const BorderMaterial: FC< 4 | JSX.IntrinsicElements[`meshPhysicalMaterial`] 5 | > = ({ ...props }) => ( 6 | 18 | ) 19 | 20 | export const Border: FC = () => { 21 | return ( 22 | e.stopPropagation()} 24 | receiveShadow 25 | position={[0, -0.35, 0]} 26 | rotation={[0, 0, 0]} 27 | > 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/server/joinRoom.ts: -------------------------------------------------------------------------------- 1 | import type { JoinRoomClient } from '@/components/GameCreation' 2 | import type { Color } from '@/logic/pieces' 3 | import type { MyServer, MySocket, playerJoinedServer } from '@/pages/api/socket' 4 | 5 | export const joinRoom = (socket: MySocket, io: MyServer): void => { 6 | socket.on(`joinRoom`, (data: JoinRoomClient) => { 7 | const { room, username } = data 8 | 9 | const playerCount = io.sockets.adapter.rooms.get(data.room)?.size || 0 10 | if (playerCount === 2) { 11 | socket.emit(`newError`, `Room is full`) 12 | return 13 | } 14 | 15 | socket.join(room) 16 | const color: Color = playerCount === 1 ? `black` : `white` 17 | const props: playerJoinedServer = { room, username, color, playerCount } 18 | io.sockets.in(room).emit(`playerJoined`, props) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/state/game.ts: -------------------------------------------------------------------------------- 1 | import create from 'zustand' 2 | 3 | import type { MovingTo } from '@/components/Board' 4 | import type { Color } from '@/logic/pieces' 5 | import { oppositeColor } from '@/logic/pieces' 6 | 7 | export const useGameSettingsState = create<{ 8 | gameType: `local` | `online` 9 | setGameType: (type: `local` | `online`) => void 10 | turn: Color 11 | setTurn: () => void 12 | resetTurn: () => void 13 | gameStarted: boolean 14 | setGameStarted: (started: boolean) => void 15 | movingTo: MovingTo | null 16 | setMovingTo: (move: MovingTo | null) => void 17 | }>((set) => ({ 18 | gameType: `online`, 19 | setGameType: (type) => set({ gameType: type }), 20 | turn: `white`, 21 | setTurn: () => set((state) => ({ turn: oppositeColor(state.turn) })), 22 | resetTurn: () => set({ turn: `white` }), 23 | gameStarted: false, 24 | setGameStarted: (started: boolean) => set({ gameStarted: started }), 25 | movingTo: null, 26 | setMovingTo: (move: MovingTo | null) => set({ movingTo: move }), 27 | })) 28 | -------------------------------------------------------------------------------- /src/logic/pieces/bishop.ts: -------------------------------------------------------------------------------- 1 | import type { MoveFunction, Piece, PieceFactory } from './' 2 | import { getFarMoves, getBasePiece } from './' 3 | 4 | export function isBishop(value: Bishop | Piece | null): value is Bishop { 5 | return value?.type === `bishop` 6 | } 7 | 8 | export const bishopMoves: MoveFunction = ({ 9 | piece, 10 | board, 11 | propagateDetectCheck, 12 | }) => { 13 | const props = { piece, board, propagateDetectCheck } 14 | const moveRightDown = getFarMoves({ dir: { x: 1, y: 1 }, ...props }) 15 | const moveLeftUp = getFarMoves({ dir: { x: -1, y: -1 }, ...props }) 16 | const moveLeftDown = getFarMoves({ dir: { x: -1, y: 1 }, ...props }) 17 | const moveRightUp = getFarMoves({ dir: { x: 1, y: -1 }, ...props }) 18 | return [...moveRightDown, ...moveLeftUp, ...moveLeftDown, ...moveRightUp] 19 | } 20 | 21 | export const createBishop = ({ color, id, position }: PieceFactory): Bishop => { 22 | return { 23 | ...getBasePiece({ color, id, type: `bishop`, position }), 24 | } 25 | } 26 | 27 | export type Bishop = Piece 28 | -------------------------------------------------------------------------------- /src/logic/pieces/rook.ts: -------------------------------------------------------------------------------- 1 | import type { MoveFunction, Piece, PieceFactory } from './' 2 | import { getFarMoves, getBasePiece } from './' 3 | 4 | export function isRook(value: Piece | Rook | null): value is Rook { 5 | return value?.type === `rook` 6 | } 7 | 8 | export const rookMoves: MoveFunction = ({ 9 | piece, 10 | board, 11 | propagateDetectCheck, 12 | }) => { 13 | const props = { piece, board, propagateDetectCheck } 14 | const movesForward = getFarMoves({ dir: { x: 0, y: 1 }, ...props }) 15 | const movesBackward = getFarMoves({ dir: { x: 0, y: -1 }, ...props }) 16 | const movesLeft = getFarMoves({ dir: { x: -1, y: 0 }, ...props }) 17 | const movesRight = getFarMoves({ dir: { x: 1, y: 0 }, ...props }) 18 | return [...movesForward, ...movesBackward, ...movesLeft, ...movesRight] 19 | } 20 | 21 | export const createRook = ({ color, id, position }: PieceFactory): Rook => { 22 | return { 23 | hasMoved: false, 24 | ...getBasePiece({ color, id, type: `rook`, position }), 25 | } 26 | } 27 | 28 | export type Rook = Piece & { hasMoved: boolean } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "jsxImportSource": "@emotion/react", 21 | "incremental": true, 22 | "types": [ 23 | "@emotion/react/types/css-prop" 24 | ], 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": ["src/*"], 28 | "@components/*": ["src/components/*"], 29 | "@pages/*": ["src/pages/*"], 30 | "@logic/*": ["src/logic/*"], 31 | "@models/*": ["src/models/*"], 32 | } 33 | }, 34 | "include": [ 35 | "next-env.d.ts", 36 | "**/*.ts", 37 | "**/*.tsx" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/logic/pieces/knight.ts: -------------------------------------------------------------------------------- 1 | import type { Position } from '../board' 2 | import type { MoveFunction, Piece, PieceFactory } from './' 3 | import { getMove, getBasePiece } from './' 4 | 5 | export function isKnight(value: Knight | Piece | null): value is Knight { 6 | return value?.type === `knight` 7 | } 8 | 9 | export const knightMoves: MoveFunction = ({ 10 | piece, 11 | board, 12 | propagateDetectCheck, 13 | }) => { 14 | const moves = [] 15 | for (const steps of KNIGHT_MOVES) { 16 | const move = getMove({ piece, board, steps, propagateDetectCheck }) 17 | if (!move) continue 18 | moves.push(move) 19 | } 20 | 21 | return moves 22 | } 23 | 24 | export const createKnight = ({ color, id, position }: PieceFactory): Knight => { 25 | return { 26 | ...getBasePiece({ color, id, type: `knight`, position }), 27 | } 28 | } 29 | 30 | const KNIGHT_MOVES: Position[] = [ 31 | { 32 | x: 1, 33 | y: 2, 34 | }, 35 | { 36 | x: 2, 37 | y: 1, 38 | }, 39 | { 40 | x: 2, 41 | y: -1, 42 | }, 43 | { 44 | x: 1, 45 | y: -2, 46 | }, 47 | { 48 | x: -1, 49 | y: -2, 50 | }, 51 | { 52 | x: -2, 53 | y: -1, 54 | }, 55 | { 56 | x: -2, 57 | y: 1, 58 | }, 59 | { 60 | x: -1, 61 | y: 2, 62 | }, 63 | ] 64 | 65 | export type Knight = Piece 66 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/logic/pieces/queen.ts: -------------------------------------------------------------------------------- 1 | import type { MoveFunction, Piece, PieceFactory } from './' 2 | import { getFarMoves, getBasePiece } from './' 3 | 4 | export function isQueen(value: Piece | Queen | null): value is Queen { 5 | return value?.type === `queen` 6 | } 7 | 8 | export const queenMoves: MoveFunction = ({ 9 | piece, 10 | board, 11 | propagateDetectCheck, 12 | }) => { 13 | const props = { piece, board, propagateDetectCheck } 14 | const moveRightDown = getFarMoves({ dir: { x: 1, y: 1 }, ...props }) 15 | const moveLeftUp = getFarMoves({ dir: { x: -1, y: -1 }, ...props }) 16 | const moveLeftDown = getFarMoves({ dir: { x: -1, y: 1 }, ...props }) 17 | const moveRightUp = getFarMoves({ dir: { x: 1, y: -1 }, ...props }) 18 | 19 | const movesForward = getFarMoves({ dir: { x: 0, y: 1 }, ...props }) 20 | const movesBackward = getFarMoves({ dir: { x: 0, y: -1 }, ...props }) 21 | const movesLeft = getFarMoves({ dir: { x: -1, y: 0 }, ...props }) 22 | const movesRight = getFarMoves({ dir: { x: 1, y: 0 }, ...props }) 23 | 24 | return [ 25 | ...moveRightDown, 26 | ...moveLeftUp, 27 | ...moveLeftDown, 28 | ...moveRightUp, 29 | ...movesForward, 30 | ...movesBackward, 31 | ...movesLeft, 32 | ...movesRight, 33 | ] 34 | } 35 | 36 | export const createQueen = ({ color, id, position }: PieceFactory): Queen => { 37 | return { 38 | ...getBasePiece({ color, id, type: `queen`, position }), 39 | } 40 | } 41 | 42 | export type Queen = Piece 43 | -------------------------------------------------------------------------------- /src/components/StatusBar.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { css } from '@emotion/react' 4 | 5 | import { useGameSettingsState } from '@/state/game' 6 | import { usePlayerState } from '@/state/player' 7 | import { uppercaseFirstLetter } from '@/utils/upperCaseFirstLetter' 8 | 9 | export const StatusBar: FC = () => { 10 | const { room, joinedRoom, playerColor } = usePlayerState((state) => ({ 11 | room: state.room, 12 | joinedRoom: state.joinedRoom, 13 | playerColor: state.playerColor, 14 | })) 15 | const { gameStarted, turn } = useGameSettingsState((state) => ({ 16 | gameStarted: state.gameStarted, 17 | turn: state.turn, 18 | })) 19 | return ( 20 |
39 | {joinedRoom && ( 40 |

41 | Room{` `} 42 | {room} 43 | {` | `}Player{` `} 44 | {uppercaseFirstLetter(playerColor)} 45 | {` | `}Turn{` `} 46 | {uppercaseFirstLetter(turn)} 47 |

48 | )} 49 | {!gameStarted && joinedRoom && ( 50 |

Share your room name to invite another player.

51 | )} 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/Opponent.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { animated, useSpring } from '@react-spring/three' 4 | import { Float, Html } from '@react-three/drei' 5 | 6 | import { useOpponentState, usePlayerState } from '@/state/player' 7 | 8 | export const Opponent: FC = () => { 9 | const handleClick = () => { 10 | console.log(`click`) 11 | } 12 | const { position, name } = useOpponentState((state) => state) 13 | const { playerColor } = usePlayerState((state) => state) 14 | 15 | const { smoothPosition } = useSpring({ 16 | smoothPosition: position, 17 | }) 18 | return ( 19 | 20 | 21 | 38 | {name} 39 | 40 | 41 | 42 | 47 | 48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "3d-chess", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@emotion/css": "^11.10.5", 13 | "@emotion/react": "^11.10.5", 14 | "@next/font": "13.1.0", 15 | "@react-spring/three": "^9.6.1", 16 | "@react-three/drei": "^9.48.1", 17 | "@react-three/fiber": "^8.9.1", 18 | "@react-three/postprocessing": "^2.7.0", 19 | "@types/node": "18.11.17", 20 | "@types/react": "18.0.26", 21 | "@types/react-dom": "18.0.10", 22 | "@use-gesture/react": "^10.2.23", 23 | "eslint": "^8.30.0", 24 | "eslint-config-next": "13.1.0", 25 | "framer-motion-3d": "^8.0.2", 26 | "nanoid": "^4.0.0", 27 | "next": "13.1.0", 28 | "react": "18.2.0", 29 | "react-dom": "18.2.0", 30 | "react-icons": "^4.7.1", 31 | "react-toastify": "^9.1.1", 32 | "socket.io": "^4.5.4", 33 | "socket.io-client": "^4.5.4", 34 | "three": "^0.148.0", 35 | "typescript": "4.9.4" 36 | }, 37 | "devDependencies": { 38 | "@eyecuelab/eslint-config-react": "^1.0.2", 39 | "@types/three": "^0.146.0", 40 | "@typescript-eslint/eslint-plugin": "^5.47.0", 41 | "@typescript-eslint/parser": "^5.47.0", 42 | "eslint-config-prettier": "^8.5.0", 43 | "eslint-plugin-import": "^2.26.0", 44 | "eslint-plugin-jest": "^27.1.7", 45 | "eslint-plugin-prettier": "^4.2.1", 46 | "eslint-plugin-react": "^7.31.11", 47 | "eslint-plugin-react-hooks": "^4.6.0", 48 | "eslint-plugin-unused-imports": "^2.0.0", 49 | "prettier": "^2.8.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/state/player.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import create from 'zustand' 3 | 4 | import type { Color } from '@/logic/pieces' 5 | import { isDev } from '@/utils/isDev' 6 | 7 | export type Message = { 8 | author: string 9 | message: string 10 | } 11 | 12 | export const useMessageState = create<{ 13 | messages: Message[] 14 | addMessage: (message: Message) => void 15 | }>((set) => ({ 16 | messages: [] as Message[], 17 | addMessage: (message) => 18 | set((state) => ({ messages: [...state.messages, message] })), 19 | })) 20 | 21 | export const useOpponentState = create<{ 22 | position: [number, number, number] 23 | mousePosition: [number, number, number] 24 | setPosition: (position: [number, number, number]) => void 25 | setMousePosition: (mousePosition: [number, number, number]) => void 26 | name: string 27 | setName: (name: string) => void 28 | }>((set) => ({ 29 | position: [0, 100, 0], 30 | setPosition: (position) => set({ position }), 31 | name: ``, 32 | setName: (name) => set({ name }), 33 | mousePosition: [0, 0, 0], 34 | setMousePosition: (mousePosition) => set({ mousePosition }), 35 | })) 36 | 37 | export const usePlayerState = create<{ 38 | username: string 39 | id: string 40 | setUsername: (username: string) => void 41 | room: string 42 | setRoom: (room: string) => void 43 | joinedRoom: boolean 44 | setJoinedRoom: (joinedRoom: boolean) => void 45 | playerColor: Color 46 | setPlayerColor: (color: Color) => void 47 | }>((set) => ({ 48 | username: isDev ? `dev` : ``, 49 | setUsername: (username) => set({ username }), 50 | id: nanoid(), 51 | room: isDev ? `room` : ``, 52 | setRoom: (room) => set({ room }), 53 | joinedRoom: false, 54 | setJoinedRoom: (joinedRoom) => set({ joinedRoom }), 55 | playerColor: `white`, 56 | setPlayerColor: (color: Color) => set({ playerColor: color }), 57 | })) 58 | -------------------------------------------------------------------------------- /src/components/GameOverScreen.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { css } from '@emotion/react' 4 | import { VscDebugRestart } from 'react-icons/vsc' 5 | 6 | import type { GameOver } from '@/pages/index' 7 | import { usePlayerState } from '@/state/player' 8 | import { useSocketState } from '@/utils/socket' 9 | 10 | export const GameOverScreen: FC<{ 11 | gameOver: GameOver | null 12 | }> = ({ gameOver }) => { 13 | const socket = useSocketState((state) => state.socket) 14 | const { room } = usePlayerState((state) => state) 15 | const reset = () => { 16 | socket?.emit(`resetGame`, { room }) 17 | } 18 | return ( 19 | <> 20 | {gameOver && ( 21 |
49 |

50 | {gameOver.type === `checkmate` 51 | ? `Checkmate! ${gameOver.winner} wins!` 52 | : `Stalemate!`} 53 |

54 | 57 |
58 | )} 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/models/Tile.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { useSpring, animated } from '@react-spring/three' 4 | 5 | import type { Position } from '../logic/board' 6 | 7 | const getColor = (color: string, canMoveHere: boolean) => { 8 | if (canMoveHere) { 9 | return `#ff0101` 10 | } 11 | if (color === `white`) { 12 | return `#aaaaaa` 13 | } 14 | if (color === `black`) { 15 | return `#5a5a5a` 16 | } 17 | return `purple` 18 | } 19 | 20 | const getEmissive = (color: string, canMoveHere: boolean) => { 21 | if (canMoveHere && color === `white`) { 22 | return `#ff0000` 23 | } 24 | if (canMoveHere && color === `black`) { 25 | return `#c50000` 26 | } 27 | return `black` 28 | } 29 | 30 | export const TileMaterial: FC< 31 | JSX.IntrinsicElements[`meshPhysicalMaterial`] & { 32 | canMoveHere: Position | null 33 | } 34 | > = ({ color, canMoveHere, ...props }) => { 35 | const { tileColor, emissiveColor } = useSpring({ 36 | tileColor: getColor(color as string, !!canMoveHere), 37 | emissiveColor: getEmissive(color as string, !!canMoveHere), 38 | }) 39 | return ( 40 | <> 41 | {/* @ts-ignore */} 42 | 52 | 53 | ) 54 | } 55 | 56 | export const TileComponent: FC< 57 | JSX.IntrinsicElements[`mesh`] & { 58 | canMoveHere: Position | null 59 | color: string 60 | } 61 | > = ({ color, canMoveHere, ...props }) => { 62 | return ( 63 | 64 | 65 | 66 | 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React from 'react' 3 | 4 | import { css } from '@emotion/react' 5 | import type { Board } from '@logic/board' 6 | import type { Move, Piece } from '@logic/pieces' 7 | import { AiFillCloseCircle } from 'react-icons/ai' 8 | import { BsReverseLayoutSidebarInsetReverse } from 'react-icons/bs' 9 | 10 | import { HistoryPanel } from './History' 11 | import { MiniMap } from './MiniMap' 12 | import { usePlayerState } from '@/state/player' 13 | 14 | export const Sidebar: FC<{ 15 | board: Board 16 | moves: Move[] 17 | selected: Piece | null 18 | }> = ({ board, moves, selected }) => { 19 | const [show, setShow] = React.useState(false) 20 | const joinedGame = usePlayerState((state) => state.joinedRoom) 21 | 22 | return ( 23 | <> 24 | {!show && joinedGame && ( 25 | setShow(!show)} 27 | css={css` 28 | position: absolute; 29 | top: 30px; 30 | left: 30px; 31 | z-index: 100; 32 | color: rgba(255, 255, 255, 0.8); 33 | font-size: 30px; 34 | cursor: pointer; 35 | `} 36 | /> 37 | )} 38 |
svg { 55 | position: absolute; 56 | top: 30px; 57 | right: -15px; 58 | z-index: 100; 59 | color: rgba(255, 255, 255, 0.8); 60 | cursor: pointer; 61 | font-size: 20px; 62 | } 63 | `} 64 | > 65 | {show && ( 66 | <> 67 | setShow(!show)} /> 68 | 69 | 70 | 71 | )} 72 |
73 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/components/History.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { css } from '@emotion/react' 4 | import type { Board, Position } from '@logic/board' 5 | import type { MoveTypes, Piece } from '@logic/pieces' 6 | 7 | import { useHistoryState } from '@/state/history' 8 | import { uppercaseFirstLetter } from '@/utils/upperCaseFirstLetter' 9 | 10 | export type History = { 11 | board: Board 12 | from: Position 13 | to: Position 14 | capture: Piece | null 15 | type: MoveTypes 16 | steps: Position 17 | piece: Piece 18 | } 19 | 20 | const convertCoords = (x: number, y: number) => { 21 | return { y: y + 1, x: numberMap[x] } 22 | } 23 | 24 | const numberMap: { 25 | [key: number]: string 26 | } = { 27 | 0: `a`, 28 | 1: `b`, 29 | 2: `c`, 30 | 3: `d`, 31 | 4: `e`, 32 | 5: `f`, 33 | 6: `g`, 34 | 7: `h`, 35 | } 36 | 37 | const getLastFive = (arr: History[]) => { 38 | if (arr.length < 5) return arr 39 | return arr.slice(arr.length - 5, arr.length) 40 | } 41 | 42 | export const HistoryPanel: FC = () => { 43 | const history = useHistoryState((state) => state.history) 44 | return ( 45 |
69 |

History

70 | {getLastFive(history).map((h, i) => { 71 | const from = convertCoords(h.from.x, h.from.y) 72 | const to = convertCoords(h.to.x, h.to.y) 73 | return ( 74 |

75 | {uppercaseFirstLetter(h.piece?.color)} 76 | {` `} 77 | {uppercaseFirstLetter(h.piece?.type)} 78 | 79 | {` `}from{` `} 80 | 81 | {from.x + from.y} 82 | 83 | {` `}to{` `} 84 | 85 | {to.x + to.y} 86 |

87 | ) 88 | })} 89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/components/MiniMap.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { css } from '@emotion/react' 4 | import type { Board } from '@logic/board' 5 | import type { Move, Piece } from '@logic/pieces' 6 | import { checkIfSelectedPieceCanMoveHere } from '@logic/pieces' 7 | import { 8 | FaChessPawn, 9 | FaChessKnight, 10 | FaChessBishop, 11 | FaChessRook, 12 | FaChessQueen, 13 | FaChessKing, 14 | } from 'react-icons/fa' 15 | 16 | export const MiniMap: FC<{ 17 | board: Board 18 | selected: Piece | null 19 | moves: Move[] 20 | }> = ({ board, selected, moves }) => { 21 | return ( 22 |
36 | {board.map((row, i) => ( 37 |
43 | {row.map((tile, j) => { 44 | const bg = `${(i + j) % 2 === 0 ? `#a5a5a5` : `#676767`}` 45 | const isSelected = selected?.getId() === tile.piece?.getId?.() 46 | const canMove = checkIfSelectedPieceCanMoveHere({ 47 | selected, 48 | moves, 49 | tile, 50 | }) 51 | 52 | return ( 53 |
67 | {tile && ( 68 | <> 69 | {tile.piece?.type === `pawn` && } 70 | {tile.piece?.type === `knight` && } 71 | {tile.piece?.type === `bishop` && } 72 | {tile.piece?.type === `rook` && } 73 | {tile.piece?.type === `queen` && } 74 | {tile.piece?.type === `king` && } 75 | 76 | )} 77 |
78 | ) 79 | })} 80 |
81 | ))} 82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/logic/pieces/king.ts: -------------------------------------------------------------------------------- 1 | import type { Board, Position } from '../board' 2 | import type { Move, MoveFunction, Piece, PieceFactory } from './' 3 | import { moveTypes, getFarMoves, getPiece, getMove, getBasePiece } from './' 4 | import type { Rook } from './rook' 5 | import { isRook } from './rook' 6 | 7 | export function isKing(value: King | Piece | null): value is King { 8 | return value?.type === `king` 9 | } 10 | 11 | const canCastleKing = (king: King, board: Board): Move[] => { 12 | if (king.hasMoved) return [] 13 | const possibleRookPositions: Move[] = [] 14 | const rook = getPiece(board, { 15 | x: king.position.x + 3, 16 | y: king.position.y, 17 | }) 18 | const rook2 = getPiece(board, { 19 | x: king.position.x - 4, 20 | y: king.position.y, 21 | }) 22 | const spacesToRight = getFarMoves({ 23 | board, 24 | piece: king, 25 | dir: { x: 1, y: 0 }, 26 | propagateDetectCheck: false, 27 | }) 28 | const spacesToLeft = getFarMoves({ 29 | board, 30 | piece: king, 31 | dir: { x: -1, y: 0 }, 32 | propagateDetectCheck: false, 33 | }) 34 | const props = (right: boolean) => ({ 35 | capture: null, 36 | piece: king, 37 | type: moveTypes.castling, 38 | newPosition: { x: right ? 6 : 2, y: king.position.y }, 39 | steps: { x: right ? 2 : -2, y: 0 }, 40 | castling: { 41 | rook: right ? (rook as Rook) : (rook2 as Rook), 42 | rookSteps: { x: right ? -2 : 3, y: 0 }, 43 | rookNewPosition: { x: right ? 5 : 3, y: king.position.y }, 44 | }, 45 | }) 46 | if (isRook(rook) && !rook.hasMoved && spacesToRight.length === 2) { 47 | possibleRookPositions.push(props(true)) 48 | } 49 | 50 | if (isRook(rook2) && !rook2.hasMoved && spacesToLeft.length === 3) { 51 | possibleRookPositions.push(props(false)) 52 | } 53 | return possibleRookPositions 54 | } 55 | 56 | export const kingMoves: MoveFunction = ({ 57 | piece, 58 | board, 59 | propagateDetectCheck, 60 | }) => { 61 | const moves: Move[] = [] 62 | 63 | for (const steps of KING_MOVES) { 64 | const move = getMove({ piece, board, steps, propagateDetectCheck }) 65 | if (!move) continue 66 | moves.push(move) 67 | } 68 | 69 | const possibleCastles = canCastleKing(piece, board) 70 | 71 | return [...moves, ...possibleCastles] 72 | } 73 | 74 | export const createKing = ({ color, id, position }: PieceFactory): King => { 75 | return { 76 | hasMoved: false, 77 | ...getBasePiece({ color, id, type: `king`, position }), 78 | } 79 | } 80 | 81 | export type King = Piece & { 82 | hasMoved: boolean 83 | } 84 | 85 | const KING_MOVES: Position[] = [ 86 | { 87 | x: 0, 88 | y: -1, 89 | }, 90 | { 91 | x: 0, 92 | y: 1, 93 | }, 94 | { 95 | x: -1, 96 | y: 0, 97 | }, 98 | { 99 | x: 1, 100 | y: 0, 101 | }, 102 | { 103 | x: -1, 104 | y: -1, 105 | }, 106 | { 107 | x: 1, 108 | y: 1, 109 | }, 110 | { 111 | x: -1, 112 | y: 1, 113 | }, 114 | { 115 | x: 1, 116 | y: -1, 117 | }, 118 | ] 119 | -------------------------------------------------------------------------------- /src/logic/pieces/pawn.ts: -------------------------------------------------------------------------------- 1 | import type { Position, Tile } from '../board' 2 | import type { Move, MoveFunction, Piece, PieceFactory } from './' 3 | import { getMove, getBasePiece } from './' 4 | import { useHistoryState } from '@/state/history' 5 | 6 | export function isPawn(value: Pawn | Piece | null): value is Pawn { 7 | return value?.type === `pawn` 8 | } 9 | 10 | const canEnPassant = (piece: Piece, colorMultiplier: number) => { 11 | const { history } = useHistoryState.getState() 12 | const lastMove = history[history.length - 1] 13 | if ( 14 | lastMove && 15 | lastMove.piece.type === `pawn` && 16 | Math.abs(lastMove.steps.y) === 2 17 | ) { 18 | const isSameY = lastMove.to.y === piece.position.y 19 | const isOnRight = lastMove.to.x === piece.position.x + 1 20 | const isOnLeft = lastMove.to.x === piece.position.x - 1 21 | 22 | const canEnPassant = isSameY && (isOnRight || isOnLeft) 23 | if (canEnPassant) { 24 | return { 25 | steps: { 26 | x: isOnLeft ? -1 : 1, 27 | y: colorMultiplier, 28 | }, 29 | piece: lastMove.piece, 30 | } 31 | } 32 | } 33 | return null 34 | } 35 | 36 | export const getPieceFromBoard = ( 37 | board: Tile[][], 38 | position: Position, 39 | ): Piece | null => { 40 | const { x, y } = position 41 | const { piece } = board[y][x] 42 | return piece || null 43 | } 44 | 45 | export const pawnMoves: MoveFunction = ({ 46 | piece, 47 | board, 48 | propagateDetectCheck, 49 | }) => { 50 | const { hasMoved, color } = piece 51 | const colorMultiplier = color === `white` ? -1 : 1 52 | 53 | const moves: Move[] = [] 54 | 55 | const movesForward: Position[] = [{ x: 0, y: 1 * colorMultiplier }] 56 | if (!hasMoved) { 57 | movesForward.push({ x: 0, y: 2 * colorMultiplier }) 58 | } 59 | for (const steps of movesForward) { 60 | const move = getMove({ piece, board, steps, propagateDetectCheck }) 61 | if (move && move.type !== `capture` && move.type !== `captureKing`) { 62 | moves.push(move) 63 | } else { 64 | break 65 | } 66 | } 67 | 68 | const enPassant = canEnPassant(piece, colorMultiplier) 69 | if (enPassant) { 70 | moves.push({ 71 | piece, 72 | type: `captureEnPassant`, 73 | steps: enPassant.steps, 74 | capture: enPassant.piece, 75 | newPosition: { 76 | x: piece.position.x + enPassant.steps.x, 77 | y: piece.position.y + enPassant.steps.y, 78 | }, 79 | }) 80 | } 81 | 82 | const movesDiagonal: Position[] = [ 83 | { x: 1, y: 1 * colorMultiplier }, 84 | { x: -1, y: 1 * colorMultiplier }, 85 | ] 86 | for (const steps of movesDiagonal) { 87 | const move = getMove({ piece, board, steps, propagateDetectCheck }) 88 | if (move?.type === `capture` || move?.type === `captureKing`) { 89 | moves.push(move) 90 | } 91 | } 92 | 93 | return moves 94 | } 95 | 96 | export const createPawn = ({ color, id, position }: PieceFactory): Pawn => { 97 | const hasMoved = false 98 | return { 99 | hasMoved, 100 | ...getBasePiece({ color, id, type: `pawn`, position }), 101 | } 102 | } 103 | 104 | export type Pawn = Piece & { 105 | hasMoved: boolean 106 | } 107 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { useEffect, useState } from 'react' 3 | 4 | import { css } from '@emotion/react' 5 | import type { Board } from '@logic/board' 6 | import { createBoard } from '@logic/board' 7 | import type { Color, GameOverType, Move, Piece } from '@logic/pieces' 8 | import { Environment, useProgress } from '@react-three/drei' 9 | import { Canvas } from '@react-three/fiber' 10 | 11 | import { BoardComponent } from '@/components/Board' 12 | import { Chat } from '@/components/Chat' 13 | import { GameCreation } from '@/components/GameCreation' 14 | import { GameOverScreen } from '@/components/GameOverScreen' 15 | import { Loader } from '@/components/Loader' 16 | import { Opponent } from '@/components/Opponent' 17 | import { Sidebar } from '@/components/Sidebar' 18 | import { StatusBar } from '@/components/StatusBar' 19 | import { Toast } from '@/components/Toast' 20 | import { Border } from '@/models/Border' 21 | import { useGameSettingsState } from '@/state/game' 22 | import { useHistoryState } from '@/state/history' 23 | import { usePlayerState } from '@/state/player' 24 | import { useSockets } from '@/utils/socket' 25 | 26 | export type GameOver = { 27 | type: GameOverType 28 | winner: Color 29 | } 30 | 31 | export const Home: FC = () => { 32 | const [board, setBoard] = useState(createBoard()) 33 | const [selected, setSelected] = useState(null) 34 | const [moves, setMoves] = useState([]) 35 | const [gameOver, setGameOver] = useState(null) 36 | const resetHistory = useHistoryState((state) => state.reset) 37 | const { resetTurn } = useGameSettingsState((state) => ({ 38 | resetTurn: state.resetTurn, 39 | gameStarted: state.gameStarted, 40 | })) 41 | const { joined } = usePlayerState((state) => ({ 42 | joined: state.joinedRoom, 43 | })) 44 | 45 | const reset = () => { 46 | setBoard(createBoard()) 47 | setSelected(null) 48 | setMoves([]) 49 | resetHistory() 50 | resetTurn() 51 | setGameOver(null) 52 | } 53 | 54 | useSockets({ reset }) 55 | 56 | const [total, setTotal] = useState(0) 57 | const { progress } = useProgress() 58 | useEffect(() => { 59 | setTotal(progress) 60 | }, [progress]) 61 | 62 | return ( 63 |
75 | {total === 100 ? : } 76 | 77 | {joined && } 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 94 | 95 |
96 | ) 97 | } 98 | 99 | export default Home 100 | -------------------------------------------------------------------------------- /src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { useState } from 'react' 3 | 4 | import { css } from '@emotion/react' 5 | 6 | import type { Message } from '@/state/player' 7 | import { useMessageState, usePlayerState } from '@/state/player' 8 | import { useSocketState } from '@/utils/socket' 9 | 10 | export type MessageClient = { 11 | room: string 12 | message: Message 13 | } 14 | 15 | export const Chat: FC = () => { 16 | const [message, setMessage] = useState(``) 17 | const [messages] = useMessageState((state) => [state.messages]) 18 | const { room, username } = usePlayerState((state) => ({ 19 | room: state.room, 20 | username: state.username, 21 | })) 22 | const socket = useSocketState((state) => state.socket) 23 | const sendMessage = async () => { 24 | socket?.emit(`createdMessage`, { 25 | room: room, 26 | message: { author: username, message }, 27 | }) 28 | setMessage(``) 29 | } 30 | 31 | const handleKeypress = (e: { keyCode: number }) => { 32 | if (e.keyCode === 13) { 33 | if (message) { 34 | sendMessage() 35 | } 36 | } 37 | } 38 | return ( 39 |
57 |
69 | {messages.map((msg, i) => { 70 | return ( 71 |
84 |

85 | {msg.author}: {msg.message} 86 |

87 |
88 | ) 89 | })} 90 |
91 |
105 | setMessage(e.target.value)} 110 | onKeyUp={handleKeypress} 111 | /> 112 | 119 |
120 |
121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /src/pages/api/socket.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import type { Socket, ServerOptions } from 'socket.io' 4 | import { Server } from 'socket.io' 5 | 6 | import type { MakeMoveClient, MovingTo } from '@/components/Board' 7 | import type { MessageClient } from '@/components/Chat' 8 | import type { JoinRoomClient } from '@/components/GameCreation' 9 | import type { Color } from '@/logic/pieces' 10 | import type { CameraMove } from '@/server/cameraMove' 11 | import { cameraMove } from '@/server/cameraMove' 12 | import { disconnect } from '@/server/disconnect' 13 | import { fetchPlayers } from '@/server/fetchPlayers' 14 | import { joinRoom } from '@/server/joinRoom' 15 | import { makeMove } from '@/server/makeMove' 16 | import { resetGame } from '@/server/resetGame' 17 | import { sendMessage } from '@/server/sendMessage' 18 | import type { Message } from '@/state/player' 19 | 20 | export type playerJoinedServer = { 21 | room: string 22 | username: string 23 | color: Color 24 | playerCount: number 25 | } 26 | 27 | export type Room = { 28 | room: string 29 | } 30 | export interface SocketClientToServer { 31 | createdMessage: (MessageClient: MessageClient) => void 32 | joinRoom: (JoinRoomClient: JoinRoomClient) => void 33 | makeMove: (MakeMoveClient: MakeMoveClient) => void 34 | cameraMove: (CameraMove: CameraMove) => void 35 | fetchPlayers: (Room: Room) => void 36 | resetGame: (Room: Room) => void 37 | playerLeft: (Room: Room) => void 38 | disconnect: (Room: Room) => void 39 | disconnecting: (Room: any) => void 40 | error: (Room: any) => void 41 | existingPlayer: (room: Room & { name: string }) => void 42 | } 43 | 44 | export interface SocketServerToClient { 45 | newIncomingMessage: (MessageClient: Message) => void 46 | playerJoined: (playerJoinedServer: playerJoinedServer) => void 47 | moveMade: (movingTo: MovingTo) => void 48 | cameraMoved: (CameraMove: CameraMove) => void 49 | playersInRoom: (players: number) => void 50 | gameReset: (data: boolean) => void 51 | newError: (error: string) => void 52 | joinRoom: (JoinRoomClient: JoinRoomClient) => void 53 | playerLeft: (Room: Room) => void 54 | clientExistingPlayer: (name: string) => void 55 | } 56 | 57 | export type MySocket = Socket 58 | export type MyServer = Server 59 | 60 | export default function SocketHandler( 61 | req: NextApiRequest, 62 | res: NextApiResponse & { 63 | socket: { 64 | server: ServerOptions & { 65 | io: Server 66 | } 67 | } 68 | }, 69 | ): void { 70 | // It means that socket server was already initialized 71 | if (res?.socket?.server?.io) { 72 | console.log(`Already set up`) 73 | res.end() 74 | return 75 | } 76 | 77 | const io = new Server( 78 | res?.socket?.server, 79 | ) 80 | res.socket.server.io = io 81 | 82 | const onConnection = (socket: MySocket) => { 83 | sendMessage(socket, io) 84 | joinRoom(socket, io) 85 | makeMove(socket, io) 86 | cameraMove(socket, io) 87 | fetchPlayers(socket, io) 88 | resetGame(socket, io) 89 | disconnect(socket, io) 90 | socket.on(`existingPlayer`, (data) => { 91 | io.sockets.in(data.room).emit(`clientExistingPlayer`, data.name) 92 | }) 93 | } 94 | 95 | // Define actions inside 96 | io.on(`connection`, onConnection) 97 | 98 | console.log(`Setting up socket`) 99 | res.end() 100 | } 101 | -------------------------------------------------------------------------------- /src/utils/socket.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { toast } from 'react-toastify' 4 | import type { Socket } from 'socket.io-client' 5 | // eslint-disable-next-line import/no-named-as-default 6 | import io from 'socket.io-client' 7 | import create from 'zustand' 8 | 9 | import type { MovingTo } from '@/components/Board' 10 | import type { 11 | SocketClientToServer, 12 | SocketServerToClient, 13 | playerJoinedServer, 14 | } from '@/pages/api/socket' 15 | import type { CameraMove } from '@/server/cameraMove' 16 | import { useGameSettingsState } from '@/state/game' 17 | import type { Message } from '@/state/player' 18 | import { 19 | useOpponentState, 20 | usePlayerState, 21 | useMessageState, 22 | } from '@/state/player' 23 | 24 | type ClientSocket = Socket 25 | let socket: ClientSocket 26 | 27 | export const useSocketState = create<{ 28 | socket: ClientSocket | null 29 | setSocket: (socket: ClientSocket) => void 30 | }>((set) => ({ 31 | socket: null, 32 | setSocket: (socket) => set({ socket }), 33 | })) 34 | 35 | export const useSockets = ({ reset }: { reset: VoidFunction }): void => { 36 | const [addMessage] = useMessageState((state) => [state.addMessage]) 37 | const { setGameStarted, setMovingTo } = useGameSettingsState((state) => ({ 38 | setGameStarted: state.setGameStarted, 39 | setMovingTo: state.setMovingTo, 40 | })) 41 | const { setPlayerColor, setJoinedRoom } = usePlayerState((state) => state) 42 | 43 | const { setPosition, setName: setOpponentName } = useOpponentState( 44 | (state) => state, 45 | ) 46 | 47 | const { socket: socketState, setSocket } = useSocketState((state) => ({ 48 | socket: state.socket, 49 | setSocket: state.setSocket, 50 | })) 51 | useEffect(() => { 52 | socketInitializer() 53 | 54 | return () => { 55 | if (socketState) { 56 | socketState.emit(`playerLeft`, { room: usePlayerState.getState().room }) 57 | socketState.disconnect() 58 | } 59 | } 60 | }, []) 61 | 62 | const socketInitializer = async () => { 63 | await fetch(`/api/socket`) 64 | socket = io() 65 | setSocket(socket) 66 | 67 | socket.on(`newIncomingMessage`, (msg: Message) => { 68 | addMessage(msg) 69 | }) 70 | 71 | socket.on(`playerJoined`, (data: playerJoinedServer) => { 72 | const split = data.username.split(`#`) 73 | addMessage({ 74 | author: `System`, 75 | message: `${split[0]} has joined ${data.room}`, 76 | }) 77 | const { id, username } = usePlayerState.getState() 78 | if (split[1] === id) { 79 | setPlayerColor(data.color) 80 | setJoinedRoom(true) 81 | } else { 82 | socket.emit(`existingPlayer`, { 83 | room: data.room, 84 | name: `${username}#${id}`, 85 | }) 86 | setOpponentName(split[0]) 87 | } 88 | }) 89 | 90 | socket.on(`clientExistingPlayer`, (data: string) => { 91 | const split = data.split(`#`) 92 | if (split[1] !== usePlayerState.getState().id) { 93 | setOpponentName(split[0]) 94 | } 95 | }) 96 | 97 | socket.on(`cameraMoved`, (data: CameraMove) => { 98 | const { playerColor } = usePlayerState.getState() 99 | if (playerColor === data.color) { 100 | return 101 | } 102 | setPosition(data.position) 103 | }) 104 | 105 | socket.on(`moveMade`, (data: MovingTo) => { 106 | setMovingTo(data) 107 | }) 108 | 109 | socket.on(`gameReset`, () => { 110 | reset() 111 | }) 112 | 113 | socket.on(`playersInRoom`, (data: number) => { 114 | if (data === 2) { 115 | setGameStarted(true) 116 | } 117 | }) 118 | 119 | socket.on(`newError`, (err: string) => { 120 | toast.error(err, { 121 | toastId: err, 122 | }) 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/components/GameCreation.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent, FC } from 'react' 2 | 3 | import { css } from '@emotion/react' 4 | import { toast } from 'react-toastify' 5 | 6 | import { usePlayerState } from '@/state/player' 7 | import { useSocketState } from '@/utils/socket' 8 | 9 | export type JoinRoomClient = { 10 | room: string 11 | username: string 12 | } 13 | 14 | const filter = ( 15 | e: ChangeEvent, 16 | set: (str: string) => void, 17 | ) => { 18 | const str = e.target.value 19 | const filtered = str.replace(/[^a-zA-Z0-9]/g, ``) 20 | set(filtered) 21 | } 22 | 23 | export const GameCreation: FC = () => { 24 | const { room, username, joinedRoom, setUsername, setRoom, id } = 25 | usePlayerState((state) => state) 26 | const { socket } = useSocketState((state) => ({ 27 | socket: state.socket, 28 | })) 29 | const sendRoom = async () => { 30 | if (!socket) return 31 | const data: JoinRoomClient = { room, username: `${username}#${id}` } 32 | socket.emit(`joinRoom`, data) 33 | socket.emit(`fetchPlayers`, { room }) 34 | } 35 | return ( 36 | <> 37 | {!joinedRoom && ( 38 | <> 39 |
{ 41 | e.preventDefault() 42 | if (username.length < 3 || room.length < 3) { 43 | toast.error(`Name or Room is too short.`, { 44 | toastId: `nameOrRoomTooShort`, 45 | }) 46 | return 47 | } 48 | sendRoom() 49 | }} 50 | > 51 |
87 | filter(e, setUsername)} 92 | minLength={3} 93 | maxLength={10} 94 | /> 95 |
96 | filter(e, setRoom)} 101 | minLength={3} 102 | maxLength={16} 103 | /> 104 |

If no room exists one will be created.

105 |
106 | 107 |
108 |
109 |
120 | 121 | )} 122 | 123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /src/models/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import type { FC } from 'react' 3 | 4 | import type { Position } from '@logic/board' 5 | import { useSpring, animated } from '@react-spring/three' 6 | import type { 7 | AnimationControls, 8 | TargetAndTransition, 9 | VariantLabels, 10 | Transition, 11 | } from 'framer-motion' 12 | import { motion } from 'framer-motion-3d' 13 | 14 | export const PieceMaterial: FC< 15 | JSX.IntrinsicElements[`meshPhysicalMaterial`] & { 16 | isSelected: boolean 17 | pieceIsBeingReplaced: boolean 18 | } 19 | > = ({ color, isSelected, pieceIsBeingReplaced, ...props }) => { 20 | const { opacity } = useSpring({ 21 | opacity: pieceIsBeingReplaced ? 0 : 1, 22 | }) 23 | return ( 24 | // @ts-ignore 25 | 37 | ) 38 | } 39 | 40 | export type ModelProps = JSX.IntrinsicElements[`group`] & { 41 | color: string 42 | isSelected: boolean 43 | canMoveHere: Position | null 44 | movingTo: Position | null 45 | finishMovingPiece: () => void 46 | pieceIsBeingReplaced: boolean 47 | wasSelected: boolean 48 | } 49 | 50 | export const MeshWrapper: FC = ({ 51 | movingTo, 52 | finishMovingPiece, 53 | isSelected, 54 | children, 55 | pieceIsBeingReplaced, 56 | wasSelected, 57 | ...props 58 | }) => { 59 | const ref = useRef(null) 60 | const meshRef = useRef(null) 61 | return ( 62 | 63 | { 90 | if (movingTo) { 91 | finishMovingPiece() 92 | } 93 | }} 94 | > 95 | {children} 96 | 101 | 102 | 103 | ) 104 | } 105 | 106 | export const FRAMER_MULTIPLIER = 6.66 107 | export const getDistance = (px?: number): number => 108 | px ? px * FRAMER_MULTIPLIER : 0 109 | 110 | export const transitions: { 111 | select: Transition 112 | moveTo: Transition & { y: Transition } 113 | initial: Transition 114 | replace: Transition 115 | wasSelected: Transition 116 | } = { 117 | moveTo: { 118 | type: `spring`, 119 | stiffness: 200, 120 | damping: 30, 121 | y: { delay: 0.15, stiffness: 120, damping: 5 }, 122 | }, 123 | select: { 124 | type: `spring`, 125 | }, 126 | replace: { 127 | type: `spring`, 128 | stiffness: 50, 129 | damping: 5, 130 | }, 131 | initial: { 132 | duration: 0, 133 | }, 134 | wasSelected: { 135 | type: `spring`, 136 | duration: 0.5, 137 | }, 138 | } 139 | 140 | export type VariantReturns = 141 | | AnimationControls 142 | | TargetAndTransition 143 | | VariantLabels 144 | | boolean 145 | export type VariantProps = { 146 | isSelected: boolean 147 | movingTo: Position | null 148 | } 149 | 150 | type VariantFunction = (props: VariantProps) => VariantReturns 151 | export const variants: { 152 | select: VariantFunction 153 | move: VariantFunction 154 | replace: VariantFunction 155 | initial: VariantFunction 156 | } = { 157 | initial: () => ({ 158 | x: 0, 159 | }), 160 | select: ({ isSelected }: VariantProps) => ({ 161 | x: 0, 162 | y: isSelected ? 1.4 : 0, 163 | z: 0, 164 | }), 165 | move: ({ movingTo }: VariantProps) => ({ 166 | x: getDistance(movingTo?.x), 167 | y: [1.4, 1.6, 0], 168 | z: getDistance(movingTo?.y), 169 | }), 170 | replace: () => ({ 171 | y: 20, 172 | x: 5 * randomNegative(), 173 | z: 10 * randomNegative(), 174 | rotateX: (Math.PI / 4) * randomNegative(), 175 | }), 176 | } 177 | 178 | const randomNegative = () => (Math.random() > 0.5 ? -1 : 1) 179 | -------------------------------------------------------------------------------- /src/logic/board.ts: -------------------------------------------------------------------------------- 1 | import type { Piece, PieceArgs } from './pieces' 2 | import { createPiece } from './pieces' 3 | import type { Pawn } from './pieces/pawn' 4 | import type { Rook } from './pieces/rook' 5 | 6 | export type Position = { x: number; y: number } 7 | 8 | export type Board = Tile[][] 9 | 10 | export type Tile = { 11 | position: Position 12 | piece: Pawn | Piece | Rook | null 13 | } 14 | export const createTile = (position: Position, piece?: PieceArgs): Tile => { 15 | return { 16 | position, 17 | piece: piece ? createPiece({ ...piece, position }) : null, 18 | } 19 | } 20 | 21 | export const checkIfPositionsMatch = ( 22 | pos1?: Position | null, 23 | pos2?: Position | null, 24 | ): boolean => { 25 | if (!pos1 || !pos2) return false 26 | return pos1.x === pos2.x && pos1.y === pos2.y 27 | } 28 | 29 | export const copyBoard = (board: Board): Board => { 30 | return [ 31 | ...board.map((row) => { 32 | return [ 33 | ...row.map((tile) => { 34 | return { ...tile, piece: tile.piece ? { ...tile.piece } : null } 35 | }), 36 | ] 37 | }), 38 | ] 39 | } 40 | 41 | export const createBoard = (): Board => { 42 | const DEFAULT_BOARD: Board = [ 43 | [ 44 | createTile( 45 | { x: 0, y: 0 }, 46 | { 47 | color: `black`, 48 | id: 1, 49 | type: `rook`, 50 | }, 51 | ), 52 | createTile( 53 | { x: 1, y: 0 }, 54 | { 55 | color: `black`, 56 | id: 1, 57 | type: `knight`, 58 | }, 59 | ), 60 | createTile( 61 | { x: 2, y: 0 }, 62 | { 63 | color: `black`, 64 | id: 1, 65 | type: `bishop`, 66 | }, 67 | ), 68 | createTile( 69 | { x: 3, y: 0 }, 70 | { 71 | color: `black`, 72 | id: 1, 73 | type: `queen`, 74 | }, 75 | ), 76 | createTile( 77 | { x: 4, y: 0 }, 78 | { 79 | color: `black`, 80 | id: 1, 81 | type: `king`, 82 | }, 83 | ), 84 | createTile( 85 | { x: 5, y: 0 }, 86 | { 87 | color: `black`, 88 | id: 2, 89 | type: `bishop`, 90 | }, 91 | ), 92 | createTile( 93 | { x: 6, y: 0 }, 94 | { 95 | color: `black`, 96 | id: 2, 97 | type: `knight`, 98 | }, 99 | ), 100 | createTile( 101 | { x: 7, y: 0 }, 102 | { 103 | color: `black`, 104 | id: 2, 105 | type: `rook`, 106 | }, 107 | ), 108 | ], 109 | [ 110 | ...Array(8) 111 | .fill(null) 112 | .map((_, i) => 113 | createTile( 114 | { x: i, y: 1 }, 115 | { 116 | color: `black`, 117 | id: i + 1, 118 | type: `pawn`, 119 | }, 120 | ), 121 | ), 122 | ], 123 | [ 124 | ...Array(8) 125 | .fill(null) 126 | .map((_, i) => createTile({ x: i, y: 2 })), 127 | ], 128 | [ 129 | ...Array(8) 130 | .fill(null) 131 | .map((_, i) => createTile({ x: i, y: 3 })), 132 | ], 133 | [ 134 | ...Array(8) 135 | .fill(null) 136 | .map((_, i) => createTile({ x: i, y: 4 })), 137 | ], 138 | [ 139 | ...Array(8) 140 | .fill(null) 141 | .map((_, i) => createTile({ x: i, y: 5 })), 142 | ], 143 | [ 144 | ...Array(8) 145 | .fill(null) 146 | .map((_, i) => 147 | createTile( 148 | { x: i, y: 6 }, 149 | { 150 | color: `white`, 151 | id: i + 1, 152 | type: `pawn`, 153 | }, 154 | ), 155 | ), 156 | ], 157 | [ 158 | createTile( 159 | { x: 0, y: 7 }, 160 | { 161 | color: `white`, 162 | id: 1, 163 | type: `rook`, 164 | }, 165 | ), 166 | createTile( 167 | { x: 1, y: 7 }, 168 | { 169 | color: `white`, 170 | id: 1, 171 | type: `knight`, 172 | }, 173 | ), 174 | createTile( 175 | { x: 2, y: 7 }, 176 | { 177 | color: `white`, 178 | id: 1, 179 | type: `bishop`, 180 | }, 181 | ), 182 | createTile( 183 | { x: 3, y: 7 }, 184 | { 185 | color: `white`, 186 | id: 1, 187 | type: `queen`, 188 | }, 189 | ), 190 | createTile( 191 | { x: 4, y: 7 }, 192 | { 193 | color: `white`, 194 | id: 1, 195 | type: `king`, 196 | }, 197 | ), 198 | createTile( 199 | { x: 5, y: 7 }, 200 | { 201 | color: `white`, 202 | id: 2, 203 | type: `bishop`, 204 | }, 205 | ), 206 | createTile( 207 | { x: 6, y: 7 }, 208 | { 209 | color: `white`, 210 | id: 2, 211 | type: `knight`, 212 | }, 213 | ), 214 | createTile( 215 | { x: 7, y: 7 }, 216 | { 217 | color: `white`, 218 | id: 2, 219 | type: `rook`, 220 | }, 221 | ), 222 | ], 223 | ] 224 | return DEFAULT_BOARD 225 | } 226 | 227 | type TestBoardArgs = { 228 | position: Position 229 | piece: PieceArgs 230 | } 231 | export const createTestBoard = (pieces: TestBoardArgs[]): Board => { 232 | const board = [ 233 | ...Array(8) 234 | .fill(null) 235 | .map((_, j) => 236 | Array(8) 237 | .fill(null) 238 | .map((_, i) => createTile({ x: i, y: j })), 239 | ), 240 | ] 241 | 242 | for (const { position, piece } of pieces) { 243 | board[position.y][position.x].piece = createPiece({ ...piece, position }) 244 | } 245 | return board 246 | } 247 | 248 | export const TEST_EXAMPLES: { kingInCheck: TestBoardArgs[] } = { 249 | kingInCheck: [ 250 | { 251 | position: { x: 7, y: 7 }, 252 | piece: { 253 | color: `white`, 254 | id: 1, 255 | type: `king`, 256 | }, 257 | }, 258 | { 259 | position: { x: 7, y: 0 }, 260 | piece: { 261 | color: `black`, 262 | id: 1, 263 | type: `king`, 264 | }, 265 | }, 266 | { 267 | position: { x: 5, y: 7 }, 268 | piece: { 269 | color: `black`, 270 | id: 1, 271 | type: `queen`, 272 | }, 273 | }, 274 | { 275 | position: { x: 2, y: 7 }, 276 | piece: { 277 | color: `white`, 278 | id: 1, 279 | type: `rook`, 280 | }, 281 | }, 282 | { 283 | position: { x: 0, y: 7 }, 284 | piece: { 285 | color: `white`, 286 | id: 1, 287 | type: `queen`, 288 | }, 289 | }, 290 | ], 291 | } 292 | -------------------------------------------------------------------------------- /src/logic/pieces/index.ts: -------------------------------------------------------------------------------- 1 | import type { Board, Position, Tile } from '../board' 2 | import { copyBoard } from '../board' 3 | import { bishopMoves, createBishop, isBishop } from './bishop' 4 | import type { King } from './king' 5 | import { createKing, isKing, kingMoves } from './king' 6 | import { createKnight, isKnight, knightMoves } from './knight' 7 | import type { Pawn } from './pawn' 8 | import { createPawn, isPawn, pawnMoves } from './pawn' 9 | import { createQueen, isQueen, queenMoves } from './queen' 10 | import { createRook, isRook, rookMoves } from './rook' 11 | 12 | export type Piece = { 13 | type: PieceType 14 | color: Color 15 | id: number 16 | getId: () => string 17 | position: Position 18 | } 19 | 20 | export type Color = `black` | `white` 21 | export type PieceType = `bishop` | `king` | `knight` | `pawn` | `queen` | `rook` 22 | 23 | export const oppositeColor = (color: Color): Color => { 24 | return color === `black` ? `white` : `black` 25 | } 26 | 27 | export const movesForPiece = ({ 28 | piece, 29 | board, 30 | propagateDetectCheck, 31 | }: { 32 | piece: King | Pawn | Piece | null 33 | board: Board 34 | propagateDetectCheck: boolean 35 | }): Move[] => { 36 | if (!piece) return [] 37 | const props = { piece, board, propagateDetectCheck } 38 | if (isPawn(piece)) { 39 | return pawnMoves({ ...props, piece: piece as Pawn }) 40 | } 41 | if (isRook(piece)) { 42 | return rookMoves(props) 43 | } 44 | if (isKnight(piece)) { 45 | return knightMoves(props) 46 | } 47 | if (isBishop(piece)) { 48 | return bishopMoves(props) 49 | } 50 | if (isQueen(piece)) { 51 | return queenMoves(props) 52 | } 53 | if (isKing(piece)) { 54 | return kingMoves({ ...props, piece: piece as King }) 55 | } 56 | return [] 57 | } 58 | 59 | export type PieceArgs = { 60 | color: Color 61 | id: number 62 | type: PieceType 63 | } 64 | 65 | export type PieceFactory = PieceArgs & { position: Position } 66 | 67 | export const getBasePiece = (args: PieceFactory): Piece => { 68 | return { 69 | color: args.color, 70 | id: args.id, 71 | type: args.type, 72 | getId: () => createId(args), 73 | position: args.position, 74 | } 75 | } 76 | 77 | export const createPiece = ( 78 | args?: PieceArgs & { position: Position }, 79 | ): Pawn | Piece | null => { 80 | if (!args) return null 81 | switch (args.type) { 82 | case `pawn`: 83 | return createPawn(args) 84 | case `rook`: 85 | return createRook(args) 86 | case `knight`: 87 | return createKnight(args) 88 | case `bishop`: 89 | return createBishop(args) 90 | case `queen`: 91 | return createQueen(args) 92 | case `king`: 93 | return createKing(args) 94 | default: 95 | return null 96 | } 97 | } 98 | 99 | export const moveTypes = { 100 | invalid: `invalid` as const, 101 | valid: `valid` as const, 102 | captureKing: `captureKing` as const, 103 | capture: `capture` as const, 104 | captureEnPassant: `captureEnPassant` as const, 105 | castling: `castling` as const, 106 | willBeInCheck: `willBeInCheck` as const, 107 | } 108 | export type MoveTypes = typeof moveTypes[keyof typeof moveTypes] 109 | export type Move = { 110 | steps: Position 111 | type: MoveTypes 112 | piece: Piece 113 | capture: Piece | null 114 | newPosition: Position 115 | castling?: { 116 | rook: Piece 117 | rookNewPosition: Position 118 | rookSteps: Position 119 | } 120 | } 121 | export type MoveFunction = (props: { 122 | piece: T 123 | board: Board 124 | propagateDetectCheck: boolean 125 | }) => Move[] 126 | 127 | export const willBeInCheck = ( 128 | piece: Piece, 129 | board: Board, 130 | move: Position, 131 | ): boolean => { 132 | const newBoard = copyBoard(board) 133 | const tile = getTile(newBoard, piece.position) 134 | const newTile = getTile(newBoard, { 135 | x: move.x + piece.position.x, 136 | y: move.y + piece.position.y, 137 | }) 138 | if (!tile || !newTile) return false 139 | newTile.piece = piece 140 | tile.piece = null 141 | 142 | for (const tile of newBoard.flat()) { 143 | if (tile.piece?.color === oppositeColor(piece.color)) { 144 | const moves = movesForPiece({ 145 | piece: tile.piece, 146 | board: newBoard, 147 | propagateDetectCheck: false, 148 | }) 149 | if (moves.find((move) => move.type === `captureKing`)) { 150 | return true 151 | } 152 | } 153 | } 154 | return false 155 | } 156 | 157 | export type GameOverType = `checkmate` | `stalemate` 158 | 159 | export const detectStalemate = ( 160 | board: Board, 161 | turn: Color, 162 | ): GameOverType | null => { 163 | for (const tile of board.flat()) { 164 | if (tile.piece?.color === turn) { 165 | const moves = movesForPiece({ 166 | piece: tile.piece, 167 | board, 168 | propagateDetectCheck: true, 169 | }) 170 | if (moves.find((move) => move.type !== `invalid`)) { 171 | return null 172 | } 173 | } 174 | } 175 | return `stalemate` 176 | } 177 | 178 | export const detectCheckmate = ( 179 | board: Board, 180 | turn: Color, 181 | ): GameOverType | null => { 182 | for (const tile of board.flat()) { 183 | if (tile.piece?.color !== turn) { 184 | const moves = movesForPiece({ 185 | piece: tile.piece, 186 | board, 187 | propagateDetectCheck: false, 188 | }) 189 | if (moves.find((move) => move.type === `captureKing`)) { 190 | return `checkmate` 191 | } 192 | } 193 | } 194 | 195 | return null 196 | } 197 | 198 | export const detectGameOver = ( 199 | board: Board, 200 | turn: Color, 201 | ): GameOverType | null => { 202 | let gameOver = null 203 | const staleMate = detectStalemate(board, turn) 204 | if (staleMate) { 205 | gameOver = staleMate 206 | const checkMate = detectCheckmate(board, turn) 207 | if (checkMate) gameOver = checkMate 208 | } 209 | 210 | return gameOver 211 | } 212 | 213 | export const getTile = (board: Board, position: Position): Tile | null => { 214 | const row = board[position.y] 215 | if (!row) return null 216 | const cur = row[position.x] 217 | if (!cur) return null 218 | return cur 219 | } 220 | 221 | export const getPiece = (board: Board, position: Position): Piece | null => { 222 | const piece = getTile(board, position)?.piece 223 | return piece || null 224 | } 225 | 226 | export const getMove = ({ 227 | piece, 228 | board, 229 | steps, 230 | propagateDetectCheck, 231 | getFar, 232 | }: { 233 | piece: Piece 234 | board: Board 235 | steps: Position 236 | propagateDetectCheck: boolean 237 | getFar?: boolean 238 | }): Move | null => { 239 | const { position } = piece 240 | const { x, y } = steps 241 | const nextPosition = { x: position.x + x, y: position.y + y } 242 | const row = board[nextPosition.y] 243 | if (!row) return null 244 | const cur = row[nextPosition.x] 245 | if (!cur) return null 246 | const props = { 247 | piece, 248 | steps, 249 | newPosition: nextPosition, 250 | } 251 | const willBeCheck = propagateDetectCheck && willBeInCheck(piece, board, steps) 252 | 253 | if (cur.piece?.color === oppositeColor(piece.color) && !willBeCheck) { 254 | return { 255 | ...props, 256 | type: cur.piece.type === `king` ? `captureKing` : `capture`, 257 | capture: cur.piece, 258 | } 259 | } 260 | if (cur.piece) { 261 | return null 262 | } 263 | 264 | if (willBeCheck) { 265 | return getFar 266 | ? { 267 | ...props, 268 | type: `willBeInCheck`, 269 | capture: null, 270 | } 271 | : null 272 | } 273 | 274 | return { 275 | steps, 276 | type: `valid`, 277 | piece, 278 | capture: null, 279 | newPosition: nextPosition, 280 | } 281 | } 282 | 283 | export const getFarMoves = ({ 284 | dir, 285 | piece, 286 | board, 287 | propagateDetectCheck, 288 | }: { 289 | dir: Position 290 | piece: Piece 291 | board: Board 292 | propagateDetectCheck: boolean 293 | }): Move[] => { 294 | const moves: Move[] = [] 295 | for (let i = 1; i < 8; i++) { 296 | const getStep = (dir: Position) => ({ x: dir.x * i, y: dir.y * i }) 297 | const steps = getStep(dir) 298 | const move = getMove({ 299 | piece, 300 | board, 301 | steps, 302 | propagateDetectCheck, 303 | getFar: true, 304 | }) 305 | if (!move) break 306 | if (move.type === `willBeInCheck`) continue 307 | moves.push(move) 308 | if (move.type === `capture` || move.type === `captureKing`) break 309 | } 310 | return moves 311 | } 312 | 313 | export const createId = (piece?: PieceArgs | null): string => { 314 | if (!piece) return `empty` 315 | return `${piece?.type}-${piece?.color}-${piece?.id}` 316 | } 317 | 318 | export const shouldPromotePawn = ({ tile }: { tile: Tile }): boolean => { 319 | if (tile.position.y === 0 || tile.position.y === 7) { 320 | return true 321 | } 322 | return false 323 | } 324 | 325 | export const checkIfSelectedPieceCanMoveHere = ({ 326 | selected, 327 | moves, 328 | tile, 329 | }: { 330 | selected: Piece | null 331 | moves: Move[] 332 | tile: Tile 333 | }): Move | null => { 334 | if (!selected) return null 335 | 336 | for (const move of moves) { 337 | if ( 338 | move.newPosition.x === tile.position.x && 339 | move.newPosition.y === tile.position.y 340 | ) { 341 | return move 342 | } 343 | } 344 | return null 345 | } 346 | -------------------------------------------------------------------------------- /src/components/Board.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import React, { useEffect, useState } from 'react' 3 | 4 | import type { Position, Tile, Board } from '@logic/board' 5 | import { checkIfPositionsMatch, copyBoard } from '@logic/board' 6 | import type { Move, Piece } from '@logic/pieces' 7 | import { 8 | createId, 9 | getTile, 10 | detectGameOver, 11 | oppositeColor, 12 | shouldPromotePawn, 13 | checkIfSelectedPieceCanMoveHere, 14 | movesForPiece, 15 | } from '@logic/pieces' 16 | import { isPawn } from '@logic/pieces/pawn' 17 | import { BishopComponent } from '@models/Bishop' 18 | import type { ModelProps } from '@models/index' 19 | import { MeshWrapper } from '@models/index' 20 | import { KingComponent } from '@models/King' 21 | import { KnightComponent } from '@models/Knight' 22 | import { PawnModel } from '@models/Pawn' 23 | import { QueenComponent } from '@models/Queen' 24 | import { RookComponent } from '@models/Rook' 25 | import { TileComponent } from '@models/Tile' 26 | import { useSpring, animated } from '@react-spring/three' 27 | import { OrbitControls } from '@react-three/drei' 28 | import { useThree } from '@react-three/fiber' 29 | 30 | import { isKing } from '@/logic/pieces/king' 31 | import { isRook } from '@/logic/pieces/rook' 32 | import type { GameOver } from '@/pages/index' 33 | import type { CameraMove } from '@/server/cameraMove' 34 | import { useGameSettingsState } from '@/state/game' 35 | import { useHistoryState } from '@/state/history' 36 | import { usePlayerState } from '@/state/player' 37 | import { isDev } from '@/utils/isDev' 38 | import { useSocketState } from '@/utils/socket' 39 | 40 | type ThreeMouseEvent = { 41 | stopPropagation: () => void 42 | } 43 | 44 | export type MovingTo = { 45 | move: Move 46 | tile: Tile 47 | } 48 | export type MakeMoveClient = { 49 | movingTo: MovingTo 50 | room: string 51 | } 52 | 53 | export const BoardComponent: FC<{ 54 | selected: Piece | null 55 | setSelected: (piece: Piece | null) => void 56 | board: Board 57 | setBoard: React.Dispatch> 58 | moves: Move[] 59 | setGameOver: (gameOver: GameOver | null) => void 60 | setMoves: (moves: Move[]) => void 61 | }> = ({ 62 | selected, 63 | setSelected, 64 | board, 65 | setBoard, 66 | moves, 67 | setMoves, 68 | setGameOver, 69 | }) => { 70 | const [lastSelected, setLastSelected] = useState(null) 71 | const [history, setHistory] = useHistoryState((state) => [ 72 | state.history, 73 | state.addItem, 74 | ]) 75 | const { playerColor, room } = usePlayerState((state) => ({ 76 | playerColor: state.playerColor, 77 | room: state.room, 78 | })) 79 | const [turn, setTurn, gameStarted, movingTo, setMovingTo] = 80 | useGameSettingsState((state) => [ 81 | state.turn, 82 | state.setTurn, 83 | state.gameStarted, 84 | state.movingTo, 85 | state.setMovingTo, 86 | ]) 87 | const socket = useSocketState((state) => state.socket) 88 | 89 | const [redLightPosition, setRedLightPosition] = useState({ 90 | x: 0, 91 | y: 0, 92 | }) 93 | 94 | const selectThisPiece = (e: ThreeMouseEvent, tile: Tile | null) => { 95 | e.stopPropagation() 96 | const isPlayersTurn = turn === playerColor || isDev 97 | if (!isPlayersTurn || !gameStarted) return 98 | if (!tile?.piece?.type && !selected) return 99 | if (!tile?.piece) { 100 | setSelected(null) 101 | return 102 | } 103 | 104 | setMovingTo(null) 105 | setMoves( 106 | movesForPiece({ piece: tile.piece, board, propagateDetectCheck: true }), 107 | ) 108 | setSelected(tile.piece) 109 | setLastSelected(tile) 110 | setRedLightPosition(tile.position) 111 | } 112 | 113 | const finishMovingPiece = (tile: Tile | null) => { 114 | if (!tile || !movingTo || !socket) return 115 | const newHistoryItem = { 116 | board: copyBoard(board), 117 | to: movingTo.move.newPosition, 118 | from: movingTo.move.piece.position, 119 | steps: movingTo.move.steps, 120 | capture: movingTo.move.capture, 121 | type: movingTo.move.type, 122 | piece: movingTo.move.piece, 123 | } 124 | setHistory(newHistoryItem) 125 | setBoard((prev) => { 126 | const newBoard = copyBoard(prev) 127 | if (!movingTo.move.piece) return prev 128 | const selectedTile = getTile(newBoard, movingTo.move.piece.position) 129 | const tileToMoveTo = getTile(newBoard, tile.position) 130 | if (!selectedTile || !tileToMoveTo) return prev 131 | 132 | if ( 133 | isPawn(selectedTile.piece) || 134 | isKing(selectedTile.piece) || 135 | isRook(selectedTile.piece) 136 | ) { 137 | selectedTile.piece = { ...selectedTile.piece, hasMoved: true } 138 | } 139 | if (isPawn(selectedTile.piece) && shouldPromotePawn({ tile })) { 140 | selectedTile.piece.type = `queen` 141 | selectedTile.piece.id = selectedTile.piece.id + 1 142 | } 143 | 144 | if ( 145 | isPawn(selectedTile.piece) && 146 | movingTo.move.type === `captureEnPassant` 147 | ) { 148 | const latestMove = history[history.length - 1] 149 | const enPassantTile = newBoard[latestMove.to.y][latestMove.to.x] 150 | enPassantTile.piece = null 151 | } 152 | 153 | if (movingTo.move.castling) { 154 | const rookTile = 155 | newBoard[movingTo.move.castling.rook.position.y][ 156 | movingTo.move.castling.rook.position.x 157 | ] 158 | const rookTileToMoveTo = 159 | newBoard[movingTo.move.castling.rookNewPosition.y][ 160 | movingTo.move.castling.rookNewPosition.x 161 | ] 162 | if (!isRook(rookTile.piece)) return prev 163 | 164 | rookTileToMoveTo.piece = { 165 | ...rookTile.piece, 166 | hasMoved: true, 167 | position: rookTileToMoveTo.position, 168 | } 169 | rookTile.piece = null 170 | } 171 | 172 | tileToMoveTo.piece = selectedTile.piece 173 | ? { ...selectedTile.piece, position: tile.position } 174 | : null 175 | selectedTile.piece = null 176 | return newBoard 177 | }) 178 | 179 | setTurn() 180 | 181 | setMovingTo(null) 182 | setMoves([]) 183 | setSelected(null) 184 | setLastSelected(null) 185 | } 186 | 187 | useEffect(() => { 188 | const gameOverType = detectGameOver(board, turn) 189 | if (gameOverType) { 190 | setGameOver({ type: gameOverType, winner: oppositeColor(turn) }) 191 | } 192 | }, [board, turn]) 193 | 194 | const startMovingPiece = (e: ThreeMouseEvent, tile: Tile, nextTile: Move) => { 195 | e.stopPropagation() 196 | if (!socket) return 197 | const newMovingTo: MovingTo = { 198 | move: nextTile, 199 | tile: tile, 200 | } 201 | const makeMove: MakeMoveClient = { 202 | movingTo: newMovingTo, 203 | room: room, 204 | } 205 | socket.emit(`makeMove`, makeMove) 206 | } 207 | 208 | const { intensity } = useSpring({ 209 | intensity: selected ? 0.35 : 0, 210 | }) 211 | 212 | const { camera } = useThree() 213 | 214 | useEffect(() => { 215 | const interval = setInterval(() => { 216 | const { x, y, z } = camera.position 217 | socket?.emit(`cameraMove`, { 218 | position: [x, y, z], 219 | room: room, 220 | color: playerColor, 221 | } satisfies CameraMove) 222 | }, 1000) 223 | return () => clearInterval(interval) 224 | }, [camera.position, socket, room, playerColor]) 225 | 226 | return ( 227 | 228 | 234 | 241 | 242 | {/* @ts-ignore */} 243 | 248 | {board.map((row, i) => { 249 | return row.map((tile, j) => { 250 | const bg = `${(i + j) % 2 === 0 ? `white` : `black`}` 251 | const isSelected = 252 | tile.piece && selected?.getId() === tile.piece.getId() 253 | 254 | const canMoveHere = checkIfSelectedPieceCanMoveHere({ 255 | tile, 256 | moves, 257 | selected, 258 | }) 259 | 260 | const tileId = tile.piece?.getId() 261 | const pieceIsBeingReplaced = 262 | movingTo?.move.piece && tile.piece && movingTo?.move.capture 263 | ? tileId === createId(movingTo?.move.capture) 264 | : false 265 | const rookCastled = movingTo?.move.castling?.rook 266 | const isBeingCastled = 267 | rookCastled && createId(rookCastled) === tile.piece?.getId() 268 | 269 | const handleClick = (e: ThreeMouseEvent) => { 270 | if (movingTo) { 271 | return 272 | } 273 | 274 | const tileContainsOtherPlayersPiece = 275 | tile.piece && tile.piece?.color !== turn 276 | 277 | if (tileContainsOtherPlayersPiece && !canMoveHere && !isDev) { 278 | setSelected(null) 279 | return 280 | } 281 | 282 | canMoveHere 283 | ? startMovingPiece(e, tile, canMoveHere) 284 | : selectThisPiece(e, tile) 285 | } 286 | 287 | const props: ModelProps = { 288 | position: [j, 0.5, i], 289 | scale: [0.15, 0.15, 0.15], 290 | color: tile.piece?.color || `white`, 291 | onClick: handleClick, 292 | isSelected: isSelected ? true : false, 293 | wasSelected: lastSelected 294 | ? lastSelected?.piece?.getId() === tile.piece?.getId() 295 | : false, 296 | canMoveHere: canMoveHere?.newPosition ?? null, 297 | movingTo: 298 | checkIfPositionsMatch( 299 | tile.position, 300 | movingTo?.move.piece?.position, 301 | ) && movingTo 302 | ? movingTo.move.steps 303 | : isBeingCastled 304 | ? movingTo.move.castling?.rookSteps ?? null 305 | : null, 306 | pieceIsBeingReplaced: pieceIsBeingReplaced ? true : false, 307 | finishMovingPiece: () => 308 | isBeingCastled ? null : finishMovingPiece(movingTo?.tile ?? null), 309 | } 310 | 311 | const pieceId = tile.piece?.getId() ?? `empty-${j}-${i}` 312 | 313 | return ( 314 | 315 | 321 | 322 | {tile.piece?.type === `pawn` && } 323 | {tile.piece?.type === `rook` && } 324 | {tile.piece?.type === `knight` && } 325 | {tile.piece?.type === `bishop` && } 326 | {tile.piece?.type === `queen` && } 327 | {tile.piece?.type === `king` && } 328 | 329 | 330 | ) 331 | }) 332 | })} 333 | 334 | ) 335 | } 336 | --------------------------------------------------------------------------------