├── 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 | 
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------