• Czas czytania ~12 min
  • 01.06.2023

Dzisiaj tworzymy proceduralnie generowaną grę 2D z PHP. Jest to prosta gra, która koncentruje się na gromadzeniu zasobów na proceduralnie generowanej mapie. Jeśli nie znasz tego terminu: jest to mapa generowana przez kod, na podstawie ziarna. Każde ziarno wygeneruje całkowicie unikalną mapę. Wygląda to mniej więcej tak:

Jak więc przejść od zwykłego PHP do takiej mapy? Wszystko zaczyna się od hałasu.

# Generowanie

szumów Wyobraźmy sobie, że mamy siatkę 150 na 100 pikseli. Każdy piksel tworzy punkt naszej mapy.

$pixels = [];
for($x = 0; $x < 150; $x++) {
    for($y = 0; $y < 100; $y++) {
        $pixels[$x][$y] = drawPixel($x, $y, 0); 
    }
}

Na razie drawPixel funkcja wygeneruje piksel, div który można ułożyć na siatce CSS. Możemy refaktoryzować do późniejszego wykorzystaniacanvas, ale możliwość korzystania z wbudowanej siatki CSS oszczędza dużo czasu.

function drawPixel(int $x, int $y): string
{
    return <<<HTML
    <div style="--x: {$x}; --y: {$y};"></div>
    HTML;
}

To jest plik szablonu:To jest wynik:

<style>
    :root {
        --pixel-size: 9px;
        --pixel-gap: 1px;
        --pixel-color: #000;
    }
    .map {
        display: grid;
        grid-template-columns: repeat({{ count($pixels) }}, var(--pixel-size));
        grid-auto-rows: var(--pixel-size);
        grid-gap: var(--pixel-gap);
    }
    .map > div {
        width: var(--pixel-size);
        height: 100%;
        grid-area: var(--y) / var(--x) / var(--y) / var(--x);
        background-color: var(--pixel-color);
    }
</style>
<div class="map">
    @foreach($pixels as $x => $row)
        @foreach($row as $y => $pixel)
            {!! $pixel !!}
        @endforeach
    @endforeach
</div>

Przy okazji, pozbędę się luk między pikselami, dodałem je tylko, aby pokazać, że są to rzeczywiście oddzielne komórki siatki.

Bawmy się naszą siatką. Zaczniemy od przypisania wartości od 0 do 1 dla każdego piksela. Użyjemy klasy o nazwieNoise, która pobiera punkt piksela (współrzędne X / Y) i zwraca wartość dla tego punktu. Najpierw zwrócimy losową wartość.

final readonly class Noise
{
    public function __construct(
        private int $seed,
    ) {}
    
    public function generate(Point $point): float
    {
        return rand(1, 100) / 100;
    }
}
// …
drawPixel($x, $y, $noise->generate($x, $y)); 

Oto wynik:

poleganie na losowości nie zaprowadzi nas jednak zbyt daleko. Chcemy, aby dane ziarno generowało w kółko tę samą mapę. Zamiast więc losowości napiszmy funkcję skrótu: funkcję, która dla dowolnego punktu i ziarna będzie generować tę samą wartość w kółko. Możesz to zrobić tak proste, jak pomnożenie ziarna przez współrzędne x i y i przekształcenie go w ułamek:

public function generate(Point $point): float
{
    $hash = $this->seed * $point->x * $point->y;
    
    return floatval('0.' . $hash);
}

Wynik nie wydaje się jednak wystarczająco losowy. Pamiętajmy, że chcemy wygenerować mapę świata: chcemy trochę losowości, ale także pewnej spójności. Tak więc nasza funkcja skrótu będzie musiała być nieco bardziej złożona.

Spróbujmy istniejącej funkcji skrótu, której nie musimy sami wymyślać. Prawdopodobnie chcemy wydajnego, ponieważ generujemy skrót dla tysięcy pikseli. PHP obsługuje xxHash, który jest "niezwykle szybkim algorytmem mieszania". Spróbujmy.

