Skip to content

Instantly share code, notes, and snippets.

@timw4mail
Created June 2, 2023 14:32
Show Gist options
  • Save timw4mail/05a3464700f09db2b4e24c1984fa8d40 to your computer and use it in GitHub Desktop.
Save timw4mail/05a3464700f09db2b4e24c1984fa8d40 to your computer and use it in GitHub Desktop.
One file procedurally generated game map, based on https://stitcher.io/blog/procedurally-generated-game-in-php
<?php declare(strict_types=1);
// ----------------------------------------------------------------------------
// Biomes
// ----------------------------------------------------------------------------
namespace Biome {
use Pixel;
interface Biome
{
public function getPixelColor(Pixel $pixel): string;
}
final readonly class Sea 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 Beach implements Biome
{
public function getPixelColor(Pixel $pixel): string
{
return "#D2B48C";
}
}
final readonly class Plains implements Biome
{
public function getPixelColor(Pixel $pixel): string
{
$g = hex($pixel->value);
$b = hex($pixel->value / 4);
return "#00{$g}{$b}";
}
}
final readonly class Forest implements Biome
{
public function getPixelColor(Pixel $pixel): string
{
$g = hex($pixel->value / 1.5);
$b = hex($pixel->value / 4);
return "#00{$g}{$b}";
}
}
final readonly class Mountain implements Biome
{
public function getPixelColor(Pixel $pixel): string
{
$x = $pixel->value < 0.9 ? hex($pixel->value / 2) : hex($pixel->value);
return "#{$x}{$x}{$x}";
}
}
final readonly class Factory
{
public static function make(Pixel $pixel): Biome
{
return match (true) {
$pixel->value < 0.4 => new Sea(),
$pixel->value >= 0.4 && $pixel->value < 0.44 => new Beach(),
$pixel->value >= 0.6 && $pixel->value < 0.8 => new Forest(),
$pixel->value >= 0.8 => new Mountain(),
default => new Plains(),
};
}
}
}
namespace {
const ROWS = 100;
const COLS = 150;
const SEED = 314159;
// ----------------------------------------------------------------------------
// Value Objects
// ----------------------------------------------------------------------------
/**
* @property-read int $x
* @property-read int $y
*/
class Pixel
{
private function __construct(public float $value, private readonly Point $point)
{
}
public static function new(float $value, Point $point): self
{
return new Pixel($value, $point);
}
public function __get(string $name): ?int
{
return match ($name) {
'x' => $this->point->x,
'y' => $this->point->y,
default => null,
};
}
}
class Point
{
private function __construct(public int $x, public int $y)
{
}
public static function new(int $x, int $y): Point
{
return new Point($x, $y);
}
public function ceil(string $var): int
{
return (int)(ceil($this->$var / 10) * 10);
}
public function floor(string $var): int
{
return (int)(floor($this->$var / 10) * 10);
}
}
// ----------------------------------------------------------------------------
// Seeded "noise" generation
// ----------------------------------------------------------------------------
final readonly class Noise
{
public function __construct(private int $seed)
{
}
public function generate(Point $point): float
{
return $this->baseNoise($point) * $this->circularNoise($point);
}
private function hash(Point $point): float
{
$baseX = ceil($point->x / 10);
$baseY = ceil($point->y / 10);
$hash = bin2hex(hash(algo: 'xxh32', data: (string)($this->seed * $baseX * $baseY)));
$hash = floatval('0.' . $hash);
return sqrt($hash);
}
private function baseNoise(Point $point): float
{
if ($point->x % 10 === 0 && $point->y % 10 === 0)
{
return $this->hash($point);
}
if ($point->x % 10 === 0)
{
$top = Point::new($point->x, $point->floor('y'));
$bottom = Point::new($point->x, $point->ceil('y'));
return smooth(
a: $this->hash($top),
b: $this->hash($bottom),
fraction: ($point->y - $top->y) / ($bottom->y - $top->y)
);
}
if ($point->y % 10 === 0)
{
$left = Point::new($point->floor('x'), $point->y);
$right = Point::new($point->ceil('x'), $point->y);
return smooth(
$this->hash($left),
$this->hash($right),
($point->x - $left->x) / ($right->x - $left->x),
);
}
$topLeft = Point::new($point->floor('x'), $point->floor('y'));
$topRight = Point::new($point->ceil('x'), $point->floor('y'));
$bottomLeft = Point::new($point->floor('x'), $point->ceil('y'));
$bottomRight = Point::new($point->ceil('x'), $point->ceil('y'));
$a = smooth(
$this->hash($topLeft),
$this->hash($topRight),
($point->x - $topLeft->x) / ($topRight->x - $topLeft->x)
);
$b = smooth(
$this->hash($bottomLeft),
$this->hash($bottomRight),
($point->x - $bottomLeft->x) / ($bottomRight->x - $bottomLeft->x)
);
return smooth(
$a, $b,
($point->y - $topLeft->y) / ($bottomLeft->y - $topLeft->y)
);
}
private function circularNoise(Point $point): float
{
$totalWidth = COLS;
$totalHeight = ROWS;
$middleX = $totalWidth / 2;
$middleY = $totalHeight / 2;
$distanceFromMiddle = sqrt(
($point->x - $middleX)**2 +
($point->y - $middleY)**2
);
$maxDistanceFromMiddle = sqrt(
($totalWidth - $middleX)**2 +
($totalHeight - $middleY)**2
);
return 1 - ($distanceFromMiddle / $maxDistanceFromMiddle) + 0.3;
}
}
// ----------------------------------------------------------------------------
// Functions
// ----------------------------------------------------------------------------
function hex(float $value): string {
$value = min($value, 1.0);
$hex = dechex((int) ($value * 255));
if (strlen($hex) < 2) {
$hex = "0" . $hex;
}
return $hex;
}
function lerp(float $a, float $b, float $fraction): float
{
return $a + $fraction * ($b - $a);
}
function smooth(float $a, float $b, float $fraction): float
{
$smoothstep = static 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));
}
function getRenderData(Pixel $pixel): array
{
$biome = Biome\Factory::make($pixel);
$color = $biome->getPixelColor($pixel);
return [
$pixel->x,
$pixel->y,
$color
];
}
// ----------------------------------------------------------------------------
// Pixel Map
// ----------------------------------------------------------------------------
$grid = [];
$noise = new Noise(SEED);
$cellCount = ROWS * COLS;
for ($i = 0; $i < $cellCount; $i++)
{
$x = $i % COLS;
$y = (int)($i / COLS);
$point = Point::new($x, $y);
$pixel = Pixel::new($noise->generate($point), $point);
$grid[] = getRenderData($pixel);
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title>PHP Procedurally Generated Game</title>
<style>
:root {
--pixel-size: 8px;
--pixel-gap: 0;
--pixel-color: #000;
background: #000;
display: flex;
height: 100%;
}
body {
margin: auto;
}
.map {
display: grid;
grid-template-columns: repeat(<?= COLS ?>, 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>
</head>
<body>
<div class="map">
<?php foreach($grid as [$x, $y, $color]): ?>
<div style="--x: <?= ($x + 1) ?>; --y: <?= ($y + 1) ?>; --pixel-color: <?= $color ?>"></div>
<?php endforeach; ?>
</div>
</body>
</html>
<?php } // End namespace
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment