25.9. Interfejs użytkownika
Choć możliwe jest rozgrywanie partii szachów za pomocą konsoli interaktywnej, to prezentowana aplikacja udostępnia także graficzny interfejs użytkownika, zbudowany z wykorzystaniem projektu scala-js, pozwalającego uruchamiać kod języka Scala w przeglądarce internetowej. Zanim zaczniemy z niego korzytać, powinniśmy zmienić definicję projektu. Po pierwsze, zmienimy zawartość pliku build.sbt na następującą:
Plik build.sbt: enablePlugins(ScalaJSPlugin)
name := "Chess" version := "1.0" scalaVersion := "2.11.7" libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "0.9.0"
![]()
Dodatkowo, w podkatalogu project umieścimy plik plugins.sbt o następującej treści:
Plik plugins.sbt: addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.7")
![]()
Wiersz
powoduje dodanie pluginu służącego do pracy ze
scala-js. Wiersz
powoduje aktywowanie pluginu. Wiersz
dodaje do
projektu zależność w postaci biblioteki DOM pozwalającej na
manipulowanie w programie obiektami strony HTML. Ponowne otwarcie
konsoli sbt pododuje zaczytanie dodatkowych plików.
$ sbt
Java HotSpot(TM) Client VM warning: ignoring option MaxPermSize=256m; support was removed in 8.0
[info] Loading project definition from H:\jps2\examples\chess\project
[info] Updating {file:/H:/jps2/examples/chess/project/}chess-build...
[info] Resolving org.fusesource.jansi#jansi;1.4 ...
[info] downloading https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/org.scala-js/sbt-scalajs/scala_2.10/sbt_0.13/0.6.7/jars/sbt-scalajs.jar ...
[info] [SUCCESSFUL ] org.scala-js#sbt-scalajs;0.6.7!sbt-scalajs.jar (3792ms)
…
[info] Done updating.
[info] Set current project to Chess (in build file:/H:/jps2/examples/chess/)
>
Możemy teraz zająć się implementacją interfejsu użytkownika, która znajduje się w pliku Gui.scala. Pierwszy fragment pliku, zaprezentowany poniżej, zawiera deklarację pakietu oraz polecenia importu.
Plik Gui.scala: package chess import scala.scalajs.js.JSApp import org.scalajs.dom import org.scalajs.dom.document import org.scalajs.dom.html import ComputerPlayer._
Następnie zdefiniowana jest cecha GameState oraz rozszerzające ją obiekty i klasy przypadku, reprezentujące różne stany, w której znajduje się partia szachów rozgrywana pomiędzy człowiekiem, a komputerem. Partia może być jeszcze nierozpoczęta (NotStarted), albo już zakończona (GameOver). Jeśli partia trwa, to możliwe są następujące cztery stany:
- WaitForComputer, oznaczający oczekiwanie na ruch komputera,
- WaitForSelectingFromField, oznaczający oczekiwanie na zaznaczenie przez gracza figury, którą dokona ruchu,
- WaitForSelectingToField, oznaczający oczekiwanie na wybór pola, na które figura zostanie przesunięta,
- WaitForSelectingPromotionFigure, oznaczający oczekiwanie na wybór figury, na którą zostanie promowany pion, który dotarł do ostatniego rzędu na planszy.
Plik Gui.scala: sealed trait GameState case object NotStarted extends GameState case class GameOver(game: Game) extends GameState case class WaitForComputer(game: Game) extends GameState case class WaitForSelectingFromField(game: Game) extends GameState case class WaitForSelectingToField(game: Game, from: Field) extends GameState case class WaitForSelectingPromotionFigure(game: Game, from: Field, to: Field, possiblePromotions: Set[Figure]) extends GameState
Obiekt Gui implementuje interfejs użytkownika. Obiekt ten rozszerza cechę JSApp pochodzacą z projektu scala-js. Następnie, w ciele obiektu, wywoływane są metody tworzące elementy interfejsu użytkownika. Zostaną one po kolei opisane nieco dalej.
Plik Gui.scala: object Gui extends JSApp { val fieldsAndCells = createBoard createButtonStartWhite createButtonStartBlack val messageBox = createMessageBox val promotionFiguresAndCells = createPromotionPanel hidePromotionFigures
W obiekcie Gui zdefiniowana jest zmienna state, reprezentująca aktualny stan partii.
Plik Gui.scala: private var state: GameState = NotStarted
Cecha JSApp deklaruje abstrakcyjną metodę main, którą musimy zaimplementować. Ponieważ jednak nasz program nie wymaga wykonania żadnego kodu w tej metodzie, implementacja pozostaje pusta.
Plik Gui.scala: def main() = ()
Dalej zdefiniowana jest metoda createBoard, która tworzy planszę
szachową. Plansza jest utworzona jako tabela HTML. W wierszu
, za
pomocą wywołania metody createElement obiektu document, tworzymy
obiekt reprezentujący tabelę HTML, a w kolejnych dwóch wierszach
ustawiamy wybrane własności stylu CSS tej tabeli, wykorzystując pole
style elementu HTML. W wierszu
dodajemy do tabeli wiersz
reprezentujący etykiety kolumn planszy, zwracany przez metodę
columnLabels. Następnie, wykorzystując dwie zagnieżdzone pętle
for, tworzymy wiersze i komórki tabeli reprezentujące pola planszy
(za pomocą metody boardCell) oraz etykiety wierszy (za pomocą
wywołania metody labelCell. W wierszu
ustawiamy dla pola planszy
funkcję obsługi kliknięcia myszką na tym polu. Funkcja jest zwracana
przez metodę onMouseClick. Wreszcie, po wyjściu z pętli, dodajemy
kolejny wiersz zawierający etykiety kolumn oraz dodajemy utworzoną
tabelę do ciała dokumentu HTML. Metoda createBoard zwraca sekwencję
64 par zawierających pola planszy szachowej oraz odpowiadające im
komórki tabeli.
Plik Gui.scala: def createBoard: Seq[(Field, html.TableDataCell)] = { var fieldsCells: Seq[(Field, html.TableDataCell)] = Seq() val board = document.createElement("table").asInstanceOf[html.Table]
board.style.borderCollapse = "collapse" board.style.textAlign = "center" board.appendChild(columnLabels)
for (r <- 8 to (1,-1)) { val row = document.createElement("tr") row.appendChild(labelCell(r.toString,25,50)) for (c <- 1 to 8) { val (field,cell) = boardCell(r,c) fieldsCells = (field,cell) +: fieldsCells cell.onclick = onMouseClick(field,cell)
row.appendChild(cell) } row.appendChild(labelCell(r.toString,25,50)) board.appendChild(row) } board.appendChild(columnLabels) document.body.appendChild(board) fieldsCells }
Metoda labelCell tworzy i zwraca komórkę tabeli HTML służącą do pokazania etykiety wiersza lub kolumny planszy. Użyta w niej metoda createTextNode tworzy węzeł tekstowy zawierający podany tekst.
Plik Gui.scala: def labelCell(label: String, w: Int, h: Int) = { val cell = document.createElement("td").asInstanceOf[html.TableDataCell] cell.style.width = s"${w}px" cell.style.height = s"${h}px" cell.style.textAlign = "center" val cellContent = document.createTextNode(label) cell.appendChild(cellContent) cell }
Metoda columnLabels tworzy wiersz tabeli zawierający etykiety kolumn planszy.
Plik Gui.scala: def columnLabels = { val row = document.createElement("tr") row.appendChild(labelCell("",25,25)) for (c <- 1 to 8) row.appendChild(labelCell((c + 'a' - 1).toChar.toString,50,25)) row }
Metoda boardCell tworzy komórkę tabeli reprezentującą jedno pole planszy szachowej. Metoda zwraca parę składającą się z pola planszy oraz odpowiadającej mu, utworzonej komórki tabeli HTML.
Plik Gui.scala: def boardCell(r: Int, c: Int) = { val field = Field(c,r) val cell = document.createElement("td").asInstanceOf[html.TableDataCell] cell.style.width = "50px" cell.style.height = "50px" cell.style.cursor = "pointer" cell.style.fontSize = "28pt" cell.style.color = "black" cell.style.backgroundColor = if ((r+c) % 2 == 0) "yellow" else "red" cell.id = s"$c$r" val cellContent = document.createTextNode(" ") cell.appendChild(cellContent) (field,cell) }
Metody createButtonStartWhite oraz createButtonStartBlack tworzą przyciski służące do rozpoczęcia gry. Pierwszy z przycisków rozpoczyna grę, w której białymi gra człowiek, a drugi — w której białymi gra komputer.
Plik Gui.scala: def createButtonStartWhite = { val buttonStartWhite = document.createElement("button").asInstanceOf[html.Button] buttonStartWhite.appendChild(document.createTextNode("Start (White)")) buttonStartWhite.onclick = buttonStartWhiteClicked document.body.appendChild(buttonStartWhite) } def createButtonStartBlack = { val buttonStartBlack = document.createElement("button").asInstanceOf[html.Button] buttonStartBlack.appendChild(document.createTextNode("Start (Black)")) buttonStartBlack.onclick = buttonStartBlackClicked document.body.appendChild(buttonStartBlack) }
Metoda createMessageBox tworzy element HTML, w którym będą pojawiały się komunikaty informujące gracza o rozmaitych zdarzeniach podczas gry.
Plik Gui.scala: def createMessageBox = { val messageBox = document.createElement("div").asInstanceOf[html.Div] messageBox.style.margin = "5px" messageBox.style.height = "20px" messageBox.appendChild(document.createTextNode("")) document.body.appendChild(messageBox) messageBox }
Metoda createPromotionPanel tworzy tabelę HTML zawierającą cztery figury, na które potencjalnie może być zamieniony pion po dojściu do ostatniego rzędu. Pomocnicza metoda promotionCell tworzy pojedynczą komórkę tabeli, zawierającą jedną figurę. W momencie, w którym gracz ma wybrać figurę do promocji, odpowiednie kandydatury zostaną pokazane poprzez ustawienie właściwości CSS display komórki tabeli. Do wyświetlania figur służy metoda showPromotionFigures. Metoda hidePromotionFigures chowa figury.
Plik Gui.scala: def createPromotionPanel: Seq[(Figure, html.TableDataCell)] = { val panel = document.createElement("table").asInstanceOf[html.Table] panel.style.textAlign = "center" val row = document.createElement("tr") val figures = Seq(Figure(Queen,White),Figure(Rook,White),Figure(Knight,White),Figure(Bishop,White)) val figuresAndCells = figures.map(figure => (figure, promotionCell(figure))) figuresAndCells.foreach { case (_, cell) => row.appendChild(cell) } panel.appendChild(row) document.body.appendChild(panel) figuresAndCells } def promotionCell(figure: Figure) = { val cell = document.createElement("td").asInstanceOf[html.TableDataCell] cell.style.width = "50px" cell.style.height = "50px" cell.style.fontSize = "28pt" cell.style.color = "black" cell.appendChild(document.createTextNode(figure.unicodeSymbol)) cell.onclick = promotionFigureClicked(figure) cell } def showPromotionFigures(figures: Set[Figure]) = promotionFiguresAndCells.foreach { case (figure, cell) => if (figures.contains(figure)) cell.style.display = "table-cell" else cell.style.display = "none" } def hidePromotionFigures = showPromotionFigures(Set())
Pora przedstawić metodę onMouseClick, która zwraca funkcję obsługi kliknięcia myszką w określone pole planszy. Metoda przyjmuje dwa parametry, reprezentujące pole planszy szachowej oraz odpowiadającą mu komórkę tabeli HTML. Rezultatem metody jest funkcja, której parametrem jest obiekt typu MouseEvent, reprezentujący zdarzenie kliknięcia.
Plik Gui.scala: def onMouseClick(field: Field, cell: html.TableDataCell) = (e: dom.MouseEvent) => { state match { case NotStarted => case WaitForSelectingFromField(game) => for (figure <- game.board.get(field) if figure.figureColor == game.color) state = WaitForSelectingToField(game, field) case WaitForSelectingToField(game, from) => val possiblePromotions = game.possiblePromotions(from, field) possiblePromotions.size match { case 0 => val newGame = game.move(from, field, None) handleNewGame(game, newGame) case 1 => val newGame = game.move(from, field, Some(possiblePromotions.head)) handleNewGame(game, newGame) case _ => state = WaitForSelectingPromotionFigure(game,from,field,possiblePromotions) } case _ => } updateView }
Reakcja aplikacji na kliknięcie pola planszy zależy od jej stanu. Przypomnijmy, że stan gry reprezentowany jest przez obiekty i klasy dziedziczące z GameState. Jeśli aplikacja oczekiwała na wybór figury przez gracza (stan reprezentowany przez WaitForSelectingFromField), to reakcją jest sprawdzenie, czy na wybranym polu rzeczywiście znajduje się figura odpowiedniego koloru. Jeśli tak jest, to stan aplikacji jest zmieniany na WaitForSelectingToField. Jeśli aplikacja znajduje się już w stanie WaitForSelectingToField, to po wybraniu pola przez użytkownika wywoływana jest metoda possiblePromotions, żeby sprawdzić, czy nie mamy do czynienia z ruchem piona połączonym z promocją. Jeśli mamy do czynienia z ruchem bez promocji lub jeśli dostępna jest tylko jedna możliwość promocji piona, to wywoływana jest metoda move, aby obliczyć nowy stan gry, po czym wywołujemy metodę handleNewGame. W przypadku większej niż jedna możliwości promocji piona, stan gry zmieniany jest na WaitForSelectingPromotionFigure, aby umożliwić użytkownikowi dokonanie wyboru. W każdym przypadku, na końcu wywoływana jest metoda updateView, aby aktualizować wygląd ekranu.
Metoda handleNewGame sprawdza, jaki jest nowy stan gry zwrócony przez metodę move. Jeśli metoda nie zwróciła nowego stanu gry (czyli jeśli zwróciła obiekt None), to stan aplikacji zmieniany jest na WaitForSelectingFromField. Jeśli nowy stan gry został zwrócony, to sprawdzamy, czy gra jest zakończona, i jeśli tak jest, to stan aplikacji zmieniamy na GameOver. Jeśli gra nie jest zakończona, to stan aplikacji zmieniamy na WaitForComputer, aktualizujemy ekran, po czym za pomocą wywołania metody setTimeout nakazujemy wykonanie metody computerMove, służącej do obliczenia ruchu komputera.
Plik Gui.scala: def handleNewGame(oldGame: Game, newGameOpt: Option[Game]) = newGameOpt match { case Some(newGame) => if (newGame.isGameFinished) state = GameOver(newGame) else state = WaitForComputer(newGame) updateView dom.window.setTimeout(() => computerMove(newGame), 1.0) case None => state = WaitForSelectingFromField(oldGame) }
Metoda computerMove wywołuje metodę makeMove, i w zależności od jej rezultatu, zmienia stan gry albo na GameOver, albo na WaitForSelectingFromField, po czym aktualizuje widok wywołując updateView.
Plik Gui.scala: def computerMove(game: Game) = { game.makeMove.fold{ state = GameOver(game) }{ newGame => state = WaitForSelectingFromField(newGame) } updateView }
Pora przyjrzeć się metodzie updateView, która aktualizuje widok aplikacji. Metoda ta korzysta z pomocniczych metod aktualizujących poszczególne fragmenty widoku, które wywoływane są w różny sposób w zależności od stanu aplikacji.
Plik Gui.scala: def updateView = state match { case WaitForSelectingFromField(game) => updateBoard(game, None) updateMessage("Select figure") case WaitForSelectingToField(game, from) => updateBoard(game, Some(from)) updateMessage("Select destination field") case WaitForComputer(game) => updateBoard(game, None) updateMessage("Computer is about to make a move") hidePromotionFigures case GameOver(game) => updateMessage("Game Over. "+game.winner.fold("Draw"){ w => "Winner: "+w }) case WaitForSelectingPromotionFigure(game, from, field, possiblePromotions) => showPromotionFigures(possiblePromotions) updateMessage("Choose the figure to promote the pawn to") case _ => }
Metoda updateBoard aktualizuje widok planszy, przechodząc przez poszczególne pola planszy i wstawiając nową zawartość odpowiednich komórek tabeli HTML. Nowa zawartość zastępuje poprzednią za pomocą wywołania metody replaceChild. Jeśli jedno z pól jest zaznaczone przez użytkownika, to figura stojąca na tym polu rysowana jest w kolorze niebieskim.
Plik Gui.scala: def updateBoard(game: Game, selectedField: Option[Field]) = { for ((field,cell) <- fieldsAndCells) { val boardFigure = game.board.get(field) val figureString = boardFigure.fold(""){ f => f.unicodeSymbol } cell.replaceChild(document.createTextNode(figureString), cell.firstChild) if (selectedField == Some(field)) cell.style.color = "blue" else cell.style.color = "black" } }
Metoda updateMessage aktualizuje komunikat w polu służącym do prezentowania go użytkownikowi.
Plik Gui.scala: def updateMessage(msg: String) = messageBox.replaceChild(document.createTextNode(msg), messageBox.firstChild)
Metody buttonStartWhiteClicked oraz buttonStartBlackClicked zwracają funkcje obsługujące zdarzenia kliknięcia przycisków rozpoczynających grę. Obie metody ustawiają stan gry i aktualizują widok aplikacji.
Plik Gui.scala: def buttonStartWhiteClicked = (e: dom.MouseEvent) => { state = WaitForSelectingFromField(GameStart) updateView } def buttonStartBlackClicked = (e: dom.MouseEvent) => { state = WaitForSelectingFromField(GameStart.makeMove.get) updateView }
Ostatnią metodą zdefiniowaną w obiekcie Gui jest metoda promotionFigureClicked, zwracająca funkcję obsługującą wybór przez użytkownika figury, na którą ma zostać promowany pion. Jeśli aplikacja znajduje się w stanie WaitForSelectingPromotionFigure, funkcja oblicza nowy stan gry, po dokonaniu ruchu, a następnie wywołuje metody handleNewGame i updateView. Jeśli aplikacja znajduje się w innym stanie, metoda nie wykonuje żadnych akcji.
Plik Gui.scala: def promotionFigureClicked(figure: Figure) = (e: dom.MouseEvent) => { state match { case WaitForSelectingPromotionFigure(game, from, to, _) => val newGame = game.move(from, to, Some(figure)) handleNewGame(game, newGame) updateView case _ => } } }
W celu uruchomienia aplikacji w przeglądarce internetowej, musimy skompilować kod, ale nie do postaci plików klas, ale do postaci pliku języka JavaScript. Można tego dokonać za pomocą polecenia fastOptJS wydanego w konsoli sbt (przy pierwszym uruchomieniu polecenia możemy spodziewać się, że sbt znowu będzie pobierał z Internetu dodatkowe pliki).
> fastOptJS
[info] Updating {file:/H:/jps2/examples/chess/}chess...
[info] Resolving org.eclipse.jetty#jetty-project;8.1.16.v20140903 ...
[info] downloading https://jcenter.bintray.com/org/scala-js/scalajs-library_2.11/0.6.7/scalajs-library_2.11-0.6.7.jar ...
[info] [SUCCESSFUL ] org.scala-js#scalajs-library_2.11;0.6.7!scalajs-library_2.11.jar (25345ms)
…
[info] Done updating.
[info] Compiling 10 Scala sources to H:\jps2\examples\chess\target\scala-2.11\classes...
[info] Fast optimizing H:\jps2\examples\chess\target\scala-2.11\chess-fastopt.js
[success] Total time: 127 s, completed 2016-02-27 11:52:48
>
Potrzebujemy jeszcze stony HTML, która wczyta wygenerowany plik JavaScript oraz uruchomi aplikację tworząc obiekt Gui. Taka strona jest pokazana w pliku chess.html.
Plik chess.html: <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Chess</title> </head> <body> <script src="target/scala-2.11/chess-fastopt.js"></script> <script> chess.Gui() </script> </body> </html>
Aplikację uruchamiamy otwierając powyższy plik HTML w przeglądarce internetowej. Rysunek 25.1 pokazuje działającą aplikację.

Plik build.sbt:
enablePlugins(ScalaJSPlugin) 