refactor(core): enhance GameEngine and GUI for undo/redo functionality and integrate FEN/PGN export/import
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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<JavaExec>("run") {
|
||||
standardInput = System.`in`
|
||||
}
|
||||
|
||||
tasks.named<Jar>("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"]!!}")
|
||||
|
||||
@@ -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) =>
|
||||
@@ -215,6 +229,12 @@ 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)
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user