private function hash(Point $point): float
{
    $hash = bin2hex(
        hash(
            algo: 'xxh32',
            data: $this->seed * $point->x * $point->y,
        )
    );
    $hash = floatval('0.' . $hash);
    return $hash;
}

Ten hałas wygląda obiecująco: jest dość losowy, ale zawsze daje ten sam wynik dla danego nasiona. Ale przejście od tego do spójnej mapy świata nadal wydaje się skokiem. Zmieńmy naszą funkcję skrótu, aby zwracała ten sam kolor w kwadracie 10 pikseli:Oto wynik:Aha, przy okazji:

private function hash(Point $point): float
{
    $baseX = ceil($point->x / 10);
    $baseY = ceil($point->y / 10);
    $hash = bin2hex(
        hash(
            algo: 'xxh32',
            data: $this->seed * $baseX * $baseY,
        )
    );
    $hash = floatval('0.' . $hash);
    return sqrt($hash);
}

Oto wynik:

biorę pierwiastek kwadratowy z hasha, aby trochę zwiększyć wszystkie wartości. Będzie to przydatne w przyszłości, ale nie jest konieczne. Bez pierwiastka kwadratowego mapa wygląda tak:

Wyobraźmy sobie coś przez chwilę. Załóżmy, że wszystkie piksele o wartości wyższej niż 0,6 są uważane za ziemię, a wszystkie piksele o niższej wartości są uważane za wodę. Dokonajmy pewnych zmian w naszej drawPixel metodzie, aby odzwierciedlić to zachowanie:

function drawPixel(int $x, int $y, float $value): string
{
    $hexFromNoise = hex($value);
    
    $color = match(true) {
        $noise < 0.6 => "#0000{$hexFromNoise}", // blue
        default => "#00{$hexFromNoise}00", // green
    };
    
    return <<<HTML
    <div style="--x: {$x}; --y: {$y}; --pixel-color: {$color}"></div>
    HTML;
}

Nawiasem mówiąc, ta hex funkcja konwertuje wartość z zakresu od 0 do 1 na dwucyfrową szesnastkową. Wygląda to tak:Wynik wygląda już o wiele bardziej jak mapa:

function hex(float $value): string
{
    if ($value > 1.0) {
        $value = 1.0;
    }
    $hex = dechex((int) ($value * 255));
    if (strlen($hex) < 2) {
        $hex = "0" . $hex;
    }
    return $hex;
}

Ok, całkiem ładnie! Ale te ostre krawędzie nie wyglądają realistycznie. Czy możemy znaleźć sposób, aby przejścia między krawędziami były bardziej płynne?

# Lerp

Czas na matematykę. Powiedzmy, że mamy dwie wartości: 0.34 i 0.78. Chcemy poznać wartość dokładnie pośrodku między tymi dwoma. Jak to robimy?

Cóż, jest na to prosty wzór matematyczny. Nazywa się to "interpolacją liniową" - w skrócie "LERP": Więc, biorąc pod uwagę liczbę (), liczbę () i ułamek (0.780.340.5, znany również jako:

function lerp(float $a, float $b, float $fraction): float
{
    return $a + $fraction * ($b - $a);
}
lerp(0.34, 0.78, 0.5); // 0.56

"połowa"); otrzymujemy 0.56 — liczbę $a $b dokładnie pośrodku pomiędzy 0.34 i .

0.78 Dzięki części ułamkowej w naszym wzorze lerp możemy określić wartość w dowolnym miejscu między tymi punktami, a nie tylko środek:

lerp(0.34, 0.78, 0.25); // 0.45

Ok, więc dlaczego jest to ważne? Cóż, możemy użyć naszej funkcji lerp, aby wygładzić krawędzie! Wróćmy do naszego wzorca szumu i wyjaśnijmy:

Powiedzmy, że zamiast kolorować każdy piksel w tej siatce, kolorujemy piksel tylko wtedy, gdy jest dokładnie na siatce 10x10. Innymi słowy: gdy jego współrzędne x i y są dzielone przez 10.

final readonly class Noise
{
    public function generate(Point $x): float
    {
        if ($point->x % 10 === 0 && $point->y % 10 === 0) {
            return $this->hash($point);
        } else {
            return 0.0;
        }
    }
    // …
}

