Zajęcia 9: rysowanie w kolorze

2022/12/20

Omówienie zadania: Trójkąt

Zacznijmy od “nudnych” części programu: wczytania wysokości trójkąta, sprawdzeniu, czy użytkownik nie wpisał głupot, itp.:

fun main() = terminal {
    do {
        print("Podaj wysokość trójkąta: ")
        val wysokość = readln().toIntOrNull()

        if (wysokość != null && wysokość > 0) {
            // tu będziemy rysować coś w ten deseń:
            //
            // *******
            //  *****
            //   ***
            //    *
        }

        println()
    } while (true)
}

Zauważcie następującą rzecz: każda linijka składa się z nieparzystej liczby gwiazdek poprzedzonych spacjami. W każdej linii jest o jedną spację więcej i o dwie gwiazdki mniej niż w poprzedniej. Zaczynamy od zera spacji i… no właśnie, ilu gwiazdek?

Popatrzmy na dwa mniejsze trójkąty:

strona lewa         strona prawa

   ****                ****
    ***                ***
     **                **
      *                *

Każda strona ma w pierwszej linijce tyle samo gwiazdek, ile wynosi wysokość trójkąta, przy czym jedna gwiazdka jest wspólna dla obu stron. Tak więc w pierwszej linijce dużego trójkąta, który chcemy narysować będzie (wysokość * 2) - 1 gwiazdek.

fun main() = terminal {
    do {
        print("Podaj wysokość trójkąta: ")
        val wysokość = readln().toIntOrNull()

        if (wysokość != null && wysokość > 0) {
            var liczbaSpacji = 0
            var liczbaGwiazdek = (wysokość * 2) - 1
            
            for (i in 1..wysokość) {
                // wypisz spacje
                // wypisz gwiazdki
                // przejdź do następnej linijki
                
                liczbaSpacji += 1
                liczbaGwiazdek -= 2
            }
        }

        println()
    } while (true)
}

Do utworzenia tekstu składającego się z powtórzeń tego samego znaku możemy użyć funkcji String.repeat(n: Int): String:

fun main() = terminal {
    do {
        print("Podaj wysokość trójkąta: ")
        val wysokość = readln().toIntOrNull()

        if (wysokość != null && wysokość > 0) {
            var liczbaSpacji = 0
            var liczbaGwiazdek = (wysokość * 2) - 1
            
            for (i in 1..wysokość) {
                print(" ".repeat(liczbaSpacji))
                print("*".repeat(liczbaGwiazdek))
                println()
                
                liczbaSpacji += 1
                liczbaGwiazdek -= 2
            }
        }

        println()
    } while (true)
}

Nowy materiał

Rysowanie w kolorze

Programy, które pisaliśmy do tej pory, zawsze wypisywały tekst z góry na dół, korzystając tylko z jednego koloru czcionki, co bardzo ograniczało ich szatę graficzną. Dobra wiadomość jest taka, że nasze narzędzie terminal obsługuje drobną część tzw. ANSI escape codes, które umożliwią nam robienie nieco ciekawszych rzeczy. Zła wiadomość jest taka, że korzystanie z nich jest dość kłopotliwe.

Popatrzcie na następujący program:

fun main() = terminal {
    println("Ten tekst zaraz zniknie! Naciśnij tylko enter...")
    readln()
    
    print("\u001b[2J")
    println("Ha! A nie mówiłem!")
}

