Private
Public Access
1
0

chg: usr: change the shareable battle - add avatars to it - even on the og tags #4

This commit is contained in:
2026-04-15 16:44:57 +02:00
parent 573d409606
commit c52939a7a3
7 changed files with 224 additions and 53 deletions

View File

@@ -11,6 +11,11 @@
namespace App\Service;
use App\Entity\PlayedGame;
use Exception;
use GdImage;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Uid\Uuid;
/**
@@ -27,11 +32,17 @@ use Symfony\Component\Uid\Uuid;
*/
class BattleCardGenerator
{
private const W = 1200;
private const H = 630;
private const FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
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;
public function __construct(private readonly string $cacheDir) { }
public function __construct(
private readonly string $cacheDir,
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
@@ -50,7 +61,13 @@ class BattleCardGenerator
}
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
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);
@@ -60,9 +77,9 @@ class BattleCardGenerator
private function render(PlayedGame $game, string $dest): void
{
$im = imagecreatetruecolor(self::W, self::H);
$im = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
// Palette
/** Palette*/
$bg = imagecolorallocate($im, 13, 13, 28);
$dot = imagecolorallocate($im, 30, 30, 55);
$divider = imagecolorallocate($im, 40, 40, 70);
@@ -72,24 +89,24 @@ class BattleCardGenerator
$blue = imagecolorallocate($im, 149, 207, 245);
$gold = imagecolorallocate($im, 255, 200, 50);
// Background
/** Background*/
imagefill($im, 0, 0, $bg);
// Dot-grid texture
for ($x = 40; $x < self::W; $x += 40) {
for ($y = 40; $y < self::H; $y += 40) {
/** 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::W, 90, $divider);
imageline($im, 0, self::H - 60, self::W, self::H - 60, $divider);
/** 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::W / 2, 110, self::W / 2, self::H - 80, $divider);
/** Vertical centre divider*/
imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider);
// Resolve names
/** Resolve names*/
$redName = $game->getRed()?->getUsername()
?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
$blueName = $game->getBlue()?->getUsername()
@@ -98,7 +115,7 @@ class BattleCardGenerator
$bluePts = $game->getBluePoints();
$resign = $game->getResign();
// Winner
/** Winner*/
$winner = null;
if ($resign === 'red') {
$winner = 'blue';
@@ -110,19 +127,32 @@ class BattleCardGenerator
else $winner = 'draw';
}
$this->centeredText($im, 'MineSeeker', 20, self::W / 2, 58, $muted);
$this->centeredText($im, 'MineSeeker', 20, self::WIDTH / 2, 58, $muted);
$this->centeredText($im, 'RED', 16, self::W / 4, 130, $red);
$this->centeredText($im, 'BLUE', 16, self::W * 3 / 4, 130, $blue);
/** 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->getRed()?->getAvatarPath();
$blueAvatar = $game->getBlue()?->getAvatarPath();
$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);
$this->centeredTextFit($im, $redName, 48, self::W / 4, 265, $redColor, self::W / 2 - 80);
$this->centeredTextFit($im, $blueName, 48, self::W * 3 / 4, 265, $blueColor, self::W / 2 - 80);
/** 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::W / 2, 390, $white);
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
if ($winner === 'red') {
$resultText = $redName . ' wins';
@@ -139,21 +169,116 @@ class BattleCardGenerator
}
if ($resultText !== '') {
$this->centeredText($im, $resultText, 30, self::W / 2, 460, $resultColor);
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor);
}
if ($resign) {
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::W / 2, 498, $muted);
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 498, $muted);
}
$this->centeredText($im, 'mineseeker.hu', 16, self::W / 2, self::H - 20, $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, 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);
}
}
/** Render text centered on $cx. */
private function centeredText(\GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
{
$bbox = imagettfbbox($size, 0, self::FONT, $text);
$w = $bbox[2] - $bbox[0];
@@ -162,13 +287,13 @@ class BattleCardGenerator
/** 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
GdImage $im,
string $text,
int $size,
int $cx,
int $y,
int $color,
int $maxWidth
): void {
$bbox = imagettfbbox($size, 0, self::FONT, $text);
$w = $bbox[2] - $bbox[0];