Zróbmy sobie grę... w CSS
22.12.2016 | aktual.: 28.12.2016 12:07
Kaskadowe arkusze stylów (ang. Cascading Style Sheets, w skrócie CSS) to język służący do opisu formy prezentacji (wyświetlania) stron WWW. Takie zdanie można zobaczyć wpisując hasło "CSS" w Wikipedię. Co jednak gdyby definicję tę trochę nagiąć i stworzyć coś co na pierwszy rzut oka jest czymś więcej niż jedynie opisem formy prezentacji? Znacie grę Saper? Jest to zdecydowanie moja ulubiona mini gra, dlatego też postanowiłem wziąć ją na warsztat i stworzyć własną wersję. I nie byłoby w tym absolutnie nic nadzwyczajnego, ale postanowiłem wykorzystać do tego celu HTML i CSS. Tylko. Żadnego Javascriptu. Nic.
Dla niecierpliwych przygotowałem też możliwość podglądu finalnego efektu.
Aby formalności stało się zadość wspomnę jeszcze, że nie gwarantuje działania na wszystkich przeglądarkach, dlatego użytkownicy IE6 mogą się rozczarować.
Ale od początku
Zobaczmy z czego składa się klasyczna plansza Sapera. W naszym małym projekcie pominiemy zegar (choć również da się go zrobić w CSS) żeby niepotrzebnie nie rozbudowywać wpisu. Najważniejsza oczywiście jest siatka pól oraz możliwość odkrywania/zaznaczania ich. Skupmy się na samym odkrywaniu pól i zastanówmy się jaki element HTML pozwala się zaznaczać i odznaczać. Szczęśliwie istnieje checkbox i to na nim będziemy opierać całą grę.
Ale, ale! Ja chcę żeby mój Saper wyglądał ładnie, czy checkboxa da się odpowiednio ostylować?
Mamy rok 2016 (no...prawie 2017). Ludzie latają w kosmos, nasza sonda Voyager 1 przebywa w przestrzeni międzygwiezdnej a najmniejszych robotów nie da się zobaczyć gołym okiem. Czy wobec tego są dla rodzaju ludzkiego jakieś bariery? Owszem - ostylowanie checkboxa. Niestety nieważne ile stylów by do niego nie napisać to przeglądarka i tak wyświetli ten element strony po swojemu. W zamierzchłych czasach używało się do tego celu javascriptu, ale heloł - miało być bez niego!
Odkrył prosty sposób na ostylowanie checkboxa bez użycia Javascriptu! Naukowcy go nienawidzą! Szok! [ZOBACZ ZDJĘCIA]
Oczywiście żartuję - sposób nie jest niczym nowym i jest dość powszechnie stosowany - cały trick polega na ukryciu samego checkboxa oraz odpowiednim ostylowaniu dołączonego elementu label. Dodajmy więc checkboxa i zdefiniujmy jego wygląd tak, aby wyglądał jak kwadrat:
//index.pug doctype html html(lang="en") head meta(charset="UTF-8") link(href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet") link(rel="stylesheet" href="styles/style.css") title MineSweeper body input#foobar(type="checkbox") label(for="foobar") Click!
//style.scss #foobar { display: none; & + label { border: 1px solid black; width: 50px; height: 50px; display: block; cursor: pointer; } &:checked + label { background: gray; } }
Już się z tego tłumaczę - zamiast HTML będę używał PUG'a który upraszcza składnie standardowego kodu strony i wprowadza parę innych przydatnych sztuczek. Podobnie zamiast CSS wolę używać SASS. Nie ma w tym żadnego oszustwa, bo finalnie oba te języki są kompilowane do swoich normalnych odpowiedników. Mógłbym wszystko robić bezpośrednio w standardowym HTML i CSS, ale to tylko niepotrzebne zużycie klawiatury. Zresztą na repozytorium z gotowym projektem znajdziesz kod zarówno przed jak i po kompilacji. A oto szalenie interesujący rezultat:
Wincyj pól!
Gra w sapera z planszą 1x1 byłaby interesująca niczym kolejny artykuł porównujący Linuxa i Windowsa, dlatego spróbujmy dodać ich kilka więcej. Jako że z natury jestem leniwy, to zamiast tworzyć to wszystko ręcznie, wykorzystam pętle dostępne zarówno w PUG'u jak i SCSS'ie (już rozumiesz czemu zdecydowałem się na preprocesory?). Powtórzę się znowu - nie ma w tym nic zbereźnego, równie dobrze mógłbym użyć metody Copypastego. Abym mógł wygodnie sterować wszystkimi polami, najpierw wypisze sobie wszystkie niewidzialne checkboxy, a dopiero potem odpowiadające im labele. Dodatkowo dla większego porządku podzielę sobie moje style na kilka plików.
//index.pug - var fields = [1,0,0,0,1,0,0,1,0,0,1,1,0,0,1,0,0,1,0,1,0,0,1,1,1] body .wrapper .game .board each field, index in fields input.field(id="field" + index, type="checkbox") each field, index in fields label.field(id="label" + index, for="field" + index)
//style.scss @import "data"; @import "presentation"; //_data.scss $fields: 1,0,0,0,1,0,0,1,0,0,1,1,0,0,1,0,0,1,0,1,0,0,1,1,1; //_presentation.scss input.field { display: none; } label.field { border: 1px solid black; width: 50px; height: 50px; display: block; cursor: pointer; } @for $i from 0 through length($fields)-1 { #field#{$i}:checked ~ #label#{$i} { background: gray; } }
Dorzućmy do tego parę nudniejszych formuł takich jak wyśrodkowanie, zaokrąglenie rogów czy flexbox i uzyskamy pierwszy zarys pól:
Bombowo! Ale co z zaznaczaniem bomb?
Saper w którym możemy tylko odkrywać pola to dalej nie jest gra marzeń. Dlatego zajmiemy się teraz trybem oznaczania bomb. W tym celu stworzymy lustrzaną siatkę jak już istniejąca, nałożymy ją na plansze i domyślnie ukryjemy. Dodatkowo ponad planszą dodajmy przyciski służące do zmiany trybu (odkrywanie/zaznaczanie) w formie elementów radio. To na podstawie tego który z nich jest zaznaczony będziemy pokazywać lub ukrywać siatkę służącą do zaznaczania min. Jeśli wybrana będzie opcja "kopania" to ukryte będą pola z nowo dodanej siatki (poza już zaznaczonymi kratkami - te powinny być widoczne zawsze).
//index.pug .game input.action-type#dig(type="radio" name="actionType" checked) label.action-type(for="dig") DIG input.action-type#mark(type="radio" name="actionType") label.action-type(for="mark") MARK .board each field, index in fields input.field(id="field" + index, type="checkbox") input.field(id="mine-field" + index, type="checkbox") each field, index in fields label.field(id="label" + index, for="field" + index) .board-mines each field, index in fields label.field(id="mine-label" + index for="mine-field" + index)
Style dotyczące zachowań będziemy umieszczać w osobnym pliku który trzeba dopisać do agregującego importy style.scss
//style.scss @import "data"; @import "logic"; @import "presentation"; //logic.scss .board { #mark:checked ~ label.field{ pointer-events: none; } .board-mines{ visibility: hidden; margin-top: 0; label { visibility: hidden; #mark:checked ~ & { visibility: visible; } #dig:checked ~ & { pointer-events: none; } } } } @for $i from 0 through length($fields)-1 { #mine-field#{$i}:checked ~ .board-mines #mine-label#{$i} { visibility: visible; } } //presentation.scss // pomijam mniej ważne style, dotyczące wyglądu @for $i from 0 through length($fields)-1 { #field#{$i}:checked ~ #label#{$i} { background: gray; } #mine-field#{$i}:checked ~ .board-mines #mine-label#{$i} { background: yellow; } }
Zostały do obsłużenia jeszcze trzy niuanse:
- Nie powinniśmy móc zaznaczyć na żółto pola szarego (czyli postawić flagi na odkrytym polu)
- Szare pole musi być szare na zawsze (nie można zakopać odkopanego pola)
- Nie można zaznaczyć żółtego pola jako szare (czyli odkopać pola oznaczonego jako bomba)
Wbrew pozorom sprawa banalna, wystarczy dodać właściwość pointer-events: none stosując odpowiednie selektory:
//logic.scss @for $i from 0 through length($fields)-1 { #mine-field#{$i}:checked ~ .board-mines #mine-label#{$i} { visibility: visible; } #mine-field#{$i}:checked ~ #label#{$i} { pointer-events: none; } #field#{$i}:checked ~ .board-mines #mine-label#{$i} { pointer-events: none; } #field#{$i}:checked ~ #label#{$i} { pointer-events: none; z-index: 5; } }
Dobra dobra... gdzie te bomby?
Mamy planszę po której można klikać i... w sumie to tyle. Niewielka satysfakcja, więc pora podnieść poprzeczkę i dodać możliwość wybuchnięcia użytkownika za odkopanie bomby. Dodajmy więc do naszego PUG'a element sygnalizujący przegraną:
//index.pug - var fields = [1,0,0,0,1,0,0,1,0,0,1,1,0,0,1,0,0,1,0,1,0,0,1,1,1] // (...) form.board // (...) .loser-screen button(type="reset") Uh... :( One more time?
Przycisk typu reset wyczyści nam cały formularz (czyli naszą planszę) i przywróci grę do stanu wyjściowego. Co uważniejsi zauważyli pewnie dziwny rozkład tablicy fields - czyżby jakiś przekaz binarny? Nic bardziej mylnego! 1 oznacza bombę, 0 oznacza jej brak, a indeks w tablicy to numer pola (wszak iterujemy po nich w pętli, nieprawdaż?). Dodajmy więc informację do pól o ich bombowości:
//index.pug .board each field, index in fields input.field(id="field" + index, data-info=field == 1 ? 'mine' : 'yay', type="checkbox") input.field(id="mine-field" + index, type="checkbox") each field, index in fields label.field(id="label" + index, data-info=field == 1 ? 'mine' : 'yay', for="field" + index)
Teraz w naszym silniku gry, to znaczy w stylach, możemy stworzyć prosty "warunek" - loser-screen ma się pojawić kiedy poprzednio został zaznaczony checkbox z odpowiednią informacją wpisaną w atrybut data-info:
//_logc.scss input[data-info~="mine"]:checked ~ .loser-screen{ visibility: visible; z-index: 10; }
Parę dodatkowych, niewiele znaczących stylów i otrzymujemy ekran przegranego po kliknięciu w złe pole:
Daj mi podpowiedź, choćby najmniejszą!
Saper nie zdobyłby takiej popularności gdyby chodziło w nim jedynie o zgadywanie miejsc gdzie są bomby, dlatego dodajmy podpowiedzi dla użytkownika. Podobnie jak w klasycznej wersji gry, umieścimy liczby na odkrytych polach, które to będą oznaczać ilość bomb na polach sąsiednich. Jako, że układ planszy znamy z góry nie powinno być to trudne zadanie, jednak skorzystajmy z tego, że SASS pozwala na pozory programowania i napiszmy algorytm działający dla dowolnej planszy. Nie ma sensu wspinać się tutaj na wyżyny algorytmiki - taki skrawek kodu wystarczy w zupełności:
//_data.scss $fieldsPerRow: 5; $fields: 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1; $minesAround: (); @function neighborExists($field, $direction) { @if $direction == 'up' { @return $field - $fieldsPerRow > 0 } @if $direction == 'down' { @return $field + $fieldsPerRow <= length($fields) } @if $direction == 'right' { @return $field % $fieldsPerRow != 0 } @if $direction == 'left' { @return $field % $fieldsPerRow != 1 } } @function hasMine($field) { @return nth($fields, $field) == 1 } @for $i from 1 through length($fields) { $mines: 0; @if neighborExists($i, 'up') and hasMine($i - $fieldsPerRow) { $mines: $mines + 1; } @if neighborExists($i, 'down') and hasMine($i + $fieldsPerRow) { $mines: $mines + 1; } @if neighborExists($i, 'right') and hasMine($i + 1) { $mines: $mines + 1; } @if neighborExists($i, 'left') and hasMine($i - 1) { $mines: $mines + 1; } @if neighborExists($i, 'left') and neighborExists($i - 1, 'up') and hasMine($i - 1 - $fieldsPerRow) { $mines: $mines + 1; } @if neighborExists($i, 'right') and neighborExists($i + 1, 'up') and hasMine($i + 1 - $fieldsPerRow) { $mines: $mines + 1; } @if neighborExists($i, 'left') and neighborExists($i - 1, 'down') and hasMine($i - 1 + $fieldsPerRow) { $mines: $mines + 1; } @if neighborExists($i, 'right') and neighborExists($i + 1, 'down') and hasMine($i + 1 + $fieldsPerRow ) { $mines: $mines + 1; } $minesAround: append($minesAround, $mines, comma) } //_presentation.scss @for $i from 0 through length($fields)-1 { #field#{$i}:checked ~ #label#{$i} { background: #C2C3C5; line-height: 50px; color: #748F28; &[data-info~="yay"]{ &:after { content: '' + nth($minesAround, $i+1) } } } }
Brzydkie? Owszem - ale działa, a my nie tworzymy tutaj sterownika do rakiety, tylko Sapera w CSS.
Jedni przegrywają aby inni mogli wygrać!
Co to za gra w której można przegrać, a nie można wygrać? Skoro zrobiliśmy ekran przegranego, to analogiczny ekran dla wygranego nie powinien być dużym wyzwaniem. Aby uznać zwycięstwo musimy sprawdzić czy wszystkie niebombowe pola są odkryte:
$yayList: (); @for $i from 1 through length($fields) { $yayList: append($yayList, unquote('#field#{$i}:checked ~ '), space); } #{$yayList} .winner-screen { visibility: visible; z-index: 10; }
Daleko jeszcze do końca?
Do końca wpisu jeszcze kawałek, ale pytanie jak daleko jeszcze do końca gry? Przydałby się licznik min pozostałych do znalezienia... Tylko czy w CSS można coś zliczać? Okazuje się że można dzięki "zmiennej" counter. Zdefiniujmy więc minesCount i fieldsCount oraz odpowiednio manipulujmy nimi w zależności od zaznaczonych pól na planszy:
//_data.scss $minesCount: 0; @each $field in $fields { @if $field == 1 { $minesCount: $minesCount + 1 } } //_logic.scss body { counter-reset: minesCount fieldsCount; } .game-stats{ .fields { &:after { content: 'Digged fields: ' counter(fieldsCount) '/' + (length($fields) - $minesCount) } } .mines { &:after { content: 'Marked mines: ' counter(minesCount) '/' + $minesCount } } } @for $i from 0 through length($fields)-1 { #mine-field#{$i}:checked ~ .board-mines #mine-label#{$i} { counter-increment: minesCount } #field#{$i}:checked ~ #label#{$i} { counter-increment: fieldsCount } }
Na koniec jeszcze kilka pociągnięć pędzlem, żeby całość lepiej się prezentowała...
To już jest koniec...
Jeśli dotrwałeś do tego momentu - należą się wyrazy uznania. Być może ktoś spyta - po co takie coś? Przecież plansza jest ciągle taka sama. Odpowiedź jest prosta: po nic ;) Ot ciekawostka, przy okazji pokazująca kilka mniej znanych właściwości CSS. Czy dało się to zrobić lepiej? Tak, jestem przekonany że tak, ale z założenia miałem poświęcić na to nie więcej niż 2 wieczory i oto rezultat. Daleki od ideału ale przekazujący ideę. We wpisie z wiadomych względów umieściłem tylko strzępki kodu dlatego też pełny przykład umieszczam na moim GitHubie.
Świąteczny bonus!
Jako że święta za pasem, przygotowałem też świąteczną odsłonę gry. W tym celu zdefiniowałem dodatkowy zestaw stylów i stosuje je jeśli zaznaczony został odpowiedni checkbox w lewym górnym rogu ekranu. W tym miejscu podziękowania dla mojego znajomego, Radka, za przygotowanie grafik prezentów - wyszły bombowo ;)
//_layout.christmas.scss #christmas:checked ~ .wrapper { background: url('../img/christmas/bg.jpg') no-repeat; background-size: cover; .board, .board-mines{ width: 290px; } label.field{ border: none; margin: 4px; } label.field { background: url('../img/christmas/field.png') no-repeat; background-size: cover; } label.action-type { border: 1px solid #550900; @include background(radial-gradient(#EA6165, #a30911)); } }