• Час читання ~12 хв
  • 01.06.2023

Сьогодні ми створюємо процедурно згенеровану 2D-гру з PHP. Це проста гра, яка зосереджена на зборі ресурсів на процедурно згенерованій карті. Якщо ви не знайомі з цим терміном: це карта, згенерована кодом на основі насіння. Кожне насіння згенерує абсолютно унікальну карту. Це виглядає приблизно так:

Отже, як нам перейти від простого PHP до такої карти? Все починається з шуму.

# Генерація

шуму Уявімо, що у нас є сітка 150 на 100 пікселів. Кожен піксель становить точку нашої карти.

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

Наразі drawPixel функція згенерує div per pixel, який можна розмістити на сітці CSS. Ми можемо зробити рефакторінг для використання пізніше, але можливість використання canvas вбудованої сітки CSS економить багато часу.

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

Це файл шаблону:Ось результат:

<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>

До речі, я позбудуся від проміжків між пікселями, я лише додав їх, щоб показати, що це дійсно окремі осередки сітки.

Давайте пограємося з нашою сіткою. Ми почнемо з призначення значення від 0 до 1 для кожного окремого пікселя. Ми використаємо клас з назвоюNoise, який приймає точку пікселя (координати X/Y) і повертає значення для цієї точки. Спочатку повернемо випадкове значення.

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)); 

Ось результат:

Покладання на випадковість не заведе нас дуже далеко. Ми хочемо, щоб дане насіння генерувало одну і ту ж карту знову і знову. Тому замість випадковості давайте напишемо хеш-функцію: функцію, яка для будь-якої даної точки і насіння буде генерувати одне і те ж значення знову і знову. Ви можете зробити це так само просто, як помножити насіння на координати x і y, і перетворити це в дріб:

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

результат, однак, здається недостатньо випадковим. Пам'ятайте, що ми хочемо створити карту світу: ми хочемо якоїсь випадковості, але також певної згуртованості. Тому наша хеш-функція повинна бути трохи складнішою.

Давайте спробуємо існуючу хеш-функцію, яку нам не потрібно винаходити самим. Ймовірно, нам потрібен продуктивний, оскільки ми генеруємо хеш для тисяч пікселів. PHP підтримує xxHash, який є «надзвичайно швидким алгоритмом хешування». Спробуємо.

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

Цей шум виглядає багатообіцяюче: він досить випадковий, але завжди дає однаковий результат для даного насіння. Але перехід від цього до цілісної карти світу все ще здається стрибком. Давайте змінимо нашу хеш-функцію, щоб вона повертала той самий колір у квадраті 10 пікселів:Ось результат:О, до речі:

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);
}

Ось результат:

я беру квадратний корінь хешу, просто щоб трохи збільшити всі значення. Це буде корисно в майбутньому, але не обов'язково. Без квадратного кореня карта виглядає так:

Давайте на секунду щось уявимо. Припустимо, всі пікселі зі значенням вище 0,6 вважаються сухопутними, а всі пікселі з меншим значенням вважаються водою. Давайте внесемо деякі зміни в наш drawPixel метод, щоб відобразити цю поведінку:

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;
}

До речі, ця hex функція перетворює значення від 0 до 1 у двозначне шістнадцяткове. Це виглядає так:Результат вже набагато більше схожий на карту:

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;
}

Гаразд, досить приємно! Але ці гострі краї насправді не виглядають реалістично. Чи можемо ми знайти спосіб зробити переходи між краями більш плавними?

# Лерп

Час для деяких математичних математик. Припустимо, у нас є два значення: 0.34 і 0.78. Ми хочемо знати значення рівно посередині між цими двома. Як ми це робимо?

Ну, для цього є проста математична формула. Це називається «Лінійна інтерполяція» — скорочено «LERP»: Отже, дано число (), число () і дріб (0.340.780.5, також відоме як: «половина»

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

); отримуємо 0.56 — число $a $b рівно посередині між 0.34 і .

0.78 Завдяки частці дробу в нашій формулі lerp ми можемо визначити значення в будь-якому місці між цими точками, а не тільки в середині:

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

Ok, так чому це важливо? Ну, ми можемо використовувати нашу функцію lerp для згладжування країв! Повернімося до нашого шумового малюнка і пояснимо:

скажімо, замість того, щоб фарбувати кожен піксель у цій сітці, ми фарбуємо піксель лише тоді, коли він знаходиться точно на решітці 10x10. Іншими словами: коли його координати x і y можна розділити на 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;
        }
    }
    // …
}

Ось решітка:

Давайте уявимо, що ці пікселі є $a і $b межами, які ми переходимо в нашу функцію lerp. Для будь-якого даного пікселя тривіально визначити ці навколишні «фіксовані» точки (вони знаходяться на фіксованій сітці 10x10), і ми також можемо обчислити відносну відстань пікселя до цих точок. Ми можемо використовувати хеш цих фіксованих точок і відстань від будь-якого пікселя до цих точок як вхідні значення для нашої функції lerp. Результатом буде значення, яке знаходиться десь між значеннями наших двох крайових точок — іншими словами: плавний перехід.

