C# - Matrix

Od premiery filmu braci Wachowskich minęło już 20 lat. Za datę przyjmuje sie 24 marca 1999 roku – tydzień później film był już dostępny w dystrybucji kinowej.

Spadające zielone znaki na początku wszystkich filmów Matrix stały się jednym z najbardziej rozpoznawalnych obrazów w historii kinematografii. Zastanawialiście się kiedyś co stanowi ten kod? Czy jest to może niezwykle skomplikowane równanie matematyczne, a może coś zupełnie innego...? Czytając jeden z artykułów na https://www.cnet.com natrafiłem na wywiad z Simonem Whiteley’em (odpowiedzialny za stworzenie kodu), który powiedział: „Lubię mówić wszystkim, że kod Matrixa zrobiony jest z japońskich przepisów na sushi". Wydaje się, że taka odpowiedź rozwiewa wszystkie nasze pytania.

Ja jednak zawsze chciałem spróbować napisać taki kod. Wydawało się to niezwykle skomplikowane i trudne do przygotowania ale szereg artykułów pojawiających się z okazji 20 lat po premierze filmu natchnął mnie do podjęcia próby i napisania artykułu.

Aplikacja konsolowa

Podstawą tej aplikacji jest drukowanie przypadkowych znaków na przypadkowych wysokościach ale na wszystkich pozycjach wzdłuż szerokości okna naszej konsoli.

Przejdźmy zatem do przygotowania ustawień początkowych naszej aplikacji. Jest to odpowiednie ustawienie konsoli, wygaszenie kursora, etc:

// Ustawiamy parametry podstawowe okna naszej konsoli
private static void PrepareConsole()
{
    Console.Title = "Matrix";

    Console.WindowLeft = Console.WindowTop = 0;
    Console.WindowHeight = Console.BufferHeight = Console.LargestWindowHeight;
    Console.WindowWidth = Console.BufferWidth = Console.LargestWindowWidth;
    Console.ForegroundColor = ConsoleColor.DarkGreen;
    Console.CursorVisible = false;

    Console.Clear();
}
W kolejnym kroku musimy przygotować dwie tablice, które będą reprezentowały pozycję naszego kursora a zatem i pozycje zestawu znaków dodawanego do ekranu naszej konsoli wraz z każdą kolejną iteracją. Jedno przejście pętli będzie powodowało dodanie dwóch znaków (ciemno-zielonego i zielonego) na różnych wysokościach ale na tej samej szerokości oraz jeden biały znak (o tym później).

Samo zdefiniowane losowych wysokości nie pozwoli na osiągnięcie zadowalającego efektu. Wstawiając kolejne znaki będziemy mieli wrażenie, że są one dodane w linii pionowej. Kiedy jednak dodamy pusty znak zauważymy, że wstawione ciągi znaków odsuwają się od siebie zamiast na siebie nachodzić. Równie istotna jest w tym momencie odmienna kolorystka znaków. Iteracje składają się z dodania znaku ciemno-zielonego, zielonego oraz znaku w kolorze tła konsoli – wyłączając oczywiście iteracje spełniające warunek: x % 16 == 15. Wówczas dodajemy znak biały, zielony oraz pusty:

private static void UpdateConsoleView(int width, int height, int[] y, int[] l)
{
    int x;
    thistime = !thistime;
    for (x = 0; x < width; ++x)
    {
        // Co 15 iteracji dodajemy na ekranie naszej konsoli biały znak
        if (x % 16 == 15)
            Console.ForegroundColor = ConsoleColor.White;
        else
        {
            Console.ForegroundColor = ConsoleColor.DarkGreen;
            Console.SetCursorPosition(x, renderWithinBoundaries(y[x] - 2 - (l[x] / 40 * 2), height));
            Console.Write(Regex.Unescape(R));
            Console.ForegroundColor = ConsoleColor.Green;
        }
        Console.SetCursorPosition(x, y[x]);
        Console.Write(Regex.Unescape(R));
        y[x] = renderWithinBoundaries(y[x] + 1, height);
        Console.SetCursorPosition(x, renderWithinBoundaries(y[x] - l[x], height));
        Console.Write(' ');
    }
}
Aby znaki były wstawione na losowej wysokości należało przygotować dwie tablice. Pierwsza z nich zawiera losowe wysokości z dostępnego zakresu a kolejna pozwala na dodanie dwóch następnych znaków zgodnie z określonym algorytmem:
private static void Initialize(out int width, out int height, out int[] y, out int[] l)
{
    // wysokość całego okna
    height = Console.WindowHeight;
    width = Console.WindowWidth - 1;

    // połowa wysokości okna
    int h1 = height / 2;

    // ćwierć wysokości całego okna
    int h2 = h1 / 2;

    // Parametry h1 i h2 będą odpowiedzialne za wstawienia białych znaków (ich ułożenie ma znaczenie ponieważ znaki
    // te wyróżniają się na tle koloru zielonego i jego ciemnego odcienia

    y = new int[width];
    l = new int[width];
    int x;

    for (x = 0; x < width; ++x)
    {
        // Pierwszy tablica zawiera losowe wysokości z określonego przedziału
        y[x] = r.Next(height);

        // Druga tablica odpowiada za wartości użyte do wyliczeń matematycznych
        // Raz na 15 iteracji liczby będą z dolnego przedziału maksymalnej wysokości
        l[x] = r.Next(h2 * ((x % 16 != 15) ? 2 : 1), h1 * ((x % 16 != 15) ? 2 : 1));
    }
}
Bardzo istotna metoda kontroluje wyświetlanie się znaków w obrębie okna konsoli. Jeżeli wysokość przyjmie ujemną wartość musimy dodać wartość maksymalnej wysokości tak, żeby wyświetlony znak był dla nas widoczny. Wychodząc poza zakres wysokości okna wygenerujemy również błąd:
public static int renderWithinBoundaries(int renderHeightPosition, int consoleWindowHeight)
{
    renderHeightPosition = renderHeightPosition % consoleWindowHeight;

    // wartość mniejsza niż 0 nie zostanie wyświetlona poprawnie na ekranie
    // dodajemy wartość wysokości
    if (renderHeightPosition < 0)
        return renderHeightPosition + consoleWindowHeight;
    else
        return renderHeightPosition; // normalna wartość
}