Tekst \u001b[2J to “magiczna” komenda, której wypisanie czyści ekran i przesuwa kursor do lewego górnego rogu.

Jej pierwsza część, \u001b to pojedynczy znak, tylko zapisany w specjalny sposób. Każdy znak, który wypisujemy na ekran, ma przypisaną do siebie liczbę. Na przykład literka A ma przypisany numer 65. Zamiast print('A') możemy też napisać print('\u0041'). \u oznacza “następne cztery znaki potraktuj jako liczbę w systemie szesnastkowym”. 0041 w systemie szesnastkowym to właśnie 65 w systemie dziesiętnym, więc print('\u0041') wypisze na ekran literkę A.

Oczywiście nikt o zdrowych zmysłach nie będzie używał tego sposobu do zapisu zwykłego tekstu, ale może być on przydatny, jeśli będziemy chcieli zmusić komputer do wypisania na ekran jakiegoś znaku, którego nie da się wpisać z klawiatury. \u001b z naszej “magicznej” komendy to właśnie tego rodzaju znak: 1b to kod znaku “Escape”, od którego zaczynają się wszystkie ANSI escape codes, czyli nasze “magiczne” terminalowe komendy:

Żeby nie musieć pamiętać tych cudacznych sekwencji, napiszemy nasze pierwsze narzędzie, a przy okazji nauczymy się kilku nowych rzeczy.

Narzędzie Ansi

Na dobry początek stwórzmy osobny plik z funkcją, która zwróci komendę ANSI do czyszczenia ekranu:

package jerz.codes.narzedzia

fun wyczyśćEkran(): String {
    return "\u001b[2J"
}

Jest to bardzo prosta funkcja składająca się tylko z jednego wyrażenia return. Kotlin oferuje specjalną składnię dla takich przypadków. Możemy zmienić naszą funkcję w następujący sposób:

fun wyczyśćEkran(): String = "\u001b[2J"

A ponieważ na pierwszy rzut oka widać, że ta funkcja zwraca tekst, możemy pominąć : String i skrócić to jeszcze bardziej do fun wyczyśćEkran() = "\u001b[2J".

Dzięki temu nasz program stanie się nieco czytelniejszy:

fun main() = terminal {
    println("Ten tekst zaraz zniknie! Naciśnij tylko enter...")
    readln()

    print(wyczyśćEkran())
    println("Ha! A nie mówiłem!")
}

Będziemy mieli więcej takich funkcji i dobrze byłoby je jakoś pogrupować, żeby łatwo było je znaleźć. Zacznijmy od czegoś takiego:

package jerz.codes.narzedzia

class Ansi {
    fun wyczyśćEkran(): String = "\u001b[2J"
}

Stworzyliśmy właśnie nowy typ Ansi, dzięki czemu wszystkie powiązane metody będą w jednym miejscu i IntelliJ po napisaniu Ansi(). będzie wyświetlał wszystkie dostępne w naszym narzędziu funkcje. Sposób użycia nie jest jednak idealny:

println("To zaraz zniknie! Naciśnij enter...")
readln()

print(Ansi().wyczyśćEkran())
println("Ha! A nie mówiłem!")

Ansi to typ danych, i za każdym razem, kiedy chcemy użyć jakiejś funkcji zdefiniowanej w tym typie, musimy stworzyć obiekt tego typu przy użyciu konstruktora Ansi(). Wprowadźmy drobną zmianę:

package jerz.codes.narzedzia

object Ansi {
    fun wyczyśćEkran(): String = "\u001b[2J"
}

Słówko object działa podobnie jak class pod tym względem, że też służy ono do zdefiniowania nowego typu danych. Różnica jest taka, że jednocześnie tworzymy obiekt tego typu o nazwie takiej samej jak nazwa typu. Ponadto nie da się utworzyć innych obiektów tego typu: object Ansi oznacza, że jest tylko jeden obiekt typu Ansi, i nazywa się on Ansi. Upraszcza to nieco jego użycie:

println("To zaraz zniknie! Naciśnij enter...")
readln()

print(Ansi.wyczyśćEkran()) // nie ma nawiasów `()` po `Ansi`
println("Ha! A nie mówiłem!")

object vs. class

W zrozumieniu różnicy pomiędzy object i class może pomóc wam następujący przykład:

class KrzesłoZIkei to takie krzesło, które jest masowo produkowane w fabryce. Dowolny egzemplarz krzesła nie różni się szczególnie od innych.

object UnikatoweKrzesłoMisternieWystruganePrzezWujkaWładkaWJegoWarsztaciku to jedyne w swoim rodzaju krzesło. Nie ma drugiego takiego krzesła, nawet gdyby wujaszek bardzo się postarał, co najwyżej wyjdzie mu object ZupełnieInneUnikatoweKrzesłoWładkowejProdukcji.

Dygresja: dlaczego return zamiast print?

Możecie się zastanawiać, dlaczego nasza funkcja wyczyśćEkran zwraca komendę do czyszczenia obrazu, zamiast od razu wypisać ją na ekran przy użyciu print‘a. Będzie nam to potrzebne na późniejszych zajęciach, gdy będziemy zajmować się animacjami.

Pozostałe funkcje w narzędziu Ansi

Dodajmy metodę do zmiany koloru tekstu:

package jerz.codes.narzedzia

object Ansi {
    fun wyczyśćEkran(): String = "\u001b[2J"
    fun kolorTekstu(kolor: Color?) = 
}

Użyłem tutaj typu Color, który musimy zaimportować (będzie się on “świecić” na czerwono; po najechaniu na ten tekst kursorem możemy nacisnąć Alt + Enter, a IntelliJ doda na górze pliku linijkę import java.awt.Color). Jest to typ dostępny w standardowych bibliotekach (tak jak List, IntRange, itp.) i prościej skorzystać z niego niż pisać od zera własny.

Żeby utworzyć obiekt typu Color, możemy użyć konstruktora Color(r: Int, g: Int, b: Int), który przyjmuje trzy składowe koloru w postaci liczb z przedziału od 0 do 255. Jeśli podany kolor będzie nullem, przywrócimy domyślny kolor tekstu sekwencją \u001b[39m. W przeciwnym razie użyjemy sekwencji \u001b[38;2;r;g;bm, zastępując r, g i b wartościami wyciągniętymi z obiektu kolor przy użyciu kolor.red, kolor.blue itd.:

package jerz.codes.narzedzia

object Ansi {
    fun wyczyśćEkran() = "\u001b[2J"
    fun kolorTekstu(kolor: Color?) = when (kolor) {
        null -> "\u001b[39m"
        else -> "\u001b[38;2;${kolor.red};${kolor.green};${kolor.blue}m"
    }
}

Pozostałe funkcje dopiszcie sami!

Choinka

Możemy troszkę pozmieniać program “Trójkąt”, aby wypisać na ekran kolorową choinkę:

fun main() = terminal {
    val wysokośćChoinki = 10
    
    // narysuj gwiazdkę
    print(Ansi.kolorTekstu(Color.YELLOW))
    print(" ".repeat(wysokośćChoinki) + " * ")
    println()
    
    // narysuj gałązki
    var liczbaSpacji = wysokośćChoinki
    var liczbaGałązek = 3

    print(Ansi.kolorTekstu(Color(26, 80, 26)))
    for (i in 1..wysokośćChoinki) {
        print(" ".repeat(liczbaSpacji))
        print("#".repeat(liczbaGałązek))
        println()
        
        // ponieważ rysujemy "trójkąt" w drugą stronę,
        // zmniejszamy liczbę spacji
        // i zwiększamu liczbę gałązek
        liczbaSpacji -= 1
        liczbaGałązek += 2
    }
    
    // narysuj pień i podstawkę
    print(Ansi.kolorTekstu(Color(0x4F, 0x39, 0x08)))
    println(" ".repeat(wysokośćChoinki - 1) + " III ")
    println(" ".repeat(wysokośćChoinki - 1) + " III ")
    print(Ansi.kolorTekstu(Color.DARK_GRAY))
    println(" ".repeat(wysokośćChoinki - 1) + "=====")
}

Zwróćcie uwagę na kilka różnych sposobów tworzenia obiektu typu Color: