chg: usr: change the shareable battle - add avatars to it - even on the og tags #4
This commit is contained in:
@@ -22,7 +22,11 @@ RUN install-php-extensions \
|
|||||||
apcu \
|
apcu \
|
||||||
sodium
|
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 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' \
|
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
|
||||||
|
|||||||
@@ -747,6 +747,13 @@
|
|||||||
font: 800 24px 'Rajdhani', sans-serif;
|
font: 800 24px 'Rajdhani', sans-serif;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
&--red {
|
&--red {
|
||||||
background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%);
|
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);
|
border: 2px solid rgba(173, 10, 5, 0.5);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const RESULT_META = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function Avatar({ name, color }) {
|
function Avatar({ name, color, avatarUrl }) {
|
||||||
const isRed = 'red' === color;
|
const isRed = 'red' === color;
|
||||||
const initials = (name || '?').slice(0, 2).toUpperCase();
|
const initials = (name || '?').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
@@ -69,16 +69,29 @@ function Avatar({ name, color }) {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 72, height: 72, borderRadius: '50%',
|
width: 72, height: 72, borderRadius: '50%',
|
||||||
background: gradient,
|
background: avatarUrl ? 'transparent' : gradient,
|
||||||
border: `2px solid ${border}`,
|
border: `2px solid ${border}`,
|
||||||
boxShadow: glow,
|
boxShadow: glow,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
font: '800 24px \'Rajdhani\', sans-serif',
|
font: '800 24px \'Rajdhani\', sans-serif',
|
||||||
color: textColor,
|
color: textColor,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{initials}
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
font: '700 15px \'Rajdhani\', sans-serif',
|
font: '700 15px \'Rajdhani\', sans-serif',
|
||||||
@@ -197,7 +210,7 @@ export default function BattleDialog({ games }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bd-vs-panel">
|
<div className="bd-vs-panel">
|
||||||
<Avatar name={game.redName} color="red" />
|
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} />
|
||||||
<div className="bd-vs-center">
|
<div className="bd-vs-center">
|
||||||
<div className="bd-vs-score">
|
<div className="bd-vs-score">
|
||||||
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
|
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
|
||||||
@@ -212,7 +225,7 @@ export default function BattleDialog({ games }) {
|
|||||||
<i className={`fa ${meta.icon}`} /> {meta.label}
|
<i className={`fa ${meta.icon}`} /> {meta.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Avatar name={game.blueName} color="blue" />
|
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bd-stats">
|
<div className="bd-stats">
|
||||||
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
App\Service\BattleCardGenerator:
|
App\Service\BattleCardGenerator:
|
||||||
arguments:
|
arguments:
|
||||||
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
||||||
|
$minioMediaStorage: '@mineseeker.media.storage'
|
||||||
|
|
||||||
Aws\S3\S3Client:
|
Aws\S3\S3Client:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class ProfileController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
||||||
public function index(): Response
|
public function index(CacheManager $cacheManager): Response
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
@@ -124,7 +124,7 @@ class ProfileController extends AbstractController
|
|||||||
'bestScore' => $this->repo->findBestScoreForUser($user),
|
'bestScore' => $this->repo->findBestScoreForUser($user),
|
||||||
],
|
],
|
||||||
'recent' => ($recent = $this->repo->findRecentFinishedForUser($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;
|
$isRed = $game->getRed()?->getId() === $userId;
|
||||||
$resign = $game->getResign();
|
$resign = $game->getResign();
|
||||||
$myColor = $isRed ? 'red' : 'blue';
|
$myColor = $isRed ? 'red' : 'blue';
|
||||||
@@ -140,6 +140,9 @@ class ProfileController extends AbstractController
|
|||||||
elseif ($myPts < $oppPts) $result = 'loss';
|
elseif ($myPts < $oppPts) $result = 'loss';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$redAvatarPath = $game->getRed()?->getAvatarPath();
|
||||||
|
$blueAvatarPath = $game->getBlue()?->getAvatarPath();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $game->getId(),
|
'id' => $game->getId(),
|
||||||
'uuid' => $game->getUuid()?->toRfc4122(),
|
'uuid' => $game->getUuid()?->toRfc4122(),
|
||||||
@@ -147,6 +150,8 @@ class ProfileController extends AbstractController
|
|||||||
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
|
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
|
||||||
'blueName' =>
|
'blueName' =>
|
||||||
$game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
|
$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(),
|
'redPoints' => $game->getRedPoints(),
|
||||||
'bluePoints' => $game->getBluePoints(),
|
'bluePoints' => $game->getBluePoints(),
|
||||||
'redExplodedBomb' => $game->getRedExplodedBomb(),
|
'redExplodedBomb' => $game->getRedExplodedBomb(),
|
||||||
@@ -190,6 +195,8 @@ class ProfileController extends AbstractController
|
|||||||
$redPts = $game->getRedPoints();
|
$redPts = $game->getRedPoints();
|
||||||
$bluePts = $game->getBluePoints();
|
$bluePts = $game->getBluePoints();
|
||||||
$resign = $game->getResign();
|
$resign = $game->getResign();
|
||||||
|
$redAvatar = $game->getRed()?->getAvatarPath();
|
||||||
|
$blueAvatar = $game->getBlue()?->getAvatarPath();
|
||||||
|
|
||||||
if ($resign === 'red') {
|
if ($resign === 'red') {
|
||||||
$summary = "$redName resigned — $blueName wins";
|
$summary = "$redName resigned — $blueName wins";
|
||||||
@@ -208,14 +215,16 @@ class ProfileController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('Game/battle_share.html.twig', [
|
return $this->render('Game/battle_share.html.twig', [
|
||||||
'game' => $game,
|
'game' => $game,
|
||||||
'redName' => $redName,
|
'redName' => $redName,
|
||||||
'blueName' => $blueName,
|
'blueName' => $blueName,
|
||||||
'redPts' => $redPts,
|
'redPts' => $redPts,
|
||||||
'bluePts' => $bluePts,
|
'bluePts' => $bluePts,
|
||||||
'resign' => $resign,
|
'resign' => $resign,
|
||||||
'ogTitle' => "MineSeeker · $summary",
|
'redAvatar' => $redAvatar,
|
||||||
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
'blueAvatar' => $blueAvatar,
|
||||||
|
'ogTitle' => "MineSeeker · $summary",
|
||||||
|
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Entity\PlayedGame;
|
use App\Entity\PlayedGame;
|
||||||
|
use Exception;
|
||||||
|
use GdImage;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,11 +32,17 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
*/
|
*/
|
||||||
class BattleCardGenerator
|
class BattleCardGenerator
|
||||||
{
|
{
|
||||||
private const W = 1200;
|
private const int WIDTH = 1200;
|
||||||
private const H = 630;
|
private const int HEIGHT = 630;
|
||||||
private const FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
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. */
|
/** Returns a deterministic UUID v5 for the given battle ID — same battle always maps to the same filename. */
|
||||||
public function cachePath(int $battleId): string
|
public function cachePath(int $battleId): string
|
||||||
@@ -50,7 +61,13 @@ class BattleCardGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir($this->cacheDir)) {
|
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);
|
$this->render($game, $path);
|
||||||
@@ -60,9 +77,9 @@ class BattleCardGenerator
|
|||||||
|
|
||||||
private function render(PlayedGame $game, string $dest): void
|
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);
|
$bg = imagecolorallocate($im, 13, 13, 28);
|
||||||
$dot = imagecolorallocate($im, 30, 30, 55);
|
$dot = imagecolorallocate($im, 30, 30, 55);
|
||||||
$divider = imagecolorallocate($im, 40, 40, 70);
|
$divider = imagecolorallocate($im, 40, 40, 70);
|
||||||
@@ -72,24 +89,24 @@ class BattleCardGenerator
|
|||||||
$blue = imagecolorallocate($im, 149, 207, 245);
|
$blue = imagecolorallocate($im, 149, 207, 245);
|
||||||
$gold = imagecolorallocate($im, 255, 200, 50);
|
$gold = imagecolorallocate($im, 255, 200, 50);
|
||||||
|
|
||||||
// Background
|
/** Background*/
|
||||||
imagefill($im, 0, 0, $bg);
|
imagefill($im, 0, 0, $bg);
|
||||||
|
|
||||||
// Dot-grid texture
|
/** Dot-grid texture*/
|
||||||
for ($x = 40; $x < self::W; $x += 40) {
|
for ($x = 40; $x < self::WIDTH; $x += 40) {
|
||||||
for ($y = 40; $y < self::H; $y += 40) {
|
for ($y = 40; $y < self::HEIGHT; $y += 40) {
|
||||||
imagesetpixel($im, $x, $y, $dot);
|
imagesetpixel($im, $x, $y, $dot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontal accent lines
|
/** Horizontal accent lines*/
|
||||||
imageline($im, 0, 90, self::W, 90, $divider);
|
imageline($im, 0, 90, self::WIDTH, 90, $divider);
|
||||||
imageline($im, 0, self::H - 60, self::W, self::H - 60, $divider);
|
imageline($im, 0, self::HEIGHT - 60, self::WIDTH, self::HEIGHT - 60, $divider);
|
||||||
|
|
||||||
// Vertical centre divider
|
/** Vertical centre divider*/
|
||||||
imageline($im, self::W / 2, 110, self::W / 2, self::H - 80, $divider);
|
imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider);
|
||||||
|
|
||||||
// Resolve names
|
/** Resolve names*/
|
||||||
$redName = $game->getRed()?->getUsername()
|
$redName = $game->getRed()?->getUsername()
|
||||||
?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
|
?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
|
||||||
$blueName = $game->getBlue()?->getUsername()
|
$blueName = $game->getBlue()?->getUsername()
|
||||||
@@ -98,7 +115,7 @@ class BattleCardGenerator
|
|||||||
$bluePts = $game->getBluePoints();
|
$bluePts = $game->getBluePoints();
|
||||||
$resign = $game->getResign();
|
$resign = $game->getResign();
|
||||||
|
|
||||||
// Winner
|
/** Winner*/
|
||||||
$winner = null;
|
$winner = null;
|
||||||
if ($resign === 'red') {
|
if ($resign === 'red') {
|
||||||
$winner = 'blue';
|
$winner = 'blue';
|
||||||
@@ -110,19 +127,32 @@ class BattleCardGenerator
|
|||||||
else $winner = 'draw';
|
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);
|
/** RED and BLUE labels aligned with avatars horizontally*/
|
||||||
$this->centeredText($im, 'BLUE', 16, self::W * 3 / 4, 130, $blue);
|
$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);
|
$redColor = $winner === 'red' ? $gold : ($winner === 'draw' ? $white : $red);
|
||||||
$blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue);
|
$blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue);
|
||||||
|
|
||||||
$this->centeredTextFit($im, $redName, 48, self::W / 4, 265, $redColor, self::W / 2 - 80);
|
/** Truncate long usernames (max 10 chars + "...")*/
|
||||||
$this->centeredTextFit($im, $blueName, 48, self::W * 3 / 4, 265, $blueColor, self::W / 2 - 80);
|
$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';
|
$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') {
|
if ($winner === 'red') {
|
||||||
$resultText = $redName . ' wins';
|
$resultText = $redName . ' wins';
|
||||||
@@ -139,21 +169,116 @@ class BattleCardGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($resultText !== '') {
|
if ($resultText !== '') {
|
||||||
$this->centeredText($im, $resultText, 30, self::W / 2, 460, $resultColor);
|
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($resign) {
|
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);
|
imagepng($im, $dest);
|
||||||
imagedestroy($im);
|
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. */
|
/** 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);
|
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
||||||
$w = $bbox[2] - $bbox[0];
|
$w = $bbox[2] - $bbox[0];
|
||||||
@@ -162,13 +287,13 @@ class BattleCardGenerator
|
|||||||
|
|
||||||
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
|
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
|
||||||
private function centeredTextFit(
|
private function centeredTextFit(
|
||||||
\GdImage $im,
|
GdImage $im,
|
||||||
string $text,
|
string $text,
|
||||||
int $size,
|
int $size,
|
||||||
int $cx,
|
int $cx,
|
||||||
int $y,
|
int $y,
|
||||||
int $color,
|
int $color,
|
||||||
int $maxWidth
|
int $maxWidth
|
||||||
): void {
|
): void {
|
||||||
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
||||||
$w = $bbox[2] - $bbox[0];
|
$w = $bbox[2] - $bbox[0];
|
||||||
|
|||||||
@@ -32,7 +32,13 @@
|
|||||||
<div class="bshare-vs">
|
<div class="bshare-vs">
|
||||||
<div class="bshare-player bshare-player--red">
|
<div class="bshare-player bshare-player--red">
|
||||||
<div class="bshare-avatar bshare-avatar--red">
|
<div class="bshare-avatar bshare-avatar--red">
|
||||||
{{ redName|slice(0,2)|upper }}
|
{% if redAvatar %}
|
||||||
|
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
|
||||||
|
alt="{{ redName }}"
|
||||||
|
class="bshare-avatar__img">
|
||||||
|
{% else %}
|
||||||
|
{{ redName|slice(0,2)|upper }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<span class="bshare-player__name">{{ redName }}</span>
|
<span class="bshare-player__name">{{ redName }}</span>
|
||||||
<span class="bshare-player__side">Red</span>
|
<span class="bshare-player__side">Red</span>
|
||||||
@@ -74,7 +80,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bshare-player bshare-player--blue">
|
<div class="bshare-player bshare-player--blue">
|
||||||
<div class="bshare-avatar bshare-avatar--blue">
|
<div class="bshare-avatar bshare-avatar--blue">
|
||||||
{{ blueName|slice(0,2)|upper }}
|
{% if blueAvatar %}
|
||||||
|
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
|
||||||
|
alt="{{ blueName }}"
|
||||||
|
class="bshare-avatar__img">
|
||||||
|
{% else %}
|
||||||
|
{{ blueName|slice(0,2)|upper }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<span class="bshare-player__name">{{ blueName }}</span>
|
<span class="bshare-player__name">{{ blueName }}</span>
|
||||||
<span class="bshare-player__side">Blue</span>
|
<span class="bshare-player__side">Blue</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user