Dla nas najwazniejsze, jest każde kolejne wywołanie metody UpdateConsoleView(...). Każda kolejna iteracja powoduje dodanie 2(widocznych) znaków na tej samej szerokości. Pierwsza interacja w moim przypadku wygląda tak: Matrix: pierwsza interacja Kolejna interacja przedstawia się w następujący sposób: Matrix: pierwsza interacja A jeżeli pozwolimy na dalsze wykonywanie programu uzyskamy taki efekt:

Poniżej cały kod programu dla łatwiejszej interpretacji i samodzielnych eksperymentów:

using ConsoleApplication1;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace MatrixRain
{
    class Program
    {
        private static int sleepTime;
        public static List<string> catacana;

        // Ustawiamy parametry podstawowe okna naszej konsoli
        private static void PrepareConsole()
        {
            Console.Title = "Matrix";

            Console.WindowLeft = Console.WindowTop = 0;
            Console.WindowHeight = Console.BufferHeight = Console.LargestWindowHeight;
            Console.WindowWidth = Console.BufferWidth = Console.LargestWindowWidth;
            Console.ForegroundColor = ConsoleColor.DarkGreen;
            Console.CursorVisible = false;

            Console.Clear();

            catacana = new GenerateKatanaFromUnicode().KatakanaCollection;

        }

        static void Main(string[] args)
        {
            PrepareConsole();

            int width, height;
            int[] y;
            int[] l;

            // Metoda służąca do inicjowania losowych wartości wysokości dla całego spektrum szerokości okna naszej konsoli
            Initialize(out width, out height, out y, out l);

            while (true)
            {
                DateTime t1 = DateTime.Now;

                // metoda UpdateConsoleView reprezentuje każdy krok (dodanie 3 znaków w danej "kolumnie")
                UpdateConsoleView(width, height, y, l);
                sleepTime = 100 - (int)((TimeSpan)(DateTime.Now - t1)).TotalMilliseconds;

                // zmienna sleepTime pozwala nam na zatrzymanie ruchu konsoli na określoną liczbę milisekund - pozwala to
                // na kontrolę szybkość działania animacji, tj. realizacji następnych kroków
                if (sleepTime > 0)
                    System.Threading.Thread.Sleep(sleepTime);

                // Sprawdzamy czy użytwkonik wcisnął jakiś przycisk
                // Escape pozwala na zakończenie działania pracy aplikacji konsolowej
                if (Console.KeyAvailable)
                    if (Console.ReadKey().Key == ConsoleKey.Escape)
                    {
                        // exitCode: 0 - system poprawnie zakończył swoje działanie
                        System.Environment.Exit(0);
                    }
            }
        }

        private static void UpdateConsoleView(int width, int height, int[] y, int[] l)
        {
            int x;

            for (x = 0; x < width; ++x)
            {
                // Co 15 iteracji dodajemy na ekranie naszej konsoli biały znak
                if (x % 16 == 15)
                    Console.ForegroundColor = ConsoleColor.White;
                else
                {
                    Console.ForegroundColor = ConsoleColor.DarkGreen;
                    Console.SetCursorPosition(x, renderWithinBoundaries(y[x] - 2 - (l[x] / 40 * 2), height));
                    Console.Write(singleChar);
                    Console.ForegroundColor = ConsoleColor.Green;
                }
                Console.SetCursorPosition(x, y[x]);
                Console.Write(singleChar);
                y[x] = renderWithinBoundaries(y[x] + 1, height);
                Console.SetCursorPosition(x, renderWithinBoundaries(y[x] - l[x], height));
                Console.Write(' ');
            }
        }

        private static void Initialize(out int width, out int height, out int[] y, out int[] l)
        {
            // wysokość całego okna
            height = Console.WindowHeight;
            width = Console.WindowWidth - 1;

            // połowa wysokości okna
            int h1 = height / 2;

            // ćwierć wysokości całego okna
            int h2 = h1 / 2;

            // Parametry h1 i h2 będą odpowiedzialne za wstawienia białych znaków (ich ułożenie ma znaczenie ponieważ znaki
            // te wyróżniają się na tle koloru zielonego i jego ciemnego odcienia

            y = new int[width];
            l = new int[width];
            int x;

            for (x = 0; x < width; ++x)
            {
                // Pierwszy tablica zawiera losowe wysokości z określonego przedziału
                y[x] = r.Next(height);

                // Druga tablica odpowiada za wartości użyte do wyliczeń matematycznych
                // Raz na 15 iteracji liczby będą z dolnego przedziału maksymalnej wysokości
                l[x] = r.Next(h2 * ((x % 16 != 15) ? 2 : 1), h1 * ((x % 16 != 15) ? 2 : 1));
            }
        }

        static Random r = new Random();

        static char singleChar
        {
            get
            {
                int t = r.Next(10);
                if (t <= 2)
                    return (char)('0' + r.Next(10));
                else if (t <= 4)
                    return (char)('a' + r.Next(27));
                else if (t <= 6)
                    return (char)('A' + r.Next(27));
                else
                    return (char)(r.Next(32, 255));
            }
        }

        public static int renderWithinBoundaries(int renderHeightPosition, int consoleWindowHeight)
        {
            renderHeightPosition = renderHeightPosition % consoleWindowHeight;

            // wartość mniejsza niż 0 nie zostanie wyświetlona poprawnie na ekranie
            // dodajemy wartość wysokości
            if (renderHeightPosition < 0)
                return renderHeightPosition + consoleWindowHeight;
            else
                return renderHeightPosition; // normalna wartość
        }
    }
}

