diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a86698e..6f6f3b9 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -31,5 +31,7 @@ module.exports = { semi: true, }, ], + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'vue/require-explicit-emits': ['off'], }, }; diff --git a/docs/.vitepress/index.css b/docs/.vitepress/index.css index 73d08d7..11b5187 100644 --- a/docs/.vitepress/index.css +++ b/docs/.vitepress/index.css @@ -19,6 +19,10 @@ max-width: 600px; } +coords.files { + bottom: 5px !important; +} + .move-container { min-height: 250px; } @@ -61,3 +65,17 @@ h1 { background-position: center; background-repeat: no-repeat; } + +.buttons { + padding: 5px; + text-align: center; +} +.buttons .activated { + background-color: #888; +} +.buttons button { + background-color: #555; + border-radius: 5px; + margin: 5px; + padding: 5px; +} \ No newline at end of file diff --git a/docs/board-api.md b/docs/board-api.md index b742f47..4702ea3 100644 --- a/docs/board-api.md +++ b/docs/board-api.md @@ -48,7 +48,7 @@ Here is a list of all the available methods: ```ts /** - * Resets the board to the initial starting position. + * Resets the board to the initial starting configuration. */ resetBoard(): void; @@ -95,10 +95,12 @@ getOpeningName(): Promise; /** * make a move programmatically on the board - * @param move the san move to make like 'e4', 'O-O' or 'e8=Q' + * @param move either a string in Standard Algebraic Notation (SAN), eg. 'e4', 'exd5', 'O-O', 'Nf3' or 'e8=Q' + * or an object of shape { from: string; to: string; promotion?: string; }, eg. { from: 'g8', to: 'f6' } or + * { from: 'e7', to: 'e8', promotion: 'q'} * @returns true if the move was made, false if the move was illegal */ -move(move: string): boolean; +move(move: string | { from: Key; to: Key; promotion?: Promotion; }): boolean; /** * returns the current turn color @@ -192,7 +194,8 @@ getSquareColor(square: string): SquareColor | null; getSquare(square: Square): Piece | null; /** - * Returns the piece on the square or null if there is no piece + * loads a fen into the board + * Caution: this will erase the game history. To set position with history call loadPgn with a pgn instead */ setPosition(fen: string): void; @@ -236,6 +239,19 @@ loadPgn(pgn: string): void; getPgnInfo(): { [key: string]: string | undefined; }; + +/** + * Sets the config of the board. + * Caution: providing a config with a fen will erase the game history and change the starting position + * for resetBoard. To keep history and starting position: omit fen from the given config and call + * loadPgn with a pgn instead. + * + * @param config - a subset of config options, eg. `{ viewOnly: true, animation: { enabled: false } }` + * or `{ movable: { events: { after: afterFunc }, showDests: false }, drawable: { enabled: false } }` + * @param fillDefaults - if true unprovided config options will be substituted with default values, if + * false the unprovided options will remain unchanged. + */ +setConfig(config: BoardConfig, fillDefaults = false): void { ``` ## Example Board API Usage diff --git a/docs/board-props.md b/docs/board-props.md index 9c59e61..5e2789a 100644 --- a/docs/board-props.md +++ b/docs/board-props.md @@ -1,5 +1,6 @@ # Board props -## Configure the chessboard +Available props: + - `board-config` + - `player-color` + - `reactive-config` + +## `board-config`: Configure the chessboard To edit the chessboard you can pass a configuration object to the component. Here are all the available options: @@ -150,7 +162,7 @@ max-width: 90%; } -## Configure the board for multiplayer +## `player-color`: Configure the board for multiplayer The board can accept a player-color prop to denote the color that the corresponding client should be allowed to play. Moves from a players opponent can be applied to the board with the `BoardApi`'s `move` method - the turns will switch once the `move` method is called with a valid sen string. If no value is provided, turns will switch locally. @@ -179,4 +191,156 @@ function onRecieveMove(move: string) { :player-color="playerColor" /> -``` \ No newline at end of file +``` + + +## `reactive-config`: Using a reactive config object + +The `TheChessboard` component can accept a `reactive-config` prop to allow the `board-config` prop to be reactive to changes. Any mutations of the `board-config` prop will propagate to changes of the board config. This works with nested properties in a non-destructive way, ie. setting `boardConfig.draggable = { distance: 10, showGhost: false }` won't affect the other properties of `draggable` on the actual board config, such as `draggable.enabled`. However, it will change the "default state" of the board so that if `boardAPI.resetBoard()` is called the current state of the `board-config` prop is considered to be the provided config, as if it was passed at the time of instantiation of the `TheChessboard` component. + +Note that prop mutation is a *one-way flow of data*, so the state of the `board-config` prop won't necessarily reflect the state of the actual board config. For example, as the game progresses the `fen` property of the `board-config` prop **will not** update. + +See the following example for how one might make use of this feature: + +::: code-group + +```vue [TypeScript] + + + + + +``` + +```vue [JavaScript] + + + + + +``` + +::: + +The board should then look like this: + +
+ +
+ + + + +
+
\ No newline at end of file diff --git a/src/classes/BoardApi.ts b/src/classes/BoardApi.ts index e586565..4492f35 100644 --- a/src/classes/BoardApi.ts +++ b/src/classes/BoardApi.ts @@ -1,10 +1,9 @@ -import type { +import { Chess, - Move, - Piece, - PieceSymbol, - Square, - Color as ShortColor, + type Piece, + type PieceSymbol, + type Square, + type Color as ShortColor, } from 'chess.js'; import type { Api } from 'chessground/api'; import type { @@ -17,16 +16,23 @@ import { getThreats, shortToLongColor, possibleMoves, - roleAbbrToRole, + deepMergeConfig, + isPromotion, } from '@/helper/Board'; -import { emitBoardEvents } from '@/helper/EmitEvents'; import type { - Emit, + Move, + MoveEvent, + Props, + Emits, BoardState, PromotedTo, SquareColor, + Promotion, } from '@/typings/Chessboard'; -import type { Color, Key } from 'chessground/types'; +import type { Color, Key, MoveMetadata } from 'chessground/types'; +import type BoardConfig from '@/typings/BoardConfig'; +import { defaultBoardConfig } from '@/helper/DefaultConfig'; +import { Chessground } from 'chessground/chessground'; /** * class for modifying and reading data from the board, \ @@ -38,26 +44,126 @@ export class BoardApi { private game: Chess; private board: Api; private boardState: BoardState; - private emit: Emit; - constructor(game: Chess, board: Api, boardState: BoardState, emit: Emit) { - this.game = game; - this.board = board; + private props: Props; + private emit: Emits; + constructor( + boardElement: HTMLElement, + boardState: BoardState, + props: Props, + emit: Emits + ) { this.boardState = boardState; + this.props = props; this.emit = emit; + this.game = new Chess(); + this.board = Chessground(boardElement); + this.resetBoard(); } + // + // PRIVATE INTERAL METHODS: + // + /** - * Resets the board to the initial starting position. + * syncs chess.js state with the board + * @private */ - resetBoard(): void { - this.game.reset(); - this.board.redrawAll(); - this.board.set(this.boardState.boardConfig); - this.board.state.check = undefined; - this.board.selectSquare(null); + private updateGameState(): void { + this.board.set({ fen: this.game.fen() }); + this.board.state.turnColor = this.getTurnColor(); + this.board.state.movable.color = + this.props.playerColor || this.board.state.turnColor; + this.board.state.movable.dests = possibleMoves(this.game); + if (this.boardState.showThreats) { - this.board.setShapes(getThreats(this.game.moves({ verbose: true }))); + this.drawMoves(); } + + this.emitEvents(); + } + + /** + * emits neccessary events + * @private + */ + private emitEvents(): void { + const lastMove = this.getLastMove(); + if (lastMove) { + this.emit('move', lastMove); + } + + if (lastMove?.promotion) { + this.emit('promotion', { + color: shortToLongColor(lastMove.color), + promotedTo: lastMove.promotion.toUpperCase() as PromotedTo, + sanMove: lastMove.san, + }); + } + + if (this.game.inCheck()) { + for (const [key, piece] of this.board.state.pieces) { + if ( + piece.role === 'king' && + piece.color === this.board?.state.turnColor + ) { + this.board.state.check = key; + this.emit( + this.game.isCheckmate() ? 'checkmate' : 'check', + this.board.state.turnColor + ); + break; + } + } + } else { + this.board.state.check = undefined; + } + + if (this.game.isDraw()) { + this.emit('draw'); + } + + if (this.game.isStalemate()) { + this.emit('stalemate'); + } + } + + /** + * Changes the turn of the game, triggered by config.movable.events.after + * @private + */ + private async changeTurn( + orig: Key, + dest: Key, + _metadata: MoveMetadata + ): Promise { + let selectedPromotion: Promotion | undefined = undefined; + + if (isPromotion(dest, this.game.get(orig as Square))) { + selectedPromotion = await new Promise((resolve) => { + this.boardState.promotionDialogState = { + isEnabled: true, + color: this.getTurnColor(), + callback: resolve, + }; + }); + } + + this.move({ + from: orig, + to: dest, + promotion: selectedPromotion, + }); + } + + // + // PUBLIC API METHODS: + // + + /** + * Resets the board to the initial starting configuration. + */ + resetBoard(): void { + this.setConfig(this.props.boardConfig as BoardConfig, true); } /** @@ -66,25 +172,13 @@ export class BoardApi { undoLastMove(): void { const undoMove = this.game.undo(); if (undoMove == null) return; - const lastMove = this.game.history({ verbose: true }).at(-1); - - this.board.set({ fen: this.game.fen() }); - this.board.state.turnColor = shortToLongColor(this.game.turn()); - this.board.state.movable.color = - this.boardState.playerColor || this.board.state.turnColor; - this.board.state.movable.dests = possibleMoves(this.game); - this.board.state.check = undefined; - - if (this.game.history().length === 0 || typeof lastMove === 'undefined') { - this.board.state.lastMove = undefined; - } else { + this.updateGameState(); + const lastMove = this.getLastMove(); + if (lastMove) { this.board.state.lastMove = [lastMove?.from, lastMove?.to]; - } - - if (this.boardState.showThreats) { - // redraw threats in new position if enabled - this.board.setShapes(getThreats(this.game.moves({ verbose: true }))); + } else { + this.board.state.lastMove = undefined; } } @@ -162,11 +256,10 @@ export class BoardApi { * toggle drawing of arrows and circles on the board for possible moves/captures */ toggleMoves(): void { - this.boardState.showThreats = !this.boardState.showThreats; if (this.boardState.showThreats) { - this.board.setShapes(getThreats(this.game.moves({ verbose: true }))); + this.hideMoves(); } else { - this.board.setShapes([]); + this.drawMoves(); } } @@ -192,59 +285,24 @@ export class BoardApi { /** * make a move programmatically on the board - * @param move the san move to make like 'e4', 'exd5', 'O-O', 'Nf3' or 'e8=Q' + * @param move either a string in Standard Algebraic Notation (SAN), eg. 'e4', 'exd5', 'O-O', 'Nf3' or 'e8=Q' + * or an object of shape { from: string; to: string; promotion?: string; }, eg. { from: 'g8', to: 'f6' } or + * { from: 'e7', to: 'e8', promotion: 'q'} * @returns true if the move was made, false if the move was illegal */ - move(move: string): boolean { - let m: Move; + move(move: Move): boolean { + let moveEvent: MoveEvent; + + // TODO: handle exception based on boardConfig.movable.free try { - m = this.game.move(move); + moveEvent = this.game.move(move); } catch { return false; } - // check for castle - if (move === 'O-O-O' || move === 'O-O') { - const currentRow = m.to[1]; - if (move === 'O-O-O') { - this.board.move(`a${currentRow}` as Key, `d${currentRow}` as Key); - } else { - this.board.move(`h${currentRow}` as Key, `f${currentRow}` as Key); - } - } - - // check for promotion move - if (m.promotion) { - this.board.state.pieces.set(m.to, { - color: shortToLongColor(m.color), - role: roleAbbrToRole(m.promotion), - promoted: true, - }); - this.board.state.pieces.delete(m.from); - this.board.redrawAll(); - const promotedTo = m.promotion.toUpperCase() as PromotedTo; - this.emit('promotion', { - color: this.board?.state.turnColor === 'white' ? 'black' : 'white', - promotedTo: promotedTo, - sanMove: m.san, - }); - } else { - this.board.move(m.from, m.to); - } - - this.board.set({ fen: this.game.fen() }); - this.board.state.movable.dests = possibleMoves(this.game); - this.board.state.turnColor = shortToLongColor(this.game.turn()); - this.board.state.movable.color = - this.boardState.playerColor || this.board.state.turnColor; - this.board.state.lastMove = [m.from, m.to]; - - if (this.boardState.showThreats) { - // redraw threats in new position if enabled - this.board.setShapes(getThreats(this.game.moves({ verbose: true }))); - } - emitBoardEvents(this.game, this.board, this.emit); - + this.board.move(moveEvent.from, moveEvent.to); + this.updateGameState(); + this.board.playPremove(); return true; } @@ -253,7 +311,7 @@ export class BoardApi { * @returns 'white' or 'black' */ getTurnColor(): Color { - return this.board.state.turnColor; + return shortToLongColor(this.game.turn()); } /** @@ -276,7 +334,7 @@ export class BoardApi { /** * returns the latest move made on the board */ - getLastMove(): Move | undefined { + getLastMove(): MoveEvent | undefined { return this.game.history({ verbose: true }).at(-1); } @@ -284,8 +342,9 @@ export class BoardApi { * Retrieves the move history. * * @param verbose - passing true will add more info + * @example Verbose: [{"color": "w", "from": "e2", "to": "e4", "flags": "b", "piece": "p", "san": "e4"}], without verbose flag: [ "e7", "e5" ] */ - getHistory(verbose = false): Move[] | string[] { + getHistory(verbose = false): MoveEvent[] | string[] { return this.game.history({ verbose: verbose }); } @@ -376,6 +435,7 @@ export class BoardApi { /** * loads a fen into the board + * Caution: this will erase the game history. To set position with history call loadPgn with a pgn instead */ setPosition(fen: string): void { this.game.load(fen); @@ -415,19 +475,6 @@ export class BoardApi { this.updateGameState(); } - /** - * syncs chess.js state with the board - * @private - */ - private updateGameState(): void { - this.board.set({ fen: this.game.fen() }); - this.board.state.turnColor = shortToLongColor(this.game.turn()); - this.board.state.movable.color = - this.boardState.playerColor || this.board.state.turnColor; - this.board.state.movable.dests = possibleMoves(this.game); - emitBoardEvents(this.game, this.board, this.emit); - } - /** * returns the header information of the current pgn, if no pgn is loaded, returns an empty object * @example { @@ -447,6 +494,41 @@ export class BoardApi { } { return this.game.header(); } + + /** + * Sets the config of the board. + * Caution: providing a config with a fen will erase the game history and change the starting position + * for resetBoard. To keep history and starting position: omit fen from the given config and call + * loadPgn with a pgn instead. + * + * @param config - a subset of config options, eg. `{ viewOnly: true, animation: { enabled: false } }` + * or `{ movable: { events: { after: afterFunc }, showDests: false }, drawable: { enabled: false } }` + * @param fillDefaults - if true unprovided config options will be substituted with default values, if + * false the unprovided options will remain unchanged. + */ + setConfig(config: BoardConfig, fillDefaults = false): void { + if (fillDefaults) { + config = deepMergeConfig(defaultBoardConfig, config); + } + + // If user provided a movable.events.after function we patch changeTurn to run before it. We want + // changeTurn to run before the user's function rather than after it so that during their function + // call the API can provide correct data about the game, eg. getLastMove() for the san. + if (config.movable?.events && 'after' in config.movable.events) { + const userAfter = config.movable.events.after; + config.movable.events.after = userAfter + ? async (...args): Promise => { + await this.changeTurn(...args); + userAfter(...args); + } + : this.changeTurn; // in case user provided config with { movable: { event: { after: undefined } } } + } + + const { fen, ...configWithoutFen } = config; + this.board.set(configWithoutFen); + if (fen) this.setPosition(fen); + this.board.redrawAll(); + } } export default BoardApi; diff --git a/src/components/PromotionDialog.vue b/src/components/PromotionDialog.vue index 37ecb9f..9a19b27 100644 --- a/src/components/PromotionDialog.vue +++ b/src/components/PromotionDialog.vue @@ -1,62 +1,41 @@ diff --git a/src/components/TheChessboard.vue b/src/components/TheChessboard.vue index 405805b..28878bd 100644 --- a/src/components/TheChessboard.vue +++ b/src/components/TheChessboard.vue @@ -1,62 +1,21 @@