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 }) {
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 %}
+
 }})
+ {% else %}
+ {{ redName|slice(0,2)|upper }}
+ {% endif %}
{{ redName }}
Red
@@ -74,7 +80,13 @@
- {{ blueName|slice(0,2)|upper }}
+ {% if blueAvatar %}
+
 }})
+ {% else %}
+ {{ blueName|slice(0,2)|upper }}
+ {% endif %}
{{ blueName }}
Blue