From bf0b9862fcec6daae2fe398e506b0f4b3b449826 Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 1 Apr 2026 10:41:12 +0200 Subject: [PATCH] feat: highlight legal moves on chess board interaction --- .../de/nowchess/chess/engine/GameEngine.scala | 10 ++ .../de/nowchess/ui/gui/ChessBoardView.scala | 149 ++++++++++++++++-- .../scala/de/nowchess/ui/gui/ChessGUI.scala | 2 +- 3 files changed, 144 insertions(+), 17 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 3d43fb8..b0ea251 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -212,6 +212,16 @@ class GameEngine( notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion.")) } + /** Load an arbitrary board position, clearing all history and undo/redo state. */ + def loadPosition(board: Board, history: GameHistory, turn: Color): Unit = synchronized { + currentBoard = board + currentHistory = history + currentTurn = turn + pendingPromotion = None + invoker.clear() + notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn)) + } + /** Reset the board to initial position. */ def reset(): Unit = synchronized { currentBoard = Board.initial diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala index 02d22a4..b8d162a 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala @@ -10,9 +10,11 @@ import scalafx.scene.shape.Rectangle import scalafx.scene.text.{Font, Text} import scalafx.stage.Stage import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square, File, Rank} +import de.nowchess.api.game.{CastlingRights, GameState, GameStatus} import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.engine.GameEngine -import de.nowchess.chess.logic.GameRules +import de.nowchess.chess.logic.{CastlingRightsCalculator, EnPassantCalculator, GameHistory, GameRules, withCastle} +import de.nowchess.chess.notation.{FenExporter, FenParser, PgnExporter, PgnParser} /** ScalaFX chess board view that displays the game state. * Uses chess sprites and color palette. @@ -57,25 +59,57 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B children = boardGrid } - bottom = new HBox { + bottom = new VBox { padding = Insets(10) - spacing = 10 + spacing = 8 alignment = Pos.Center children = Seq( - new Button("Undo") { - font = Font.font("Comic Sans MS", 12) - onAction = _ => if engine.canUndo then engine.undo() - style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;" + new HBox { + spacing = 10 + alignment = Pos.Center + children = Seq( + new Button("Undo") { + font = Font.font("Comic Sans MS", 12) + onAction = _ => if engine.canUndo then engine.undo() + style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;" + }, + new Button("Redo") { + font = Font.font("Comic Sans MS", 12) + onAction = _ => if engine.canRedo then engine.redo() + style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;" + }, + new Button("Reset") { + font = Font.font("Comic Sans MS", 12) + onAction = _ => engine.reset() + style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;" + } + ) }, - new Button("Redo") { - font = Font.font("Comic Sans MS", 12) - onAction = _ => if engine.canRedo then engine.redo() - style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;" - }, - new Button("Reset") { - font = Font.font("Comic Sans MS", 12) - onAction = _ => engine.reset() - style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;" + new HBox { + spacing = 10 + alignment = Pos.Center + children = Seq( + new Button("FEN Export") { + font = Font.font("Comic Sans MS", 12) + onAction = _ => doFenExport() + style = "-fx-background-radius: 8; -fx-background-color: #DAC4B9;" + }, + new Button("FEN Import") { + font = Font.font("Comic Sans MS", 12) + onAction = _ => doFenImport() + style = "-fx-background-radius: 8; -fx-background-color: #DAD4B9;" + }, + new Button("PGN Export") { + font = Font.font("Comic Sans MS", 12) + onAction = _ => doPgnExport() + style = "-fx-background-radius: 8; -fx-background-color: #C4DAB9;" + }, + new Button("PGN Import") { + font = Font.font("Comic Sans MS", 12) + onAction = _ => doPgnImport() + style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;" + } + ) } ) } @@ -221,3 +255,86 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop) case Some("Knight") => engine.completePromotion(PromotionPiece.Knight) case _ => engine.completePromotion(PromotionPiece.Queen) // Default + + private def doFenExport(): Unit = + val state = GameState( + piecePlacement = FenExporter.boardToFen(currentBoard), + activeColor = currentTurn, + castlingWhite = CastlingRightsCalculator.deriveCastlingRights(engine.history, Color.White), + castlingBlack = CastlingRightsCalculator.deriveCastlingRights(engine.history, Color.Black), + enPassantTarget = EnPassantCalculator.enPassantTarget(currentBoard, engine.history), + halfMoveClock = 0, + fullMoveNumber = engine.history.moves.size / 2 + 1, + status = GameStatus.InProgress + ) + showCopyDialog("FEN Export", FenExporter.gameStateToFen(state)) + + private def doFenImport(): Unit = + showInputDialog("FEN Import", rows = 1).foreach { fen => + FenParser.parseFen(fen) match + case None => showMessage("Invalid FEN") + case Some(state) => + FenParser.parseBoard(state.piecePlacement) match + case None => showMessage("Invalid FEN board") + case Some(board) => engine.loadPosition(board, GameHistory.empty, state.activeColor) + } + + private def doPgnExport(): Unit = + showCopyDialog("PGN Export", PgnExporter.exportGame(Map.empty, engine.history)) + + private def doPgnImport(): Unit = + showInputDialog("PGN Import", rows = 6).foreach { pgn => + PgnParser.parsePgn(pgn) match + case None => showMessage("Invalid PGN") + case Some(pgnGame) => + val (finalBoard, finalHistory) = pgnGame.moves.foldLeft((Board.initial, GameHistory.empty)): + case ((board, history), move) => + val color = if history.moves.size % 2 == 0 then Color.White else Color.Black + val newBoard = move.castleSide match + case Some(side) => board.withCastle(color, side) + case None => + val (b, _) = board.withMove(move.from, move.to) + move.promotionPiece match + case Some(pp) => + val pt = pp match + case PromotionPiece.Queen => PieceType.Queen + case PromotionPiece.Rook => PieceType.Rook + case PromotionPiece.Bishop => PieceType.Bishop + case PromotionPiece.Knight => PieceType.Knight + b.updated(move.to, Piece(color, pt)) + case None => b + (newBoard, history.addMove(move)) + val finalTurn = if finalHistory.moves.size % 2 == 0 then Color.White else Color.Black + engine.loadPosition(finalBoard, finalHistory, finalTurn) + } + + private def showCopyDialog(title: String, content: String): Unit = + val area = new javafx.scene.control.TextArea(content) + area.setEditable(false) + area.setWrapText(true) + area.setPrefRowCount(4) + val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION) + alert.setTitle(title) + alert.setHeaderText(null) + alert.getDialogPane.setContent(area) + alert.getDialogPane.setPrefWidth(500) + alert.initOwner(stage.delegate) + alert.showAndWait() + + private def showInputDialog(title: String, rows: Int = 2): Option[String] = + val area = new javafx.scene.control.TextArea() + area.setWrapText(true) + area.setPrefRowCount(rows) + val dialog = new javafx.scene.control.Dialog[String]() + dialog.setTitle(title) + dialog.getDialogPane.setContent(area) + dialog.getDialogPane.getButtonTypes.addAll( + javafx.scene.control.ButtonType.OK, + javafx.scene.control.ButtonType.CANCEL + ) + dialog.setResultConverter { bt => + if bt == javafx.scene.control.ButtonType.OK then area.getText else null + } + dialog.initOwner(stage.delegate) + val result = dialog.showAndWait() + if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala index 815f952..857c1a0 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala @@ -19,7 +19,7 @@ class ChessGUIApp extends JFXApplication: stage.title = "Chess" stage.width = 700 - stage.height = 800 + stage.height = 1000 stage.resizable = false val boardView = new ChessBoardView(stage, engine)