Oto krata:Wyobraźmy sobie,

że te piksele są $a granicami i $b przechodzimy do naszej funkcji lerp. Dla każdego piksela trywialne jest określenie tych otaczających "stałych" punktów (znajdują się one na stałej siatce 10x10), a także możemy obliczyć względną odległość piksela do tych punktów. Możemy użyć skrótu tych stałych punktów i odległości od dowolnego piksela do tych punktów jako wartości wejściowych dla naszej funkcji lerp. Rezultatem będzie wartość, która znajduje się gdzieś pomiędzy wartościami naszych dwóch punktów krawędziowych - innymi słowy: płynne przejście.

Najpierw użyjemy naszej funkcji lerp na osi y (gdy x można podzielić przez 10). Określimy względny górny i dolny punkt na naszej "siatce", obliczymy odległość między naszym bieżącym punktem a górnym punktem, a następnie użyjemy naszej funkcji lerp, aby określić właściwą wartość między górnym i dolnym punktem, z tym ułamkiem odległości: Oto wynik, możesz już zobaczyć płynne przejście w liniach:

if ($point->x % 10 === 0 && $point->y % 10 === 0) {
    $noise = $this->hash($point);
} elseif ($point->x % 10 === 0) {
    $topPoint = new Point(
        x: $point->x,
        y: (floor($point->y / 10) * 10), 
        // The closest point dividable by 10, above our current pixel
    );
    $bottomPoint = new Point(
        x: $point->x, 
        y: (ceil($point->y / 10) * 10) 
        // The closest point dividable by 10, below our current pixel
    );
    $noise = lerp(
        // The hash value (or color) of that top point:
        a: $this->hash($topPoint),
        
        // The hash value (or color) of that bottom point:
        b: $this->hash($bottomPoint),
        
        // The distance between our current point and the top point
        // — the fraction
        fraction: ($point->y - $topPoint->y) / ($bottomPoint->y - $topPoint->y),
    );
}

Następnie dodajmy tę samą funkcjonalność w drugą stronę, gdy y można podzielić przez 10:

if ($point->x % 10 === 0 && $point->y % 10 === 0) {
    // …
} elseif ($point->x % 10 === 0) {
    // …
} elseif ($point->y % 10 === 0) {
    $leftPoint = new Point(
        x: (floor($point->x / 10) * 10),
        y: $point->y,
    );
    $rightPoint = new Point(
        x: (ceil($point->x / 10) * 10),
        y: $point->y,
    );
    $noise = lerp(
        $this->hash($leftPoint),
        $this->hash($rightPoint),
        ($point->x - $leftPoint->x) / ($rightPoint->x - $leftPoint->x),
    );
}

Bez niespodzianek:

Wreszcie, dla pozostałych pikseli, nie będziemy w stanie wykonać prostej funkcji lerp (która działa tylko w jednym wymiarze). Będziemy musieli użyć interpolacji dwuliniowej: najpierw wykonamy dwie wartości lerp dla obu osi x, a następnie jedną końcową wartość lerp dla osi y. Będziemy również potrzebować czterech punktów krawędzi zamiast dwóch, ponieważ te piksele nie są wyrównane z naszą siecią.

if ($point->x % 10 === 0 && $point->y % 10 === 0) {
    // …
} elseif ($point->x % 10 === 0) {
    // …
} elseif ($point->y % 10 === 0) {
    // …
} else {
    $topLeftPoint = new Point(
        x: (floor($point->x / 10) * 10),
        y: (floor($point->y / 10) * 10),
    );
    $topRightPoint = new Point(
        x: (ceil($point->x / 10) * 10),
        y: (floor($point->y / 10) * 10),
    );
    $bottomLeftPoint = new Point(
        x: (floor($point->x / 10) * 10),
        y: (ceil($point->y / 10) * 10)
    );
    $bottomRightPoint = new Point(
        x: (ceil($point->x / 10) * 10),
        y: (ceil($point->y / 10) * 10)
    );
    $a = lerp(
        $this->hash($topLeftPoint),
        $this->hash($topRightPoint),
        ($point->x - $topLeftPoint->x) / ($topRightPoint->x - $topLeftPoint->x),
    );
    $b = lerp(
        $this->hash($bottomLeftPoint),
        $this->hash($bottomRightPoint),
        ($point->x - $bottomLeftPoint->x) / ($bottomRightPoint->x - $bottomLeftPoint->x),
    );
    $noise = lerp(
        $a,
        $b,
        ($point->y - $topLeftPoint->y) / ($bottomLeftPoint->y - $topLeftPoint->y),
    );
}