Спочатку ми будемо використовувати нашу функцію lerp на осі y (кожного разу, коли x можна розділити на 10). Ми визначимо відносні верхню і нижню точки на нашій «решітці», обчислимо відстань між нашою поточною точкою і верхньою точкою, а потім скористаємося нашою функцією lerp, щоб визначити правильне значення між верхньою і нижньою точкою, з цією часткою відстані:Ось результат, ви вже можете побачити плавний перехід всередині ліній:

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),
    );
}

Далі додамо той же функціонал в іншому напрямку, коли y ділиться на 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),
    );
}

Ніяких сюрпризів:

Нарешті, для решти пікселів ми не зможемо зробити просту функцію lerp (яка працює тільки в одному вимірі). Нам доведеться використовувати білінійну інтерполяцію: спочатку зробимо два значення lerp для обох осей x, а потім одне остаточне значення lerp для осі y. Нам також знадобляться чотири крайові точки замість двох, тому що ці пікселі не вирівняні з нашою решіткою.

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),
    );
}

Зверніть увагу, що в нашому коді є деякі повторення, від яких ми могли б позбутися. Але я вважаю за краще явно робити всі чотири крайові точки для ясності. Погляньте на результат:

це виглядає набагато плавніше! Давайте застосуємо наші кольори:

Хм. Ви, напевно, бачите, куди ми йдемо, але я все ще думаю, що ці рядки теж далеко... грубий. На щастя, є ще два трюки, які ми можемо застосувати! По-перше, замість простої функції lerp, ми можемо застосувати так звану «формуючу функцію» до нашої фракції. За допомогою цієї функції формування ми можемо маніпулювати нашою дробом, перш ніж передати її функції lerp. За замовчуванням наш дріб матиме лінійне значення — це відстань від будь-якої заданої точки до початкового краю:

Але, застосувавши функцію до нашого дробу, ми можемо маніпулювати нею, щоб значення ближче до країв були ще більш гладкими.

Ми могли б використовувати будь-яку функцію, яку хочемо. Для нашого випадку я буду використовувати функцію формування під назвою smoothstep, яка згладжує краї.

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));
}

Різниця незначна, але вона трохи краща.

Друга хитрість полягає в нанесенні нового шару шуму. Однак цей не повинен бути таким випадковим, як наш перший. Ми використаємо простий круговий візерунок і застосуємо його як карту висоти до наявного шуму. Чим далі піксель знаходиться від центру, тим менше його значення:Це шаблон сам по собі:І тепер ми поєднуємо цей візерунок з нашим існуючим шумом, що так само просто, як їх множення:Ось результат:

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);
    }
}

Ось результат:

Це виглядає набагато краще! Завдяки нашому круговому візерунку середня частина нашої карти піднімається, а зовнішня опускається. Він створює акуратний острівний вигляд. Давайте спробуємо кілька насіння, щоб побачити різницю між ними:

Досить приємно! Але ми ще далекі від завершення: ми захочемо додати різні області на нашу карту: ліси, рівнини, гори, рослинність, .... Простого використання a match в нашому drawPixel методі вже буде недостатньо.

# Покращений малюнок

Давайте зробимо інтерфейс Biome, який буде визначати наш піксельний колір, і зможе визначити, яку рослинність слід додати. Ми також представимо пікселі як об'єкт належного значення.

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

Давайте спочатку додамо моря і рівнини.

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}";
    }
}

Залежно від біому пікселя, ми будемо використовувати його шум для створення іншого кольору. У нашій drawPixel функції тепер ми можемо внести деякі зміни:

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;
}

Наразі наш BiomeFactory враховуватиме лише значення пікселя для визначення біому. Ми могли б додати інші умови пізніше.

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

Це все ще працює:Давайте підемо далі і додамо всі біоми зараз:Зверніть увагу, що я також вирішив змінити рівень моря:

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(),
        };
    }
}

з 0.6 до 0.4, тому карта зараз виглядає трохи інакше. Погляньте:

Непогано, правда? Але це далеко не закінчена гра — по суті, це тільки найперший крок: нам знадобиться спосіб взаємодії з цією картою, нам потрібно буде визначити якусь форму ігрового процесу. Може, ви пам'ятаєте вступ? Я згадав про збір ресурсів. Я уявляю собі цю гру як якусь гру в стилі клікерів печива: чим більше ресурсів ви збираєте, тим більше ви можете просунутися в технологічному дереві, тим більше ресурсів ви можете зібрати, ...

У будь-якому випадку, я вже зробив набагато більше роботи над цією грою: я фактично почав цей проект як експеримент з вивчення меж Laravel Livewire, тому інтерактивність і геймплей були основним фокусом. Я пояснив основні поняття вже на своєму каналі YouTube у двох відео. Я також працюю над третім відео, де обговорюю, як я додав взаємодію на цю конкретну ігрову дошку, а також проблеми, з якими я зіткнувся (є купа застережень, про які я ще не згадував у цій публікації в блозі).

Тож, якщо ви хочете слідкувати, обов'язково підпишіться на YouTube — я сподіваюся незабаром зробити третє і останнє відео цієї серії. Крім того, ви можете підписатися на мій список розсилки, де я також надішлю вам оновлення.

А поки ви можете подивитися перші дві частини цієї серії; І не забудьте підписатися!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

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...

Про автора CrazyBoy49z
WORK EXPERIENCE
Контакти
Ukraine, Lutsk
+380979856297