diff --git a/modules/gui/build.gradle.kts b/modules/gui/build.gradle.kts new file mode 100644 index 0000000..7a1f99f --- /dev/null +++ b/modules/gui/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + id("scala") + id("org.scoverage") version "8.1" + application +} + +group = "de.nowchess" +version = "1.0-SNAPSHOT" + +@Suppress("UNCHECKED_CAST") +val versions = rootProject.extra["VERSIONS"] as Map + +repositories { + mavenCentral() +} + +scala { + scalaVersion = versions["SCALA3"]!! +} + +scoverage { + scoverageVersion.set(versions["SCOVERAGE"]!!) +} + +dependencies { + implementation("org.scala-lang:scala3-compiler_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + implementation("org.scala-lang:scala3-library_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + + implementation(project(":modules:core")) + implementation(project(":modules:api")) + + // ScalaFX dependencies + implementation("org.scalafx:scalafx_3:21.0.0-R32") + + // JavaFX dependencies for the current platform + val javaFXVersion = "21.0.1" + 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:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") + testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform { + includeEngines("scalatest") + testLogging { + events("passed", "skipped", "failed") + } + } + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} diff --git a/modules/gui/src/main/resources/sprites/board/board_bottom.png b/modules/gui/src/main/resources/sprites/board/board_bottom.png new file mode 100644 index 0000000..884fb3c Binary files /dev/null and b/modules/gui/src/main/resources/sprites/board/board_bottom.png differ diff --git a/modules/gui/src/main/resources/sprites/board/board_square_black.png b/modules/gui/src/main/resources/sprites/board/board_square_black.png new file mode 100644 index 0000000..42c4b9a Binary files /dev/null and b/modules/gui/src/main/resources/sprites/board/board_square_black.png differ diff --git a/modules/gui/src/main/resources/sprites/board/board_square_white.png b/modules/gui/src/main/resources/sprites/board/board_square_white.png new file mode 100644 index 0000000..ea97b12 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/board/board_square_white.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/black_bishop.png b/modules/gui/src/main/resources/sprites/pieces/black_bishop.png new file mode 100644 index 0000000..fe2c260 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/black_bishop.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/black_king.png b/modules/gui/src/main/resources/sprites/pieces/black_king.png new file mode 100644 index 0000000..f1c96bb Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/black_king.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/black_knight.png b/modules/gui/src/main/resources/sprites/pieces/black_knight.png new file mode 100644 index 0000000..579db13 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/black_knight.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/black_pawn.png b/modules/gui/src/main/resources/sprites/pieces/black_pawn.png new file mode 100644 index 0000000..92597c9 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/black_pawn.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/black_queen.png b/modules/gui/src/main/resources/sprites/pieces/black_queen.png new file mode 100644 index 0000000..6d94c24 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/black_queen.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/black_rook.png b/modules/gui/src/main/resources/sprites/pieces/black_rook.png new file mode 100644 index 0000000..7ab7e04 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/black_rook.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/white_bishop.png b/modules/gui/src/main/resources/sprites/pieces/white_bishop.png new file mode 100644 index 0000000..ab456ed Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/white_bishop.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/white_king.png b/modules/gui/src/main/resources/sprites/pieces/white_king.png new file mode 100644 index 0000000..435d27a Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/white_king.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/white_knight.png b/modules/gui/src/main/resources/sprites/pieces/white_knight.png new file mode 100644 index 0000000..7cf6ed6 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/white_knight.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/white_pawn.png b/modules/gui/src/main/resources/sprites/pieces/white_pawn.png new file mode 100644 index 0000000..47cb262 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/white_pawn.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/white_queen.png b/modules/gui/src/main/resources/sprites/pieces/white_queen.png new file mode 100644 index 0000000..cb53ef1 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/white_queen.png differ diff --git a/modules/gui/src/main/resources/sprites/pieces/white_rook.png b/modules/gui/src/main/resources/sprites/pieces/white_rook.png new file mode 100644 index 0000000..10ba443 Binary files /dev/null and b/modules/gui/src/main/resources/sprites/pieces/white_rook.png differ diff --git a/modules/gui/src/main/resources/styles.css b/modules/gui/src/main/resources/styles.css new file mode 100644 index 0000000..aae36d1 --- /dev/null +++ b/modules/gui/src/main/resources/styles.css @@ -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; +} diff --git a/modules/gui/src/main/scala/de/nowchess/gui/ChessBoardView.scala b/modules/gui/src/main/scala/de/nowchess/gui/ChessBoardView.scala new file mode 100644 index 0000000..40d5a7c --- /dev/null +++ b/modules/gui/src/main/scala/de/nowchess/gui/ChessBoardView.scala @@ -0,0 +1,217 @@ +package de.nowchess.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.move.PromotionPiece +import de.nowchess.chess.engine.GameEngine + +/** ScalaFX chess board view that displays the game state. + * Uses Arabian 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 boardGrid = new GridPane() + private val messageLabel = new Label { + text = "Welcome to Arabian Chess!" + font = Font.font("Comic Sans MS", 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 = "Arabian Chess" + font = Font.font("Comic Sans MS", 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 HBox { + padding = Insets(10) + 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;" + } + ) + } + + 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) + } + + 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 diff --git a/modules/gui/src/main/scala/de/nowchess/gui/ChessGUI.scala b/modules/gui/src/main/scala/de/nowchess/gui/ChessGUI.scala new file mode 100644 index 0000000..20a705a --- /dev/null +++ b/modules/gui/src/main/scala/de/nowchess/gui/ChessGUI.scala @@ -0,0 +1,63 @@ +package de.nowchess.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 Arabian 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 = "Arabian Chess" + stage.width = 700 + stage.height = 800 + 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() diff --git a/modules/gui/src/main/scala/de/nowchess/gui/GUIObserver.scala b/modules/gui/src/main/scala/de/nowchess/gui/GUIObserver.scala new file mode 100644 index 0000000..e96450c --- /dev/null +++ b/modules/gui/src/main/scala/de/nowchess/gui/GUIObserver.scala @@ -0,0 +1,54 @@ +package de.nowchess.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) + } + + private def showAlert(alertType: AlertType, titleText: String, content: String): Unit = + new Alert(alertType) { + initOwner(boardView.stage) + title = titleText + headerText = None + contentText = content + }.showAndWait() diff --git a/modules/gui/src/main/scala/de/nowchess/gui/PieceSprites.scala b/modules/gui/src/main/scala/de/nowchess/gui/PieceSprites.scala new file mode 100644 index 0000000..526cc68 --- /dev/null +++ b/modules/gui/src/main/scala/de/nowchess/gui/PieceSprites.scala @@ -0,0 +1,38 @@ +package de.nowchess.gui + +import scalafx.scene.image.{Image, ImageView} +import de.nowchess.api.board.{Piece, PieceType, Color} + +/** Utility object for loading Arabian 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 Arabian 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 diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts index f70d8c1..4f64d41 100644 --- a/modules/ui/build.gradle.kts +++ b/modules/ui/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(project(":modules:core")) implementation(project(":modules:api")) + implementation(project(":modules:gui")) testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala index c8f5562..731084c 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala @@ -2,14 +2,20 @@ package de.nowchess.ui import de.nowchess.chess.engine.GameEngine import de.nowchess.ui.terminal.TerminalUI +import de.nowchess.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() diff --git a/settings.gradle.kts b/settings.gradle.kts index f164a80..75b3358 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ rootProject.name = "NowChessSystems" -include("modules:core", "modules:api", "modules:ui") \ No newline at end of file +include("modules:core", "modules:api", "modules:ui", "modules:gui") \ No newline at end of file