feat: highlight legal moves on chess board interaction

This commit is contained in:
2026-04-01 10:41:12 +02:00
parent 1811f74aa7
commit bf0b9862fc
3 changed files with 144 additions and 17 deletions
@@ -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
@@ -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
@@ -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)