* @category Class * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License * @link www.splendidbear.org * @since 2026. 04. 14. */ final class BattleCardGenerator { private const int WIDTH = 1200; private const int HEIGHT = 630; private const int AVATAR_SIZE = 120; public function __construct( private readonly string $cacheDir, private readonly string $fontPath, private readonly FilesystemOperator $minioMediaStorage, private readonly LoggerInterface $logger, ) { } /** Returns a deterministic UUID v5 for the given battle ID — same battle always maps to the same filename. */ public function cachePath(int $battleId): string { $uuid = Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'mineseeker-battle-' . $battleId); return $this->cacheDir . '/' . $uuid->toRfc4122() . '.png'; } public function generate(PlayedGame $game): string { $path = $this->cachePath((int)$game->id); // Always regenerate to ensure bonus points are included if (is_file($path)) { unlink($path); } if (!is_dir($this->cacheDir)) { if ( !mkdir($concurrentDirectory = $this->cacheDir, 0755, true) && !is_dir($concurrentDirectory) ) { $this->logger->error(sprintf('Failed to create directory "%s" for battle card cache', $concurrentDirectory)); throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); } } $this->render($game, $path); return $path; } private function render(PlayedGame $game, string $dest): void { $im = imagecreatetruecolor(self::WIDTH, self::HEIGHT); /** Palette*/ $bg = imagecolorallocate($im, 13, 13, 28); $dot = imagecolorallocate($im, 30, 30, 55); $divider = imagecolorallocate($im, 40, 40, 70); $white = imagecolorallocate($im, 230, 230, 240); $muted = imagecolorallocate($im, 90, 90, 115); $red = imagecolorallocate($im, 246, 125, 82); $blue = imagecolorallocate($im, 149, 207, 245); $gold = imagecolorallocate($im, 255, 200, 50); /** Background*/ imagefill($im, 0, 0, $bg); /** Dot-grid texture*/ for ($x = 40; $x < self::WIDTH; $x += 40) { for ($y = 40; $y < self::HEIGHT; $y += 40) { imagesetpixel($im, $x, $y, $dot); } } /** Horizontal accent lines*/ imageline($im, 0, 90, self::WIDTH, 90, $divider); imageline($im, 0, self::HEIGHT - 60, self::WIDTH, self::HEIGHT - 60, $divider); /** Vertical centre divider*/ imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider); /** Resolve names*/ $redName = $game->red?->getUsername() ?? ($game->redAnon !== null ? 'Anonymous' : 'Guest'); $blueName = $game->blue?->getUsername() ?? ($game->blueAnon !== null ? 'Anonymous' : 'Guest'); $redPts = $game->redPoints; $bluePts = $game->bluePoints; $resign = $game->resign; /** Winner*/ $winner = null; if ($resign === 'red') { $winner = 'blue'; } elseif ($resign === 'blue') { $winner = 'red'; } elseif ($redPts !== null && $bluePts !== null) { if ($redPts > $bluePts) $winner = 'red'; elseif ($bluePts > $redPts) $winner = 'blue'; else $winner = 'draw'; } $this->centeredText($im, 'MineSeeker', 20, self::WIDTH / 2, 58, $muted); /** RED and BLUE labels aligned with avatars horizontally*/ $this->centeredText($im, 'RED', 16, 220, 130, $red); $this->centeredText($im, 'BLUE', 16, 980, 130, $blue); /** Draw avatars below the team labels (moved down by 60px total: 200 → 260)*/ $redAvatar = $game->red?->avatarPath; $blueAvatar = $game->blue?->avatarPath; $this->drawAvatar($im, $redAvatar, 220, 260, $red, $redName); $this->drawAvatar($im, $blueAvatar, 980, 260, $blue, $blueName); $redColor = $winner === 'red' ? $gold : ($winner === 'draw' ? $white : $red); $blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue); /** Truncate long usernames (max 10 chars + "...")*/ $redNameDisplay = mb_strlen($redName) > 10 ? mb_substr($redName, 0, 10) . '...' : $redName; $blueNameDisplay = mb_strlen($blueName) > 10 ? mb_substr($blueName, 0, 10) . '...' : $blueName; /** Player names lower below avatars (moved down by 60px total: 310 → 370)*/ $this->centeredTextFit($im, $redNameDisplay, 36, 220, 370, $redColor, 400); $this->centeredTextFit($im, $blueNameDisplay, 36, 980, 370, $blueColor, 400); $scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS'; $this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white); /** Bonus points below score*/ $redBonusPoints = $game->redBonusPoints ?? 0; $blueBonusPoints = $game->blueBonusPoints ?? 0; $bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', ''); $this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 445, $gold); if ($winner === 'red') { $resultText = $redName . ' wins'; $resultColor = $gold; } elseif ($winner === 'blue') { $resultText = $blueName . ' wins'; $resultColor = $gold; } elseif ($winner === 'draw') { $resultText = 'Draw'; $resultColor = $muted; } else { $resultText = ''; $resultColor = $muted; } if ($resultText !== '') { $this->centeredText($im, $resultText, 30, self::WIDTH / 2, 495, $resultColor); } if ($resign) { $this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 528, $muted); } $this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted); imagepng($im, $dest); imagedestroy($im); } /** Draw avatar or initials centered on $cx, $cy. */ private function drawAvatar(GdImage $im, ?string $avatarPath, int $cx, int $cy, int $color, string $name): void { $avatarImg = null; /** Try to load avatar from MinIO if path exists*/ if ($avatarPath) { try { /** Remove 'avatar/' prefix if it exists since storage already has media/ prefix*/ $path = str_starts_with($avatarPath, 'avatar/') ? $avatarPath : 'avatar/' . $avatarPath; $avatarData = $this->minioMediaStorage->read($path); $avatarImg = imagecreatefromstring($avatarData); } catch (Exception $e) { /** Failed to load avatar, will use initials*/ $avatarImg = null; } } $x = $cx - self::AVATAR_SIZE / 2; $y = $cy - self::AVATAR_SIZE / 2; if ($avatarImg) { /** Draw circular avatar image*/ $mask = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE); $transparent = imagecolorallocatealpha($mask, 0, 0, 0, 127); imagefill($mask, 0, 0, $transparent); /** Create circular mask*/ imagefilledellipse( $mask, self::AVATAR_SIZE / 2, self::AVATAR_SIZE / 2, self::AVATAR_SIZE, self::AVATAR_SIZE, imagecolorallocate($mask, 255, 255, 255), ); /** Resize and crop avatar*/ $resized = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE); imagealphablending($resized, false); imagesavealpha($resized, true); $bg = imagecolorallocatealpha($resized, 0, 0, 0, 127); imagefill($resized, 0, 0, $bg); $srcW = imagesx($avatarImg); $srcH = imagesy($avatarImg); $size = min($srcW, $srcH); $srcX = ($srcW - $size) / 2; $srcY = ($srcH - $size) / 2; imagecopyresampled( $resized, $avatarImg, 0, 0, (int)$srcX, (int)$srcY, self::AVATAR_SIZE, self::AVATAR_SIZE, $size, $size, ); /** Apply circular mask*/ for ($py = 0; $py < self::AVATAR_SIZE; $py++) { for ($px = 0; $px < self::AVATAR_SIZE; $px++) { $maskColor = imagecolorat($mask, $px, $py); if (($maskColor >> 16) & 0xFF) { $resizedColor = imagecolorat($resized, $px, $py); imagesetpixel($im, (int)($x + $px), (int)($y + $py), $resizedColor); } } } imagedestroy($avatarImg); imagedestroy($resized); imagedestroy($mask); } else { /** Draw circular background with initials*/ imagefilledellipse($im, (int)$cx, (int)$cy, self::AVATAR_SIZE, self::AVATAR_SIZE, $color); /** Draw initials */ $initials = mb_strtoupper(mb_substr($name, 0, 2)); $fontSize = 48; $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $initials); $textW = $bbox[2] - $bbox[0]; $textH = $bbox[1] - $bbox[7]; $textX = $cx - $textW / 2; $textY = $cy + $textH / 2; $white = imagecolorallocate($im, 255, 255, 255); imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, $this->fontPath, $initials); } } /** Render text centered on $cx. */ private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void { $bbox = imagettfbbox($size, 0, $this->fontPath, $text); $w = $bbox[2] - $bbox[0]; imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, $this->fontPath, $text); } /** Render text centered on $cx, shrinking font size to fit $maxWidth. */ private function centeredTextFit( GdImage $im, string $text, int $size, int $cx, int $y, int $color, int $maxWidth ): void { $bbox = imagettfbbox($size, 0, $this->fontPath, $text); $w = $bbox[2] - $bbox[0]; if ($w > $maxWidth) { $size = (int)($size * $maxWidth / $w); } $this->centeredText($im, $text, $size, $cx, $y, $color); } }