From fa5cdf4e9c8889a33bcd78536f0155ba1c2f7a4a Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 4 Apr 2026 21:02:39 +0200 Subject: [PATCH] refactor(core): enhance GameEngine and GUI for undo/redo functionality and integrate FEN/PGN export/import --- .../de/nowchess/chess/engine/GameEngine.scala | 7 +- modules/ui/build.gradle.kts | 8 ++ .../de/nowchess/ui/gui/ChessBoardView.scala | 107 ++++++++++++++---- .../de/nowchess/ui/gui/GUIObserver.scala | 21 +++- .../de/nowchess/ui/terminal/TerminalUI.scala | 18 +++ 5 files changed, 135 insertions(+), 26 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 113b2d1..377e266 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 @@ -15,8 +15,8 @@ import de.nowchess.rules.sets.DefaultRules * All user interactions go through Commands; state changes are broadcast via GameEvents. */ class GameEngine( - initialContext: GameContext = GameContext.initial, - ruleSet: RuleSet = DefaultRules + val initialContext: GameContext = GameContext.initial, + val ruleSet: RuleSet = DefaultRules ) extends Observable: private var currentContext: GameContext = initialContext private val invoker = new CommandInvoker() @@ -59,7 +59,6 @@ class GameEngine( case "draw" => if currentContext.halfMoveClock >= 100 then - currentContext = GameContext.initial invoker.clear() notifyObservers(DrawClaimedEvent(currentContext)) else @@ -202,10 +201,8 @@ class GameEngine( if ruleSet.isCheckmate(currentContext) then val winner = currentContext.turn.opposite - currentContext = GameContext.initial notifyObservers(CheckmateEvent(currentContext, winner)) else if ruleSet.isStalemate(currentContext) then - currentContext = GameContext.initial notifyObservers(StalemateEvent(currentContext)) else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext)) diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts index a083675..b471506 100644 --- a/modules/ui/build.gradle.kts +++ b/modules/ui/build.gradle.kts @@ -1,3 +1,6 @@ +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.jvm.tasks.Jar + plugins { id("scala") id("org.scoverage") @@ -38,6 +41,10 @@ tasks.named("run") { standardInput = System.`in` } +tasks.named("jar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + dependencies { implementation("org.scala-lang:scala3-compiler_3") { @@ -54,6 +61,7 @@ dependencies { implementation(project(":modules:core")) implementation(project(":modules:rule")) implementation(project(":modules:api")) + implementation(project(":modules:io")) // ScalaFX dependencies implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}") 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 82ef8cf..ed3e757 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 @@ -1,5 +1,6 @@ package de.nowchess.ui.gui +import scala.compiletime.uninitialized import scalafx.Includes.* import scalafx.application.Platform import scalafx.geometry.{Insets, Pos} @@ -10,7 +11,9 @@ 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.{GameHistory, HistoryMove} import de.nowchess.api.move.PromotionPiece +import de.nowchess.chess.command.{MoveCommand, MoveResult} import de.nowchess.chess.engine.GameEngine /** ScalaFX chess board view that displays the game state. @@ -33,6 +36,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B private var selectedSquare: Option[Square] = None private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]() + private var undoButton: Button = uninitialized + private var redoButton: Button = uninitialized + // Initialize UI initializeBoard() @@ -66,15 +72,23 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B spacing = 10 alignment = Pos.Center children = Seq( - new Button("Undo") { - font = Font.font(comicSansFontFamily, 12) - onAction = _ => if engine.canUndo then engine.undo() - style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;" + { + undoButton = new Button("Undo") { + font = Font.font(comicSansFontFamily, 12) + onAction = _ => if engine.canUndo then engine.undo() + style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;" + disable = !engine.canUndo + } + undoButton }, - new Button("Redo") { - font = Font.font(comicSansFontFamily, 12) - onAction = _ => if engine.canRedo then engine.redo() - style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;" + { + redoButton = new Button("Redo") { + font = Font.font(comicSansFontFamily, 12) + onAction = _ => if engine.canRedo then engine.redo() + style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;" + disable = !engine.canRedo + } + redoButton }, new Button("Reset") { font = Font.font(comicSansFontFamily, 12) @@ -161,12 +175,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B if piece.color == currentTurn then selectedSquare = Some(clickedSquare) highlightSquare(rank, file, PieceSprites.SquareColors.Selected) - // TODO: Update legal move highlighting to use new RuleSet interface from modules/rule - // val legalDests = GameRules.legalMoves(currentBoard, engine.history, currentTurn) - // .collect { case (`clickedSquare`, to) => to } - // legalDests.foreach { sq => - // highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) - // } + + val legalDests = engine.ruleSet.legalMoves(engine.context, clickedSquare) + .collect { case move if move.from == clickedSquare => move.to } + legalDests.foreach { sq => + highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) + } } case Some(fromSquare) => @@ -214,7 +228,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B stackPane.children = children } - + + updateUndoRedoButtons() + + def updateUndoRedoButtons(): Unit = + if undoButton != null then undoButton.disable = !engine.canUndo + if redoButton != null then redoButton.disable = !engine.canRedo + private def highlightSquare(rank: Int, file: Int, color: String): Unit = squareViews.get((rank, file)).foreach { stackPane => val bgRect = new Rectangle { @@ -256,17 +276,33 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B case _ => engine.completePromotion(PromotionPiece.Queen) // Default private def doFenExport(): Unit = - // TODO: Update FEN export to use GameContext instead of GameHistory and calculator helpers - showMessage("FEN export temporarily disabled during NCS-22 refactoring") + val fen = de.nowchess.io.fen.FenExporter.gameContextToFen(engine.context) + showCopyDialog("FEN Export", fen) private def doFenImport(): Unit = - showMessage("FEN import temporarily disabled during NCS-22 refactoring") + showInputDialog("FEN Import", rows = 1).foreach { fen => + de.nowchess.io.fen.FenParser.parseFen(fen) match + case Some(gameContext) => + engine.loadPosition(gameContext) + case None => + showMessage("⚠️ Invalid FEN string") + } private def doPgnExport(): Unit = - showMessage("PGN export temporarily disabled during NCS-22 refactoring") + val pgn = de.nowchess.io.pgn.PgnExporter.exportGame( + Map("Event" -> "NowChess Game", "Date" -> "2026.04.04"), + exportableGameHistory() + ) + showCopyDialog("PGN Export", pgn) private def doPgnImport(): Unit = - showMessage("PGN import temporarily disabled during NCS-22 refactoring") + showInputDialog("PGN Import", rows = 5).foreach { pgn => + engine.loadPgn(pgn) match + case Right(_) => + showMessage("✓ PGN loaded successfully!") + case Left(err) => + showMessage(s"⚠️ PGN Error: $err") + } private def showCopyDialog(title: String, content: String): Unit = val area = new javafx.scene.control.TextArea(content) @@ -298,3 +334,34 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B dialog.initOwner(stage.delegate) val result = dialog.showAndWait() if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None + + private def exportableGameHistory(): GameHistory = + val moveCommands = engine.commandHistory.collect { case moveCmd: MoveCommand => moveCmd } + val activeMoveCount = engine.context.moves.length + val historyMoves = moveCommands.take(activeMoveCount).flatMap: moveCmd => + moveCmd.previousContext.flatMap: previousContext => + moveCmd.moveResult.collect: + case MoveResult.Successful(_, captured) => + val movingPiece = previousContext.board.pieceAt(moveCmd.from) + val pieceType = movingPiece.map(_.pieceType).getOrElse(PieceType.Pawn) + val castleSide = moveCmd.notation match + case "O-O" => Some("Kingside") + case "O-O-O" => Some("Queenside") + case _ => None + val promotionPiece = moveCmd.notation.split("=").lastOption.flatMap: + case "Q" => Some(PromotionPiece.Queen) + case "R" => Some(PromotionPiece.Rook) + case "B" => Some(PromotionPiece.Bishop) + case "N" => Some(PromotionPiece.Knight) + case _ => None + HistoryMove( + from = moveCmd.from, + to = moveCmd.to, + castleSide = castleSide, + promotionPiece = promotionPiece, + pieceType = pieceType, + isCapture = captured.isDefined + ) + + GameHistory(historyMoves.toList) + diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala index d370375..3dfa8ff 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala @@ -47,8 +47,27 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer: case e: DrawClaimedEvent => boardView.updateBoard(e.context.board, e.context.turn) showAlert(AlertType.Information, "Draw Claimed", "Draw claimed! The game is a draw.") - case e: FiftyMoveRuleAvailableEvent => + + case e: FiftyMoveRuleAvailableEvent => boardView.showMessage("50-move rule available! The game is a draw.") + + case e: MoveUndoneEvent => + boardView.updateBoard(e.context.board, e.context.turn) + boardView.showMessage(s"↶ Undo: ${e.pgnNotation}") + boardView.updateUndoRedoButtons() + + case e: MoveRedoneEvent => + boardView.updateBoard(e.context.board, e.context.turn) + if e.capturedPiece.isDefined then + boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}") + else + boardView.showMessage(s"↷ Redo: ${e.pgnNotation}") + boardView.updateUndoRedoButtons() + + case e: PgnLoadedEvent => + boardView.updateBoard(e.context.board, e.context.turn) + boardView.showMessage("✓ PGN loaded successfully!") + boardView.updateUndoRedoButtons() } private def showAlert(alertType: AlertType, titleText: String, content: String): Unit = diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala index baf8f8f..b23a436 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -24,6 +24,18 @@ class TerminalUI(engine: GameEngine) extends Observer: println(s"Captured: $cap on ${e.toSquare}") printPrompt(e.context.turn) + case e: MoveUndoneEvent => + println(s"Undo: ${e.pgnNotation}") + println() + print(Renderer.render(e.context.board)) + printPrompt(e.context.turn) + + case e: MoveRedoneEvent => + println(s"Redo: ${e.pgnNotation}") + println() + print(Renderer.render(e.context.board)) + printPrompt(e.context.turn) + case e: CheckDetectedEvent => println(s"${e.context.turn.label} is in check!") @@ -57,6 +69,12 @@ class TerminalUI(engine: GameEngine) extends Observer: case _: FiftyMoveRuleAvailableEvent => println("50-move rule available! The game is a draw.") + case e: PgnLoadedEvent => + println("PGN loaded successfully.") + println() + print(Renderer.render(e.context.board)) + printPrompt(e.context.turn) + /** Start the terminal UI game loop. */ def start(): Unit = // Register as observer