Zauważ, że w naszym kodzie jest pewne powtórzenie, którego moglibyśmy się pozbyć. Ale wolę wyraźnie przedstawić wszystkie cztery punkty krawędziowe dla jasności. Spójrz jednak na wynik:

Wygląda to znacznie płynniej! Zastosujmy nasze kolory:

Hm. Prawdopodobnie widzicie, dokąd zmierzamy, ale nadal uważam, że te linie są zbyt daleko... szorstki. Na szczęście możemy zastosować jeszcze dwie sztuczki! Po pierwsze, zamiast używać zwykłej funkcji lerp, możemy zastosować tak zwaną "funkcję kształtującą" do naszego ułamka. Dzięki tej funkcji kształtowania możemy manipulować naszą frakcją przed przekazaniem jej do funkcji lerp. Domyślnie nasz ułamek będzie miał wartość liniową - jest to odległość od dowolnego punktu do krawędzi początkowej:

Ale stosując funkcję do naszego ułamka, możemy nią manipulować tak, aby wartości bliżej krawędzi były jeszcze bardziej gładkie.

Moglibyśmy użyć dowolnej funkcji, jaką byśmy chcieli. W naszym przypadku użyję funkcji kształtowania o nazwie smoothstep, która wygładza krawędzie.

function smooth(float $a, float $b, float $fraction): float
{
    $smoothstep = function (float $fraction): float {
        $v1 = $fraction * $fraction;
        $v2 = 1.0  - (1.0 - $fraction) * (1.0 -$fraction);
        return lerp($v1, $v2, $fraction);
    };
    return lerp($a, $b, $smoothstep($fraction));
}

Różnica jest subtelna, ale jest trochę lepsza.

Drugą sztuczką jest zastosowanie nowej warstwy hałasu. Ten nie powinien być jednak tak losowy jak nasz pierwszy. Użyjemy prostego okrągłego wzoru i zastosujemy go jako mapę wysokości na naszym istniejącym hałasie. Im dalej piksel znajduje się od środka, tym mniejsza jego wartość:To jest wzór sam w sobie:A teraz łączymy ten wzór z naszym istniejącym szumem, co jest tak proste, jak pomnożenie ich:Oto wynik:

private function circularNoise(int $totalWidth, int $totalHeight, Point $point): float
{
    $middleX = $totalWidth / 2;
    $middleY = $totalHeight / 2;
    $distanceFromMiddle = sqrt(
        pow(($point->x - $middleX), 2)
        + pow(($point->y - $middleY), 2)
    );
    $maxDistanceFromMiddle = sqrt(
        pow(($totalWidth - $middleX), 2)
        + pow(($totalHeight - $middleY), 2)
    );
    return 1 - ($distanceFromMiddle / $maxDistanceFromMiddle) + 0.3;
}

final readonly class Noise
{
    public function __construct(
        private int $seed,
    ) {}
    
    public function generate(Point $point): float
    {
        return $this->baseNoise($point) 
             * $this->circularNoise($point);
    }
}

Oto wynik:

To wygląda o wiele lepiej! Dzięki naszemu okrągłemu wzorowi środkowa część mapy jest podniesiona, a zewnętrzne części są obniżone. Tworzy schludny wygląd wyspy. Wypróbujmy nasiona, aby zobaczyć różnicę między nimi:

Całkiem nieźle! Ale jesteśmy dalecy od zakończenia: będziemy chcieli dodać różne obszary na naszej mapie: lasy, równiny, góry, roślinność, .... Samo użycie w match naszej drawPixel metodzie już nie wystarczy.

# Ulepszony rysunek

