From c52939a7a31c7cee81a6721245da75432c4a90c6 Mon Sep 17 00:00:00 2001 From: Lang <7system7@gmail.com> Date: Wed, 15 Apr 2026 16:44:57 +0200 Subject: [PATCH] chg: usr: change the shareable battle - add avatars to it - even on the og tags #4 --- Dockerfile | 6 +- assets/css/homepage/_profile.scss | 7 + assets/js/components/BattleDialog.jsx | 23 ++- config/services.yaml | 1 + src/Controller/ProfileController.php | 29 ++-- src/Service/BattleCardGenerator.php | 195 +++++++++++++++++++++----- templates/Game/battle_share.html.twig | 16 ++- 7 files changed, 224 insertions(+), 53 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27fc57e..26debdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,11 @@ RUN install-php-extensions \ apcu \ sodium -RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + fonts-dejavu-core \ + fontconfig \ + && fc-cache -f -v \ + && rm -rf /var/lib/apt/lists/* RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \ diff --git a/assets/css/homepage/_profile.scss b/assets/css/homepage/_profile.scss index 821d5eb..e9d8537 100644 --- a/assets/css/homepage/_profile.scss +++ b/assets/css/homepage/_profile.scss @@ -747,6 +747,13 @@ font: 800 24px 'Rajdhani', sans-serif; letter-spacing: 2px; + &__img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + } + &--red { background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%); border: 2px solid rgba(173, 10, 5, 0.5); diff --git a/assets/js/components/BattleDialog.jsx b/assets/js/components/BattleDialog.jsx index eba2b7c..775d115 100644 --- a/assets/js/components/BattleDialog.jsx +++ b/assets/js/components/BattleDialog.jsx @@ -50,7 +50,7 @@ const RESULT_META = { }, }; -function Avatar({ name, color }) { +function Avatar({ name, color, avatarUrl }) { const isRed = 'red' === color; const initials = (name || '?').slice(0, 2).toUpperCase(); @@ -69,16 +69,29 @@ function Avatar({ name, color }) {
- {initials} + {avatarUrl ? ( + {name} + ) : ( + initials + )}
- +
{game.redPoints ?? '—'} @@ -212,7 +225,7 @@ export default function BattleDialog({ games }) { {meta.label}
- +
diff --git a/config/services.yaml b/config/services.yaml index 8560eaa..68ddf46 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -28,6 +28,7 @@ services: App\Service\BattleCardGenerator: arguments: $cacheDir: '%kernel.project_dir%/var/og-cache' + $minioMediaStorage: '@mineseeker.media.storage' Aws\S3\S3Client: arguments: diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 1899398..d823990 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -57,7 +57,7 @@ class ProfileController extends AbstractController } #[Route('/profile', name: 'MineSeekerBundle_profile')] - public function index(): Response + public function index(CacheManager $cacheManager): Response { /** @var User $user */ $user = $this->getUser(); @@ -124,7 +124,7 @@ class ProfileController extends AbstractController 'bestScore' => $this->repo->findBestScoreForUser($user), ], 'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)), - 'gamesData' => array_map(static function (PlayedGame $game) use ($userId): array { + 'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array { $isRed = $game->getRed()?->getId() === $userId; $resign = $game->getResign(); $myColor = $isRed ? 'red' : 'blue'; @@ -140,6 +140,9 @@ class ProfileController extends AbstractController elseif ($myPts < $oppPts) $result = 'loss'; } + $redAvatarPath = $game->getRed()?->getAvatarPath(); + $blueAvatarPath = $game->getBlue()?->getAvatarPath(); + return [ 'id' => $game->getId(), 'uuid' => $game->getUuid()?->toRfc4122(), @@ -147,6 +150,8 @@ class ProfileController extends AbstractController $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest', 'blueName' => $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest', + 'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null, + 'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null, 'redPoints' => $game->getRedPoints(), 'bluePoints' => $game->getBluePoints(), 'redExplodedBomb' => $game->getRedExplodedBomb(), @@ -190,6 +195,8 @@ class ProfileController extends AbstractController $redPts = $game->getRedPoints(); $bluePts = $game->getBluePoints(); $resign = $game->getResign(); + $redAvatar = $game->getRed()?->getAvatarPath(); + $blueAvatar = $game->getBlue()?->getAvatarPath(); if ($resign === 'red') { $summary = "$redName resigned — $blueName wins"; @@ -208,14 +215,16 @@ class ProfileController extends AbstractController } return $this->render('Game/battle_share.html.twig', [ - 'game' => $game, - 'redName' => $redName, - 'blueName' => $blueName, - 'redPts' => $redPts, - 'bluePts' => $bluePts, - 'resign' => $resign, - 'ogTitle' => "MineSeeker · $summary", - 'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.", + 'game' => $game, + 'redName' => $redName, + 'blueName' => $blueName, + 'redPts' => $redPts, + 'bluePts' => $bluePts, + 'resign' => $resign, + 'redAvatar' => $redAvatar, + 'blueAvatar' => $blueAvatar, + 'ogTitle' => "MineSeeker · $summary", + 'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.", ]); } diff --git a/src/Service/BattleCardGenerator.php b/src/Service/BattleCardGenerator.php index 5c550d9..e3f8151 100644 --- a/src/Service/BattleCardGenerator.php +++ b/src/Service/BattleCardGenerator.php @@ -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]; diff --git a/templates/Game/battle_share.html.twig b/templates/Game/battle_share.html.twig index 7cf5338..f51abca 100644 --- a/templates/Game/battle_share.html.twig +++ b/templates/Game/battle_share.html.twig @@ -32,7 +32,13 @@
- {{ redName|slice(0,2)|upper }} + {% if redAvatar %} + {{ redName }} + {% else %} + {{ redName|slice(0,2)|upper }} + {% endif %}
{{ redName }} Red @@ -74,7 +80,13 @@
- {{ blueName|slice(0,2)|upper }} + {% if blueAvatar %} + {{ blueName }} + {% else %} + {{ blueName|slice(0,2)|upper }} + {% endif %}
{{ blueName }} Blue