To jeszcze nie koniec...

Efekt działania algorymtu możecie zobaczyć powyżej. Wygląda to całkiem nieźle, widzimy płynne przejścia. Mi jednak czegoś brakowało...znaków z japońskiego alfabetu.

Zdecydowałem się na alfabet Katakany. W poniższym kodzie możecie znaleźć komentarz gdzie znalazłem zapis znaków w systemie Unicode. Przygotowałem szybką podwójną pętlę, żeby wszystkie znaki były zakodowane na liście. Możecie również zobaczyć pętlę foreach, której celem jest sprawdzenie czy takie znaki wyświetlają się na Waszym systemie. Jeżeli tak, można przejść do kolejnych kroków. W przeciwnym wypadku należy zmienić ustawienia lokalne swojego systemu a następnie uruchomić ponownie komputer: Ustawienia regionalne systemu

Proponuje utworzyć aplikację konsolową i przetestować poniższy kod:

public class GenerateKatanaFromUnicode
{
    public List<string> KatakanaCollection;
    public GenerateKatanaFromUnicode()
    {
        InitializeKatanaList();
    }

    private void InitializeKatanaList()
    {
        KatakanaCollection = new List<string>();
        string katakanaCharUnicode = String.Empty;

        // Source: https://jrgraphix.net/r/Unicode/30A0-30FF
        for (char i = 'A'; i <= 'F'; i++)
        {
            for (int j = 0; j <= 9; j++)
            {
                katakanaCharUnicode = $@"\u30{i}{j}";
                KatakanaCollection.Add(katakanaCharUnicode);
            }
        }

        for (char i = 'A'; i <= 'F'; i++)
        {
            for (char j = 'A'; j <= 'F'; j++)
            {
                katakanaCharUnicode = $@"\u30{i}{j}";
                KatakanaCollection.Add(katakanaCharUnicode);
            }
        }

        foreach (var item in KatakanaCollection)
        {
            Console.Write(Regex.Unescape(item) + " ");
        }

        Console.ReadKey();
    }
}
Jeżeli znaki wyświetlają się poprawnie możecie użyć powyższej klasy oraz całego kodu załączonego powyżej w celu uzyskania jeszcze lepszego efektu. Przed pierwszym wykonaniem metody UpdateConsoleView(...) należy skorzystać z powyższej klasy, aby utworzyć listę znaków alfabetu Katakany a następnie zwracać je zamiast obecnej implementacji:
static char singleChar
{
    get
    {
        int t = r.Next(10);
        if (t <= 2)
            return (char)('0' + r.Next(10));
        else if (t <= 4)
            return (char)('a' + r.Next(27));
        else if (t <= 6)
            return (char)('A' + r.Next(27));
        else
            return (char)(r.Next(32, 255));
    }
}
Pamiętajcie tylko o użyciu Regex.Unescape(...) a uzyskacie taki efekt:

Podsumowanie

Powyższy, kompletny kod, jest interpretacją różnych kodów źródłowych znalezionych w internecie. Niektóre z projektów charakteryzowały się nakładaniem się kolejnych znaków na siebie co nie wyglądało zbyt dobrze. Jeden z projektów działał przez określoną ilość czasu po czym większość znaków znikała a na ekranie pozostawał tylko napis Matrix. Dokonałem własnej interpretacji różnych projektów ponieważ moim celem jest przygotowanie wygaszacza ekranu wykorzystującego powyższy kod. Temat ten będzie również szeroko omówiony w jednym z kolejnych wpisów dotyczących języka C#.