feat: NCS-14 implemented insufficient moves rule (#30)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #30 Co-authored-by: Janis <janis.e.20@gmx.de> Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #30.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import scalafx.Includes.*
|
||||
import scalafx.application.Platform
|
||||
import scalafx.geometry.{Insets, Pos}
|
||||
@@ -36,13 +36,23 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
padding = Insets(10)
|
||||
}
|
||||
|
||||
private var currentBoard: Board = engine.board
|
||||
private var currentTurn: Color = engine.turn
|
||||
private var selectedSquare: Option[Square] = None
|
||||
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
|
||||
private val currentBoard = new AtomicReference[Board](engine.board)
|
||||
private val currentTurn = new AtomicReference[Color](engine.turn)
|
||||
private val selectedSquare = new AtomicReference[Option[Square]](None)
|
||||
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
|
||||
|
||||
private var undoButton: Button = uninitialized
|
||||
private var redoButton: Button = uninitialized
|
||||
private val undoButton: Button = 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
|
||||
}
|
||||
private val redoButton: Button = 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
|
||||
}
|
||||
|
||||
// Initialize UI
|
||||
initializeBoard()
|
||||
@@ -77,23 +87,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
spacing = 10
|
||||
alignment = Pos.Center
|
||||
children = Seq(
|
||||
{
|
||||
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
|
||||
}, {
|
||||
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
|
||||
},
|
||||
undoButton,
|
||||
redoButton,
|
||||
new Button("Reset") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => engine.reset()
|
||||
@@ -160,7 +155,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
squareViews((rank, file)) = square
|
||||
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
|
||||
|
||||
updateBoard(currentBoard, currentTurn)
|
||||
updateBoard(currentBoard.get(), currentTurn.get())
|
||||
|
||||
private def createSquare(rank: Int, file: Int): StackPane =
|
||||
val isWhite = (rank + file) % 2 == 0
|
||||
@@ -183,42 +178,41 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
square
|
||||
|
||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||
if engine.isPendingPromotion then return // Don't allow moves during promotion
|
||||
if !engine.isPendingPromotion then
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
selectedSquare.get() match
|
||||
case None =>
|
||||
// First click - select piece if it belongs to current player
|
||||
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
|
||||
if piece.color == currentTurn.get() then
|
||||
selectedSquare.set(Some(clickedSquare))
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
|
||||
selectedSquare match
|
||||
case None =>
|
||||
// First click - select piece if it belongs to current player
|
||||
currentBoard.pieceAt(clickedSquare).foreach { piece =>
|
||||
if piece.color == currentTurn then
|
||||
selectedSquare = Some(clickedSquare)
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) =>
|
||||
// Second click - attempt move
|
||||
if clickedSquare == fromSquare then
|
||||
// Deselect
|
||||
selectedSquare = None
|
||||
updateBoard(currentBoard, currentTurn)
|
||||
else
|
||||
// Try to move
|
||||
val moveStr = s"${fromSquare}$clickedSquare"
|
||||
engine.processUserInput(moveStr)
|
||||
selectedSquare = None
|
||||
case Some(fromSquare) =>
|
||||
// Second click - attempt move
|
||||
if clickedSquare == fromSquare then
|
||||
// Deselect
|
||||
selectedSquare.set(None)
|
||||
updateBoard(currentBoard.get(), currentTurn.get())
|
||||
else
|
||||
// Try to move
|
||||
val moveStr = s"${fromSquare}$clickedSquare"
|
||||
engine.processUserInput(moveStr)
|
||||
selectedSquare.set(None)
|
||||
|
||||
def updateBoard(board: Board, turn: Color): Unit =
|
||||
currentBoard = board
|
||||
currentTurn = turn
|
||||
selectedSquare = None
|
||||
currentBoard.set(board)
|
||||
currentTurn.set(turn)
|
||||
selectedSquare.set(None)
|
||||
|
||||
// Update all squares
|
||||
for
|
||||
@@ -240,9 +234,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
val pieceOption = board.pieceAt(square)
|
||||
|
||||
val children = pieceOption match
|
||||
val children: Seq[scalafx.scene.Node] = pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
|
||||
@@ -252,8 +246,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
updateUndoRedoButtons()
|
||||
|
||||
def updateUndoRedoButtons(): Unit =
|
||||
if undoButton != null then undoButton.disable = !engine.canUndo
|
||||
if redoButton != null then redoButton.disable = !engine.canRedo
|
||||
undoButton.disable = !engine.canUndo
|
||||
redoButton.disable = !engine.canRedo
|
||||
|
||||
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
|
||||
squareViews.get((rank, file)).foreach { stackPane =>
|
||||
@@ -266,13 +260,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
}
|
||||
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
val pieceOption = currentBoard.pieceAt(square)
|
||||
val pieceOption = currentBoard.get().pieceAt(square)
|
||||
|
||||
stackPane.children = pieceOption match
|
||||
stackPane.children = (pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
Seq(bgRect)): Seq[scalafx.scene.Node]
|
||||
}
|
||||
|
||||
def showMessage(msg: String): Unit =
|
||||
@@ -315,8 +309,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||
}
|
||||
|
||||
val selectedFile = fileChooser.showSaveDialog(stage)
|
||||
if selectedFile != null then
|
||||
Option(fileChooser.showSaveDialog(stage)).foreach { selectedFile =>
|
||||
val result = FileSystemGameService.saveGameToFile(
|
||||
engine.context,
|
||||
selectedFile.toPath,
|
||||
@@ -325,6 +318,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
result match
|
||||
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
|
||||
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
|
||||
}
|
||||
|
||||
private def doJsonImport(): Unit =
|
||||
val fileChooser = new FileChooser {
|
||||
@@ -333,8 +327,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||
}
|
||||
|
||||
val selectedFile = fileChooser.showOpenDialog(stage)
|
||||
if selectedFile != null then
|
||||
Option(fileChooser.showOpenDialog(stage)).foreach { selectedFile =>
|
||||
val result = FileSystemGameService.loadGameFromFile(
|
||||
selectedFile.toPath,
|
||||
JsonParser,
|
||||
@@ -345,6 +338,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
|
||||
case Left(err) =>
|
||||
showMessage(s"⚠️ Error: $err")
|
||||
}
|
||||
|
||||
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
|
||||
val exported = exporter.exportGameContext(engine.context)
|
||||
@@ -368,7 +362,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
area.setPrefRowCount(4)
|
||||
val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION)
|
||||
alert.setTitle(title)
|
||||
alert.setHeaderText(null)
|
||||
alert.setHeaderText("")
|
||||
alert.getDialogPane.setContent(area)
|
||||
alert.getDialogPane.setPrefWidth(500)
|
||||
alert.initOwner(stage.delegate)
|
||||
@@ -386,8 +380,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
javafx.scene.control.ButtonType.CANCEL,
|
||||
)
|
||||
dialog.setResultConverter { bt =>
|
||||
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
|
||||
if bt == javafx.scene.control.ButtonType.OK then area.getText else ""
|
||||
}
|
||||
dialog.initOwner(stage.delegate)
|
||||
val result = dialog.showAndWait()
|
||||
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
||||
if result.isPresent && result.get.nonEmpty then Some(result.get) else None
|
||||
|
||||
@@ -31,8 +31,7 @@ class ChessGUIApp extends JFXApplication:
|
||||
root = boardView
|
||||
// Load CSS if available
|
||||
try {
|
||||
val cssUrl = getClass.getResource("/styles.css")
|
||||
if cssUrl != null then stylesheets.add(cssUrl.toExternalForm)
|
||||
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
|
||||
} catch {
|
||||
case _: Exception => // CSS is optional
|
||||
}
|
||||
@@ -46,12 +45,12 @@ class ChessGUIApp extends JFXApplication:
|
||||
|
||||
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
|
||||
object ChessGUILauncher:
|
||||
@volatile private var engine: GameEngine = scala.compiletime.uninitialized
|
||||
private val engineRef = new java.util.concurrent.atomic.AtomicReference[GameEngine]()
|
||||
|
||||
def getEngine: GameEngine = engine
|
||||
def getEngine: GameEngine = engineRef.get()
|
||||
|
||||
def launch(eng: GameEngine): Unit =
|
||||
engine = eng
|
||||
engineRef.set(eng)
|
||||
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
|
||||
guiThread.setDaemon(false)
|
||||
guiThread.setName("ScalaFX-GUI-Thread")
|
||||
|
||||
@@ -5,6 +5,7 @@ import scalafx.scene.control.Alert
|
||||
import scalafx.scene.control.Alert.AlertType
|
||||
import de.nowchess.chess.observer.{GameEvent, Observer, *}
|
||||
import de.nowchess.api.board.Board
|
||||
import de.nowchess.api.game.DrawReason
|
||||
|
||||
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
|
||||
* All UI updates must be done on the JavaFX Application Thread.
|
||||
@@ -29,9 +30,14 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
|
||||
|
||||
case e: StalemateEvent =>
|
||||
case e: DrawEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Game Over", "Stalemate! The game is a draw.")
|
||||
val msg = e.reason match
|
||||
case DrawReason.Stalemate => "Stalemate! The game is a draw."
|
||||
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
|
||||
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
|
||||
case DrawReason.Agreement => "Draw by agreement."
|
||||
showAlert(AlertType.Information, "Game Over", msg)
|
||||
|
||||
case e: InvalidMoveEvent =>
|
||||
boardView.showMessage(s"⚠️ ${e.reason}")
|
||||
@@ -43,12 +49,8 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
case e: PromotionRequiredEvent =>
|
||||
boardView.showPromotionDialog(e.from, e.to)
|
||||
|
||||
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.")
|
||||
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
|
||||
|
||||
case e: MoveUndoneEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
|
||||
@@ -6,26 +6,24 @@ import de.nowchess.api.board.{Color, Piece, PieceType}
|
||||
/** Utility object for loading chess piece sprites. */
|
||||
object PieceSprites:
|
||||
|
||||
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
||||
private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]()
|
||||
|
||||
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
||||
*/
|
||||
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
|
||||
|
||||
new ImageView(image) {
|
||||
fitWidth = size
|
||||
fitHeight = size
|
||||
preserveRatio = true
|
||||
smooth = true
|
||||
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] =
|
||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
|
||||
new ImageView(image) {
|
||||
fitWidth = size
|
||||
fitHeight = size
|
||||
preserveRatio = true
|
||||
smooth = true
|
||||
}
|
||||
}
|
||||
|
||||
private def loadImage(key: String): Image =
|
||||
val path = s"/sprites/pieces/$key.png"
|
||||
val stream = getClass.getResourceAsStream(path)
|
||||
if stream == null then throw new RuntimeException(s"Could not load sprite: $path")
|
||||
new Image(stream)
|
||||
private def loadImage(key: String): Option[Image] =
|
||||
val path = s"/sprites/pieces/$key.png"
|
||||
Option(getClass.getResourceAsStream(path)).map(new Image(_))
|
||||
|
||||
/** Get square colors for the board using theme. */
|
||||
object SquareColors:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.nowchess.ui.terminal
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.api.game.DrawReason
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.ui.utils.Renderer
|
||||
@@ -10,8 +12,8 @@ import de.nowchess.ui.utils.Renderer
|
||||
* I/O and user interaction in the terminal.
|
||||
*/
|
||||
class TerminalUI(engine: GameEngine) extends Observer:
|
||||
private var running = true
|
||||
private var awaitingPromotion = false
|
||||
private val running = new AtomicBoolean(true)
|
||||
private val awaitingPromotion = new AtomicBoolean(false)
|
||||
|
||||
/** Called by GameEngine whenever a game event occurs. */
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
@@ -43,8 +45,13 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
||||
println()
|
||||
print(Renderer.render(e.context.board))
|
||||
|
||||
case e: StalemateEvent =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
case e: DrawEvent =>
|
||||
val msg = e.reason match
|
||||
case DrawReason.Stalemate => "Stalemate! The game is a draw."
|
||||
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
|
||||
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
|
||||
case DrawReason.Agreement => "Draw by agreement."
|
||||
println(msg)
|
||||
println()
|
||||
print(Renderer.render(e.context.board))
|
||||
|
||||
@@ -59,13 +66,9 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
||||
|
||||
case _: PromotionRequiredEvent =>
|
||||
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
||||
synchronized { awaitingPromotion = true }
|
||||
case _: DrawClaimedEvent =>
|
||||
println("Draw claimed! The game is a draw.")
|
||||
println()
|
||||
print(Renderer.render(engine.board))
|
||||
awaitingPromotion.set(true)
|
||||
case _: FiftyMoveRuleAvailableEvent =>
|
||||
println("50-move rule available! The game is a draw.")
|
||||
println("50-move rule is now available — type 'draw' to claim.")
|
||||
|
||||
case e: PgnLoadedEvent =>
|
||||
println("PGN loaded successfully.")
|
||||
@@ -84,22 +87,22 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
||||
printPrompt(engine.turn)
|
||||
|
||||
// Game loop
|
||||
while running do
|
||||
while running.get() do
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
synchronized {
|
||||
if awaitingPromotion then
|
||||
if awaitingPromotion.get() then
|
||||
input.toLowerCase match
|
||||
case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
|
||||
case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
|
||||
case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
|
||||
case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
|
||||
case "q" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Queen)
|
||||
case "r" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Rook)
|
||||
case "b" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Bishop)
|
||||
case "n" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Knight)
|
||||
case _ =>
|
||||
println("Invalid choice. Enter q, r, b, or n.")
|
||||
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
||||
else
|
||||
input.toLowerCase match
|
||||
case "quit" | "q" =>
|
||||
running = false
|
||||
running.set(false)
|
||||
println("Game over. Goodbye!")
|
||||
case "" =>
|
||||
printPrompt(engine.turn)
|
||||
|
||||
Reference in New Issue
Block a user