feat: NCS-17 Implement basic ScalaFX UI (#14)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com> Reviewed-on: #14 Co-authored-by: Janis <janis.e.20@gmx.de> Co-committed-by: Janis <janis.e.20@gmx.de>
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
id("org.scoverage")
|
||||
application
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ scala {
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
excludedPackages.set(listOf(
|
||||
"de.nowchess.ui.gui"
|
||||
))
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -51,7 +54,24 @@ dependencies {
|
||||
implementation(project(":modules:core"))
|
||||
implementation(project(":modules:api"))
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
// ScalaFX dependencies
|
||||
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
|
||||
|
||||
// JavaFX dependencies for the current platform
|
||||
val javaFXVersion = versions["JAVAFX"]!!
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
val platform = when {
|
||||
osName.contains("win") -> "win"
|
||||
osName.contains("mac") -> "mac"
|
||||
osName.contains("linux") -> "linux"
|
||||
else -> "linux"
|
||||
}
|
||||
|
||||
listOf("base", "controls", "graphics", "media").forEach { module ->
|
||||
implementation("org.openjfx:javafx-$module:$javaFXVersion:$platform")
|
||||
}
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||
|
||||
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 297 B |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 313 B |
|
After Width: | Height: | Size: 251 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 305 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 280 B |
@@ -0,0 +1,30 @@
|
||||
/* Arabian Chess GUI Styles */
|
||||
|
||||
.root {
|
||||
-fx-font-family: "Comic Sans MS", "Comic Sans", cursive;
|
||||
-fx-background-color: #F3C8A0;
|
||||
}
|
||||
|
||||
.button {
|
||||
-fx-background-radius: 8;
|
||||
-fx-padding: 8 16 8 16;
|
||||
-fx-font-family: "Comic Sans MS", cursive;
|
||||
-fx-font-size: 12px;
|
||||
-fx-cursor: hand;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
-fx-opacity: 0.8;
|
||||
}
|
||||
|
||||
.label {
|
||||
-fx-font-family: "Comic Sans MS", cursive;
|
||||
}
|
||||
|
||||
.dialog-pane {
|
||||
-fx-background-color: #F3C8A0;
|
||||
}
|
||||
|
||||
.dialog-pane .content {
|
||||
-fx-font-family: "Comic Sans MS", cursive;
|
||||
}
|
||||
@@ -2,14 +2,20 @@ package de.nowchess.ui
|
||||
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.ui.terminal.TerminalUI
|
||||
import de.nowchess.ui.gui.ChessGUILauncher
|
||||
|
||||
/** Application entry point - starts the Terminal UI for the chess game. */
|
||||
/** Application entry point - starts both GUI and Terminal UI for the chess game.
|
||||
* Both views subscribe to the same GameEngine via Observer pattern.
|
||||
*/
|
||||
object Main:
|
||||
def main(args: Array[String]): Unit =
|
||||
// Create the core game engine (single source of truth)
|
||||
val engine = new GameEngine()
|
||||
|
||||
// Create and start the terminal UI
|
||||
// Launch ScalaFX GUI in separate thread
|
||||
ChessGUILauncher.launch(engine)
|
||||
|
||||
// Create and start the terminal UI (blocks on main thread)
|
||||
val tui = new TerminalUI(engine)
|
||||
tui.start()
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import scalafx.Includes.*
|
||||
import scalafx.application.Platform
|
||||
import scalafx.geometry.{Insets, Pos}
|
||||
import scalafx.scene.control.{Button, ButtonType, ChoiceDialog, Label}
|
||||
import scalafx.scene.layout.{BorderPane, GridPane, HBox, VBox, StackPane}
|
||||
import scalafx.scene.paint.Color as FXColor
|
||||
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.{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.
|
||||
* Handles user interactions (clicks) and sends moves to GameEngine.
|
||||
*/
|
||||
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
||||
|
||||
private val squareSize = 70.0
|
||||
private val comicSansFontFamily = "Comic Sans MS"
|
||||
private val boardGrid = new GridPane()
|
||||
private val messageLabel = new Label {
|
||||
text = "Welcome!"
|
||||
font = Font.font(comicSansFontFamily, 16)
|
||||
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]()
|
||||
|
||||
// Initialize UI
|
||||
initializeBoard()
|
||||
|
||||
top = new VBox {
|
||||
padding = Insets(10)
|
||||
spacing = 5
|
||||
alignment = Pos.Center
|
||||
children = Seq(
|
||||
new Label {
|
||||
text = "Chess"
|
||||
font = Font.font(comicSansFontFamily, 24)
|
||||
style = "-fx-font-weight: bold;"
|
||||
},
|
||||
messageLabel
|
||||
)
|
||||
}
|
||||
|
||||
center = new VBox {
|
||||
padding = Insets(20)
|
||||
alignment = Pos.Center
|
||||
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
|
||||
children = boardGrid
|
||||
}
|
||||
|
||||
bottom = new VBox {
|
||||
padding = Insets(10)
|
||||
spacing = 8
|
||||
alignment = Pos.Center
|
||||
children = Seq(
|
||||
new HBox {
|
||||
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;"
|
||||
},
|
||||
new Button("Redo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canRedo then engine.redo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
|
||||
},
|
||||
new Button("Reset") {
|
||||
font = Font.font(comicSansFontFamily, 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(comicSansFontFamily, 12)
|
||||
onAction = _ => doFenExport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #DAC4B9;"
|
||||
},
|
||||
new Button("FEN Import") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doFenImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #DAD4B9;"
|
||||
},
|
||||
new Button("PGN Export") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doPgnExport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #C4DAB9;"
|
||||
},
|
||||
new Button("PGN Import") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doPgnImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private def initializeBoard(): Unit =
|
||||
boardGrid.padding = Insets(5)
|
||||
boardGrid.hgap = 0
|
||||
boardGrid.vgap = 0
|
||||
|
||||
// Create 8x8 board with rank/file labels
|
||||
for
|
||||
rank <- 0 until 8
|
||||
file <- 0 until 8
|
||||
do
|
||||
val square = createSquare(rank, file)
|
||||
squareViews((rank, file)) = square
|
||||
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
|
||||
|
||||
updateBoard(currentBoard, currentTurn)
|
||||
|
||||
private def createSquare(rank: Int, file: Int): StackPane =
|
||||
val isWhite = (rank + file) % 2 == 0
|
||||
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
||||
|
||||
val bgRect = new Rectangle {
|
||||
width = squareSize
|
||||
height = squareSize
|
||||
fill = FXColor.web(baseColor)
|
||||
arcWidth = 8
|
||||
arcHeight = 8
|
||||
}
|
||||
|
||||
val square = new StackPane {
|
||||
children = Seq(bgRect)
|
||||
onMouseClicked = _ => handleSquareClick(rank, file)
|
||||
style = "-fx-cursor: hand;"
|
||||
}
|
||||
|
||||
square
|
||||
|
||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||
if engine.isPendingPromotion then
|
||||
return // Don't allow moves during promotion
|
||||
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
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 = GameRules.legalMoves(currentBoard, engine.history, currentTurn)
|
||||
.collect { case (`clickedSquare`, to) => 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
|
||||
|
||||
def updateBoard(board: Board, turn: Color): Unit =
|
||||
currentBoard = board
|
||||
currentTurn = turn
|
||||
selectedSquare = None
|
||||
|
||||
// Update all squares
|
||||
for
|
||||
rank <- 0 until 8
|
||||
file <- 0 until 8
|
||||
do
|
||||
squareViews.get((rank, file)).foreach { stackPane =>
|
||||
val isWhite = (rank + file) % 2 == 0
|
||||
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
||||
|
||||
val bgRect = new Rectangle {
|
||||
width = squareSize
|
||||
height = squareSize
|
||||
fill = FXColor.web(baseColor)
|
||||
arcWidth = 8
|
||||
arcHeight = 8
|
||||
}
|
||||
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
val pieceOption = board.pieceAt(square)
|
||||
|
||||
val children = pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
|
||||
stackPane.children = children
|
||||
}
|
||||
|
||||
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
|
||||
squareViews.get((rank, file)).foreach { stackPane =>
|
||||
val bgRect = new Rectangle {
|
||||
width = squareSize
|
||||
height = squareSize
|
||||
fill = FXColor.web(color)
|
||||
arcWidth = 8
|
||||
arcHeight = 8
|
||||
}
|
||||
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
val pieceOption = currentBoard.pieceAt(square)
|
||||
|
||||
stackPane.children = pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
}
|
||||
|
||||
def showMessage(msg: String): Unit =
|
||||
messageLabel.text = msg
|
||||
|
||||
def showPromotionDialog(from: Square, to: Square): Unit =
|
||||
val choices = Seq("Queen", "Rook", "Bishop", "Knight")
|
||||
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
|
||||
initOwner(stage)
|
||||
title = "Pawn Promotion"
|
||||
headerText = "Choose promotion piece"
|
||||
contentText = "Promote to:"
|
||||
}
|
||||
|
||||
val result = dialog.showAndWait()
|
||||
result match
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
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
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import javafx.application.{Application => JFXApplication, Platform => JFXPlatform}
|
||||
import javafx.stage.Stage as JFXStage
|
||||
import scalafx.application.Platform
|
||||
import scalafx.scene.Scene
|
||||
import scalafx.stage.Stage
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
|
||||
/** ScalaFX GUI Application for Chess.
|
||||
* This is launched from Main alongside the TUI.
|
||||
* Both subscribe to the same GameEngine via Observer pattern.
|
||||
*/
|
||||
class ChessGUIApp extends JFXApplication:
|
||||
|
||||
override def start(primaryStage: JFXStage): Unit =
|
||||
val engine = ChessGUILauncher.getEngine
|
||||
val stage = new Stage(primaryStage)
|
||||
|
||||
stage.title = "Chess"
|
||||
stage.width = 700
|
||||
stage.height = 1000
|
||||
stage.resizable = false
|
||||
|
||||
val boardView = new ChessBoardView(stage, engine)
|
||||
val guiObserver = new GUIObserver(boardView)
|
||||
|
||||
// Subscribe GUI observer to engine
|
||||
engine.subscribe(guiObserver)
|
||||
|
||||
stage.scene = new Scene {
|
||||
root = boardView
|
||||
// Load CSS if available
|
||||
try {
|
||||
val cssUrl = getClass.getResource("/styles.css")
|
||||
if cssUrl != null then
|
||||
stylesheets.add(cssUrl.toExternalForm)
|
||||
} catch {
|
||||
case _: Exception => // CSS is optional
|
||||
}
|
||||
}
|
||||
|
||||
stage.onCloseRequest = _ => {
|
||||
// Unsubscribe when window closes
|
||||
engine.unsubscribe(guiObserver)
|
||||
}
|
||||
|
||||
stage.show()
|
||||
|
||||
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
|
||||
object ChessGUILauncher:
|
||||
@volatile private var engine: GameEngine = scala.compiletime.uninitialized
|
||||
|
||||
def getEngine: GameEngine = engine
|
||||
|
||||
def launch(eng: GameEngine): Unit =
|
||||
engine = eng
|
||||
val guiThread = new Thread(() => {
|
||||
JFXApplication.launch(classOf[ChessGUIApp])
|
||||
})
|
||||
guiThread.setDaemon(false)
|
||||
guiThread.setName("ScalaFX-GUI-Thread")
|
||||
guiThread.start()
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import scalafx.application.Platform
|
||||
import scalafx.scene.control.Alert
|
||||
import scalafx.scene.control.Alert.AlertType
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
||||
import de.nowchess.api.board.Board
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
// Ensure UI updates happen on JavaFX thread
|
||||
Platform.runLater {
|
||||
event match
|
||||
case e: MoveExecutedEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
e.capturedPiece.foreach { piece =>
|
||||
boardView.showMessage(s"Captured: $piece on ${e.toSquare}")
|
||||
}
|
||||
|
||||
case e: CheckDetectedEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.showMessage(s"${e.turn.label} is in check!")
|
||||
|
||||
case e: CheckmateEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
|
||||
|
||||
case e: StalemateEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
showAlert(AlertType.Information, "Game Over", "Stalemate! The game is a draw.")
|
||||
|
||||
case e: InvalidMoveEvent =>
|
||||
boardView.showMessage(s"⚠️ ${e.reason}")
|
||||
|
||||
case e: BoardResetEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.showMessage("Board has been reset to initial position.")
|
||||
|
||||
case e: PromotionRequiredEvent =>
|
||||
boardView.showPromotionDialog(e.from, e.to)
|
||||
|
||||
case e: DrawClaimedEvent =>
|
||||
boardView.updateBoard(e.board, e.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.")
|
||||
}
|
||||
|
||||
private def showAlert(alertType: AlertType, titleText: String, content: String): Unit =
|
||||
new Alert(alertType) {
|
||||
initOwner(boardView.stage)
|
||||
title = titleText
|
||||
headerText = None
|
||||
contentText = content
|
||||
}.showAndWait()
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import scalafx.scene.image.{Image, ImageView}
|
||||
import de.nowchess.api.board.{Piece, PieceType, Color}
|
||||
|
||||
/** Utility object for loading chess piece sprites. */
|
||||
object PieceSprites:
|
||||
|
||||
private val spriteCache = scala.collection.mutable.Map[String, 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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
/** Get square colors for the board using theme. */
|
||||
object SquareColors:
|
||||
val White = "#F3C8A0" // Warm light beige
|
||||
val Black = "#BA6D4B" // Warm terracotta
|
||||
val Selected = "#C19EF5" // Purple highlight
|
||||
val ValidMove = "#E1EAA9" // Light yellow-green
|
||||
val Border = "#5A2C28" // Dark brown border
|
||||
@@ -49,6 +49,12 @@ 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))
|
||||
case _: FiftyMoveRuleAvailableEvent =>
|
||||
println("50-move rule available! The game is a draw.")
|
||||
|
||||
/** Start the terminal UI game loop. */
|
||||
def start(): Unit =
|
||||
|
||||