• Время чтения ~5 мин
  • 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, который можно выложить на сетку 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.50.780.34, также известная как:

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

Хорошо, так почему это важно? Что ж, мы можем использовать нашу функцию 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 в нашем drawPixel методе match уже недостаточно.

# Улучшенная прорисовка

Давайте сделаем интерфейс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