2026-04-14 18:54:44 +02:00
|
|
|
<?php declare(strict_types=1);
|
|
|
|
|
/*
|
|
|
|
|
* This file is part of the SplendidBear Websites' projects.
|
|
|
|
|
*
|
|
|
|
|
* Copyright (c) 2026 @ www.splendidbear.org
|
|
|
|
|
*
|
|
|
|
|
* For the full copyright and license information, please view the LICENSE
|
|
|
|
|
* file that was distributed with this source code.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
namespace App\Service;
|
|
|
|
|
|
|
|
|
|
use App\Entity\PlayedGame;
|
2026-04-15 16:44:57 +02:00
|
|
|
use Exception;
|
|
|
|
|
use GdImage;
|
|
|
|
|
use League\Flysystem\FilesystemOperator;
|
|
|
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
|
use RuntimeException;
|
2026-04-14 18:54:44 +02:00
|
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Class BattleCardGenerator
|
|
|
|
|
*
|
|
|
|
|
* Generates a 1200x630 PNG battle card for Open Graph sharing.
|
|
|
|
|
*
|
|
|
|
|
* @package App\Service
|
|
|
|
|
* @author Lang <https://www.splendidbear.org>
|
|
|
|
|
* @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.
|
|
|
|
|
*/
|
|
|
|
|
class BattleCardGenerator
|
|
|
|
|
{
|
2026-04-15 16:44:57 +02:00
|
|
|
private const int WIDTH = 1200;
|
|
|
|
|
private const int HEIGHT = 630;
|
|
|
|
|
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
|
|
|
|
private const int AVATAR_SIZE = 120;
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly string $cacheDir,
|
|
|
|
|
private readonly FilesystemOperator $minioMediaStorage,
|
|
|
|
|
private readonly LoggerInterface $logger,
|
|
|
|
|
) {
|
|
|
|
|
}
|
2026-04-14 18:54:44 +02:00
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
|
{
|
2026-04-20 09:05:36 +02:00
|
|
|
$path = $this->cachePath((int)$game->id);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-18 13:44:15 +02:00
|
|
|
// Always regenerate to ensure bonus points are included
|
2026-04-14 18:54:44 +02:00
|
|
|
if (is_file($path)) {
|
2026-04-18 13:44:15 +02:00
|
|
|
unlink($path);
|
2026-04-14 18:54:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!is_dir($this->cacheDir)) {
|
2026-04-15 16:44:57 +02:00
|
|
|
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));
|
|
|
|
|
}
|
2026-04-14 18:54:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->render($game, $path);
|
|
|
|
|
|
|
|
|
|
return $path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function render(PlayedGame $game, string $dest): void
|
|
|
|
|
{
|
2026-04-15 16:44:57 +02:00
|
|
|
$im = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Palette*/
|
2026-04-14 18:54:44 +02:00
|
|
|
$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);
|
|
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Background*/
|
2026-04-14 18:54:44 +02:00
|
|
|
imagefill($im, 0, 0, $bg);
|
|
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Dot-grid texture*/
|
|
|
|
|
for ($x = 40; $x < self::WIDTH; $x += 40) {
|
|
|
|
|
for ($y = 40; $y < self::HEIGHT; $y += 40) {
|
2026-04-14 18:54:44 +02:00
|
|
|
imagesetpixel($im, $x, $y, $dot);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Horizontal accent lines*/
|
|
|
|
|
imageline($im, 0, 90, self::WIDTH, 90, $divider);
|
|
|
|
|
imageline($im, 0, self::HEIGHT - 60, self::WIDTH, self::HEIGHT - 60, $divider);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Vertical centre divider*/
|
|
|
|
|
imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Resolve names*/
|
2026-04-20 09:05:36 +02:00
|
|
|
$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;
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Winner*/
|
2026-04-14 18:54:44 +02:00
|
|
|
$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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
$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);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** Draw avatars below the team labels (moved down by 60px total: 200 → 260)*/
|
2026-04-20 09:05:36 +02:00
|
|
|
$redAvatar = $game->red?->avatarPath;
|
|
|
|
|
$blueAvatar = $game->blue?->avatarPath;
|
2026-04-15 16:44:57 +02:00
|
|
|
|
|
|
|
|
$this->drawAvatar($im, $redAvatar, 220, 260, $red, $redName);
|
|
|
|
|
$this->drawAvatar($im, $blueAvatar, 980, 260, $blue, $blueName);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
|
|
|
|
$redColor = $winner === 'red' ? $gold : ($winner === 'draw' ? $white : $red);
|
|
|
|
|
$blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue);
|
|
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** 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);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
|
|
|
|
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
|
2026-04-15 16:44:57 +02:00
|
|
|
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
2026-04-18 13:44:15 +02:00
|
|
|
/** Bonus points below score*/
|
2026-04-20 09:05:36 +02:00
|
|
|
$redBonusPoints = $game->redBonusPoints ?? 0;
|
|
|
|
|
$blueBonusPoints = $game->blueBonusPoints ?? 0;
|
2026-04-18 13:44:15 +02:00
|
|
|
$bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', '');
|
|
|
|
|
$this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 425, $gold);
|
|
|
|
|
|
2026-04-14 18:54:44 +02:00
|
|
|
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 !== '') {
|
2026-04-18 13:44:15 +02:00
|
|
|
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor);
|
2026-04-14 18:54:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($resign) {
|
2026-04-18 13:44:15 +02:00
|
|
|
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 508, $muted);
|
2026-04-14 18:54:44 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
|
2026-04-14 18:54:44 +02:00
|
|
|
|
|
|
|
|
imagepng($im, $dest);
|
|
|
|
|
imagedestroy($im);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:44:57 +02:00
|
|
|
/** 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, self::FONT, $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, self::FONT, $initials);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 18:54:44 +02:00
|
|
|
/** Render text centered on $cx. */
|
2026-04-15 16:44:57 +02:00
|
|
|
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
|
2026-04-14 18:54:44 +02:00
|
|
|
{
|
|
|
|
|
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
|
|
|
|
$w = $bbox[2] - $bbox[0];
|
|
|
|
|
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, self::FONT, $text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
|
|
|
|
|
private function centeredTextFit(
|
2026-04-15 16:44:57 +02:00
|
|
|
GdImage $im,
|
|
|
|
|
string $text,
|
|
|
|
|
int $size,
|
|
|
|
|
int $cx,
|
|
|
|
|
int $y,
|
|
|
|
|
int $color,
|
|
|
|
|
int $maxWidth
|
2026-04-14 18:54:44 +02:00
|
|
|
): void {
|
|
|
|
|
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
|
|
|
|
$w = $bbox[2] - $bbox[0];
|
|
|
|
|
if ($w > $maxWidth) {
|
|
|
|
|
$size = (int)($size * $maxWidth / $w);
|
|
|
|
|
}
|
|
|
|
|
$this->centeredText($im, $text, $size, $cx, $y, $color);
|
|
|
|
|
}
|
|
|
|
|
}
|