今日、私たちは PHP を使用して手続き的に生成された 2D ゲームを構築しています。 これは、手続き的に生成されたマップ上でリソースを収集することに重点を置いたシンプルなゲームです。 この用語に馴染みのない方のために説明しておくと、これはシードに基づいてコードによって生成されるマップです。 すべてのシードは完全に固有のマップを生成します。 次のようになります。
では、プレーンな PHP からこのようなマップに移行するにはどうすればよいでしょうか? すべてはノイズから始まります。
# ノイズの発生
150 x 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}",
default => "#00{$hexFromNoise}00",
};
return <<<HTML
<div style="--x: {$x}; --y: {$y}; --pixel-color: {$color}"></div>
HTML;
}
ちなみにあれは、 hex
関数は、0 から 1 までの値を 2 桁の 16 進数に変換します。 次のようになります。
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;
}
結果はすでにかなり地図に似ています。
わかりました、かなりいいですね! しかし、これらの鋭いエッジは実際には現実的には見えません。 エッジ間の移行をよりスムーズにする方法を見つけることはできないでしょうか?
#ラープ
数学の時間です。 2 つの値があるとします。 0.34
そして 0.78
。 これら 2 つのちょうど中間の値を知りたいと考えています。 どうやってそれを行うのでしょうか?
そうですね、これには簡単な数式があります。 これは「線形補間」、略して「LERP」と呼ばれます。
function lerp(float $a, float $b, float $fraction): float
{
return $a + $fraction * ($b - $a);
}
lerp(0.34, 0.78, 0.5);
したがって、数値が与えられると、 $a
(0.34
)、 数 $b
(0.78
)、および分数 (0.5
、「ハーフ」とも呼ばれます)。 我々が得る 0.56
— ちょうど真ん中の数字 0.34
そして 0.78
。
おかげ 分数 lerp 式の一部で、次の値を決定できます。 どこでも 真ん中だけではなく、これらの点の間で:
lerp(0.34, 0.78, 0.25);
さて、それではなぜこれが重要なのでしょうか? さて、lerp 関数を使用してエッジを滑らかにすることができます。 ノイズパターンに戻って説明しましょう。
このグリッド内の各ピクセルに色を付ける代わりに、ピクセルが 10×10 の格子上に正確に存在する場合にのみ色を付けるとします。 言い換えれば、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 関数に渡す境界。 任意のピクセルについて、周囲の「固定」点 (固定された 10×10 グリッド上にあります) を決定するのは簡単で、それらの点までのピクセルの相対距離を計算することもできます。 これらの固定点のハッシュと、任意のピクセルからこれらの点までの距離を、lerp 関数の入力値として使用できます。 結果は、2 つのエッジ ポイントの値の間のどこかにある値、つまり滑らかな遷移になります。
まず、y 軸 (x が 10 で割り切れる場合) に lerp 関数を使用します。 「格子」上の相対的な上部と下部の点を決定し、現在の点と上部の点の間の距離を計算してから、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),
);
$bottomPoint = new Point(
x: $point->x,
y: (ceil($point->y / 10) * 10)
);
$noise = lerp(
a: $this->hash($topPoint),
b: $this->hash($bottomPoint),
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 関数 (1 次元でのみ機能します) を実行できません。 双線形補間を使用する必要があります。まず、両方の X 軸に対して 2 つの lerp 値を作成し、次に Y 軸に対して最後の 1 つの lerp 値を作成します。 これらのピクセルは格子と位置合わせされていないため、エッジ ポイントも 2 つではなく 4 つ必要になります。
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),
);
}
コードには、削除できる繰り返しがいくつかあることに注意してください。 ただし、わかりやすくするために 4 つのエッジ ポイントをすべて明示的に作成することを好みます。 ただし、結果を見てください。
これで見た目がかなりスムーズになりました! 色を適用しましょう:
うーん。 おそらく私たちがどこに向かっているのかはわかるでしょうが、それでもこれらの線はあまりにも大雑把すぎると思います。 幸いなことに、適用できるトリックがさらに 2 つあります。 まず、単純な 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));
}
違いは微妙ですが、少し良くなりました。
2 番目のトリックは、新しいノイズのレイヤーを適用することです。 ただし、これは最初のものほどランダムであってはなりません。 シンプルな円形パターンを使用し、それを既存のノイズの高さマップとして適用します。 ピクセルが中心から離れるほど、その値は小さくなります。
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);
}
}
結果は次のとおりです。
これで見た目がかなり良くなりました! 円形パターンのおかげで、マップの中央部分が盛り上がっており、外側の部分が下がっています。 すっきりとしたアイランド感を演出します。 いくつかのシードを試して、それらの違いを見てみましょう。
かなりいい! しかし、まだ完了には程遠いです。森林、平原、山、植生など、さまざまなエリアをマップ上に追加したいと考えています。 単純に 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;
}
今のところ、私たちの
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 チャンネルで 2 つのビデオで説明しています。 また、この特定のゲーム ボードにインタラクションをどのように追加したか、および遭遇した問題について説明する 3 番目のビデオも作成中です (このブログ投稿ではまだ言及していない注意点がたくさんあります)。
したがって、フォローしたい場合は、必ず YouTube に登録してください。このシリーズの 3 番目で最後のビデオを近々作成したいと考えています。 あるいは、私のメーリング リストに登録していただければ、更新情報もお送りします。
それまでの間、このシリーズの最初の 2 つのパートを視聴できます。 購読することを忘れないでください!