Zróbmy interfejs Biome, który określi nasz kolor pikseli i może określić, jaki rodzaj roślinności należy dodać. Będziemy również reprezentować piksele jako obiekt właściwej wartości.

interface Biome
{
    public function getPixelColor(Pixel $pixel): string;
}

Dodajmy najpierw morza i równiny.

final readonly class SeaBiome implements Biome
{
    public function getPixelColor(Pixel $pixel): string
    {
        $base = $pixel->value;
        while ($base < 0.25) {
            $base += 0.01;
        }
        $r = hex($base / 3);
        $g = hex($base / 3);
        $b = hex($base);
        return "#{$r}{$g}{$b}";
    }
}
final readonly class PlainsBiome implements Biome
{
    public function getPixelColor(Pixel $pixel): string
    {
        $g = hex($pixel->value);
        $b = hex($pixel->value / 4);
        return "#00{$g}{$b}";
    }
}

W zależności od biomu piksela wykorzystamy jego szum do wygenerowania innego rodzaju koloru. W naszej drawPixel funkcji możemy teraz wprowadzić pewne zmiany:

function drawPixel(Pixel $pixel): string
{
    $biome = BiomeFactory::for($pixel);
    
    $color = $biome->getPixelColor($pixel);
    
    return <<<HTML
    <div style="
        --x: {$x}; 
        --y: {$y};
        --pixel-color: {$color};
    "></div>
    HTML;
}

Na razie nasza BiomeFactory weźmie pod uwagę tylko wartość piksela, aby określić biom. Później moglibyśmy dodać inne warunki.

final readonly class BiomeFactory
{
    public static function for(Pixel $pixel): Biome
    {
        return match(true) {
            $pixel->value < 0.6 => new SeaBiome(),
            default => new PlainsBiome(),
        };
    }
}

Nadal działa:Chodźmy dalej i dodajmy teraz wszystkie biomy:Zauważ, że postanowiłem również zmienić poziom morza:

final readonly class BiomeFactory
{
    public static function make(Pixel $pixel): Biome
    {
        return match(true) {
            $pixel->value < 0.4 => new SeaBiome(),
            $pixel->value >= 0.4 && $pixel->value < 0.44 => new BeachBiome(),
            $pixel->value >= 0.6 && $pixel->value < 0.8 => new ForestBiome(),
            $pixel->value >= 0.8 => new MountainBiome(),
            default => new PlainsBiome(),
        };
    }
}

z 0,6 na 0,4, więc mapa wygląda teraz nieco inaczej. Zobacz:

Nieźle, prawda? Ale to nie jest skończona gra - w rzeczywistości jest to tylko pierwszy krok: będziemy potrzebować sposobu interakcji z tą mapą, będziemy musieli zdefiniować jakąś formę rozgrywki. Może pamiętasz intro? Wspomniałem o zbieraniu zasobów. Wyobrażam sobie, że ta gra jest czymś w rodzaju gry w stylu cookie-clicker: im więcej zasobów zbierzesz, tym więcej możesz awansować w drzewie technologicznym, tym więcej zasobów możesz zebrać, ...

W każdym razie, wykonałem już dużo więcej pracy nad tą grą: właściwie rozpocząłem ten projekt jako eksperyment mający na celu zbadanie granic Laravel Livewire, więc interaktywność i rozgrywka były głównym celem. Wyjaśniłem już podstawowe pojęcia na moim kanale YouTube w dwóch filmach. Pracuję również nad trzecim filmem, w którym omawiam, w jaki sposób dodałem interakcję do tej konkretnej planszy, a także problemy, na które natknąłem się (jest kilka zastrzeżeń, o których jeszcze nie wspomniałem w tym poście na blogu).

Tak więc, jeśli chcesz śledzić, upewnij się, że subskrybujesz YouTube - mam nadzieję, że wkrótce nakręcę trzeci i ostatni film z tej serii. Alternatywnie możesz zapisać się na moją listę mailingową, gdzie również wyślę ci aktualizację.

W międzyczasie możecie obejrzeć dwie pierwsze części tej serii; i nie zapomnij się zapisać!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297