diff --git a/Controllers/ShopImageController.cs b/Controllers/ShopImageController.cs index d05024e..a9ffdf1 100644 --- a/Controllers/ShopImageController.cs +++ b/Controllers/ShopImageController.cs @@ -21,6 +21,7 @@ public partial class ShopImageController : ControllerBase private const int HORIZONTAL_PADDING = 100; private const int BOTTOM_PADDING = 100; private const int HEADER_HEIGHT = 450; + private const int LAYOUT_HEADER_HEIGHT = 200; private const int COLUMN_SPACE = 100; private const int CARDS_PER_SECTION = 5; private const int CARD_WIDTH = 220; @@ -36,6 +37,7 @@ public partial class ShopImageController : ControllerBase private const float SECTION_NAME_FONT_SIZE = 43f; private const float ENTRY_NAME_FONT_SIZE = 27f; private const float ENTRY_PRICE_FONT_SIZE = 21f; + private const float LAYOUT_NAME_FONT_SIZE = 100f; private static readonly MemoryCacheEntryOptions ShopImageCacheOptions = new() @@ -115,60 +117,39 @@ public async Task Shop([FromBody] Shop shop, [FromQuery] string? return File(data.AsStream(true), "image/png"); } - [HttpPost("section")] - public async Task ShopSection([FromBody] ShopSection section, [FromQuery] string? locale, + [HttpPost("layout")] + public async Task ShopSection([FromBody] ShopLayout layout, [FromQuery] string? locale, [FromQuery] bool? isNewShop) { locale ??= "en"; var _isNewShop = isNewShop ?? false; - Console.WriteLine($"Item Shop section image request | Locale = {locale} | New Shop = {section.Id}"); + Console.WriteLine($"Item Shop section image request | Locale = {locale} | New Shop = {isNewShop}"); SKBitmap? templateBitmap; - ShopSectionLocationData? shopSectionLocationData; + ShopSectionLocationData[]? shopSectionLocationData; - await _namedLock.WaitAsync($"shop_section_template_{section.Id}"); + await _namedLock.WaitAsync($"shop_section_template_{layout.Id}"); try { - templateBitmap = _cache.Get($"shop_section_template_bmp_{section.Id}"); - shopSectionLocationData = _cache.Get($"shop_section_location_data_{section.Id}"); + templateBitmap = _cache.Get($"shop_section_template_bmp_{layout.Id}"); + shopSectionLocationData = _cache.Get($"shop_section_location_data_{layout.Id}"); if (_isNewShop || templateBitmap is null) { - await PrefetchImages([section]); - var templateGenerationResult = await GenerateSectionTemplate(section); + await PrefetchImages(layout.Sections); + var templateGenerationResult = await GenerateLayoutTemplate(layout); templateBitmap = templateGenerationResult.Item2; shopSectionLocationData = templateGenerationResult.Item1; - _cache.Set($"shop_section_template_bmp_{section.Id}", templateBitmap, ShopImageCacheOptions); - _cache.Set($"shop_section_location_data_{section.Id}", shopSectionLocationData, + _cache.Set($"shop_section_template_bmp_{layout.Id}", templateBitmap, ShopImageCacheOptions); + _cache.Set($"shop_section_location_data_{layout.Id}", shopSectionLocationData, TimeSpan.FromMinutes(10)); } } finally { - _namedLock.Release($"shop_section_template_{section.Id}"); + _namedLock.Release($"shop_section_template_{layout.Id}"); } - SKBitmap? localeTemplateBitmap; - - var lockName = $"shop_section_template_{locale}_{section.Id}"; - await _namedLock.WaitAsync(lockName); - try - { - localeTemplateBitmap = _cache.Get($"shop_section_template_{locale}_bmp_{section.Id}"); - if (_isNewShop || localeTemplateBitmap == null) - { - localeTemplateBitmap = - await GenerateSectionLocaleTemplate(section, templateBitmap, shopSectionLocationData!); - _cache.Set($"shop_section_template_{locale}_bmp_{section.Id}", localeTemplateBitmap, - ShopImageCacheOptions); - } - } - finally - { - _namedLock.Release(lockName); - } - - using var localeTemplateBitmapCopy = localeTemplateBitmap.Copy(); - using var image = await GenerateShopSectionImage(section, localeTemplateBitmapCopy); + using var image = await GenerateShopLayoutImage(layout, templateBitmap, shopSectionLocationData!); var data = image.Encode(SKEncodedImageFormat.Png, 100); return File(data.AsStream(true), "image/png"); } @@ -434,8 +415,7 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template columnCount = curColumnCount; } - var imageInfo = new SKImageInfo(width, height); - var bitmap = new SKBitmap(imageInfo); + var bitmap = new SKBitmap(new SKImageInfo(width, height)); using var canvas = new SKCanvas(bitmap); var shopLocationData = new ShopSectionLocationData[shop.Sections.Length]; @@ -446,8 +426,7 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template for (var j = 0; j < sections.Count; j++) { var section = sections[j]; - var sectionImageInfo = new SKImageInfo(SECTION_WIDTH, SECTION_HEIGHT); - using var sectionBitmap = new SKBitmap(sectionImageInfo); + using var sectionBitmap = new SKBitmap(new SKImageInfo(SECTION_WIDTH, SECTION_HEIGHT)); using var sectionCanvas = new SKCanvas(sectionBitmap); var sectionX = HORIZONTAL_PADDING + i * SECTION_WIDTH + i * COLUMN_SPACE; @@ -710,19 +689,215 @@ private static string[] SplitNameText(string text, int maxWidth, SKPaint paint) [GeneratedRegex("([a-z0-9]+|[^a-z0-9])", RegexOptions.IgnoreCase)] private static partial Regex NameSplitRegex(); - private async Task GenerateShopSectionImage(ShopSection section, SKBitmap templateBitmap) + private async Task GenerateShopLayoutImage(ShopLayout layout, SKBitmap templateBitmap, + ShopSectionLocationData[] shopSectionLocationData) { - return null; - } + var imageInfo = new SKImageInfo(templateBitmap.Width, templateBitmap.Height); + var bitmap = new SKBitmap(imageInfo); + using var canvas = new SKCanvas(bitmap); - private async Task GenerateSectionLocaleTemplate(ShopSection section, SKBitmap templateBitmap, - ShopSectionLocationData sectionLocationData) - { - return null; + canvas.DrawBitmap(templateBitmap, SKPoint.Empty); + + using var paint = new SKPaint(); + paint.IsAntialias = true; + paint.Shader = SKShader.CreateLinearGradient( + new SKPoint((float)imageInfo.Width / 2, 0), + new SKPoint((float)imageInfo.Width / 2, imageInfo.Height), + [new SKColor(44, 154, 234), new SKColor(14, 53, 147)], + [0.0f, 1.0f], + SKShaderTileMode.Repeat); + + canvas.DrawRoundRect(0, 0, imageInfo.Width, imageInfo.Height, 50, 50, paint); + + // Drawing + using (var layoutNamePaint = new SKPaint()) + { + layoutNamePaint.IsAntialias = true; + layoutNamePaint.TextSize = LAYOUT_NAME_FONT_SIZE; + layoutNamePaint.Color = SKColors.White; + layoutNamePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-86Bold.otf"); + + canvas.DrawText(layout.Name, 50, 50 - layoutNamePaint.FontMetrics.Ascent, layoutNamePaint); + } + + foreach (var sectionLocationData in shopSectionLocationData) + { + var shopSection = layout.Sections.FirstOrDefault(x => x.Id == sectionLocationData.Id); + + // Draw the section name if it exists + if (sectionLocationData.Name != null) + { + using var sectionNamePaint = new SKPaint(); + sectionNamePaint.IsAntialias = true; + sectionNamePaint.TextSize = SECTION_NAME_FONT_SIZE; + sectionNamePaint.Color = SKColors.White; + sectionNamePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-86BoldItalic.otf"); + + var sectionNamePoint = new SKPoint(sectionLocationData.Name.X, + sectionLocationData.Name.Y - sectionNamePaint.FontMetrics.Ascent); + canvas.DrawText(shopSection?.Name, sectionNamePoint, sectionNamePaint); + } + + foreach (var entryLocationData in sectionLocationData.Entries) + { + var shopEntry = shopSection?.Entries?.FirstOrDefault(x => x.Id == entryLocationData.Id); + if (shopEntry is null) + continue; + + // Draw the shop entry name + using (var entryNamePaint = new SKPaint()) + { + entryNamePaint.IsAntialias = true; + entryNamePaint.TextSize = ENTRY_NAME_FONT_SIZE; + entryNamePaint.Color = SKColors.White; + entryNamePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf"); + + var entryNameTextBounds = new SKRect(); + var nameLines = SplitNameText(shopEntry.Name, entryLocationData.Name.MaxWidth ?? 0, entryNamePaint); + if (nameLines.Length > 1) + { + entryNamePaint.MeasureText(nameLines[0], ref entryNameTextBounds); + canvas.DrawText(nameLines[0], entryLocationData.Name.X, + entryLocationData.Name.Y + entryNameTextBounds.Height - 33, entryNamePaint); + } + + entryNamePaint.MeasureText(nameLines.Last(), ref entryNameTextBounds); + canvas.DrawText(nameLines.Last(), entryLocationData.Name.X, + entryLocationData.Name.Y + entryNameTextBounds.Height, entryNamePaint); + } + + // Draw the shop entry price + using var pricePaint = new SKPaint(); + pricePaint.IsAntialias = true; + pricePaint.TextSize = ENTRY_PRICE_FONT_SIZE; + pricePaint.Color = SKColors.White; + pricePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf"); + + var priceTextWidth = pricePaint.MeasureText(shopEntry.FinalPrice); + var pricePoint = new SKPoint(entryLocationData.Price.X, + entryLocationData.Price.Y - pricePaint.FontMetrics.Descent); + canvas.DrawText(shopEntry.FinalPrice, pricePoint, pricePaint); + + // Draw strikeout old price if item is discounted + if (shopEntry.FinalPrice != shopEntry.RegularPrice) + { + using var oldPricePaint = new SKPaint(); + oldPricePaint.IsAntialias = true; + oldPricePaint.TextSize = ENTRY_PRICE_FONT_SIZE; + oldPricePaint.Color = SKColors.White.WithAlpha((int)(.6 * 255)); + oldPricePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf"); + + var oldPriceTextWidth = oldPricePaint.MeasureText(shopEntry.RegularPrice); + var oldPricePoint = new SKPoint(entryLocationData.Price.X + priceTextWidth + 9, + entryLocationData.Price.Y - pricePaint.FontMetrics.Descent); + canvas.DrawText(shopEntry.RegularPrice, oldPricePoint, oldPricePaint); + + // Draw the strikeout line + using var strikePaint = new SKPaint(); + strikePaint.IsAntialias = true; + strikePaint.StrokeWidth = 2f; + strikePaint.Color = SKColors.White.WithAlpha((int)(.6 * 255)); + + var strikeStart = new SKPoint(oldPricePoint.X - 4, oldPricePoint.Y - 9); + var strikeEnd = new SKPoint(oldPricePoint.X + oldPriceTextWidth + 2, oldPricePoint.Y - 6); + canvas.DrawLine(strikeStart, strikeEnd, strikePaint); + } + + if (shopEntry.Banner != null) + { + using var bannerBitmap = await GenerateBanner(shopEntry.Banner.Text, shopEntry.Banner.Colors, + (int)entryLocationData.Banner!.MaxWidth!); + canvas.DrawBitmap(bannerBitmap, entryLocationData.Banner!.X, entryLocationData.Banner.Y); + } + } + } + + return bitmap; } - private async Task<(ShopSectionLocationData, SKBitmap)> GenerateSectionTemplate(ShopSection section) + private async Task<(ShopSectionLocationData[], SKBitmap)> GenerateLayoutTemplate(ShopLayout layout) { - return (null, null); + var columnCount = 1; + var bestAspectRatioDiff = float.MaxValue; + int width = 0, height = 0, sectionsPerColumn = 0; + for (var curColumnCount = columnCount; curColumnCount <= 15; curColumnCount++) + { + var curWidth = HORIZONTAL_PADDING + curColumnCount * SECTION_WIDTH + (curColumnCount - 1) * COLUMN_SPACE; + var curSectionsPerColumn = (int)Math.Ceiling((double)layout.Sections.Length / curColumnCount); + var curHeight = LAYOUT_HEADER_HEIGHT + curSectionsPerColumn * SECTION_HEIGHT + + (curSectionsPerColumn - 1) * CARD_SPACE + BOTTOM_PADDING; + + // The goal is reaching a 1:1 aspect ratio + var aspectRatio = (float)curWidth / curHeight; + var aspectRatioDiff = Math.Abs(aspectRatio - 1); + if (aspectRatioDiff >= bestAspectRatioDiff) break; + + width = curWidth; + height = curHeight; + sectionsPerColumn = curSectionsPerColumn; + bestAspectRatioDiff = aspectRatioDiff; + columnCount = curColumnCount; + } + + var bitmap = new SKBitmap(new SKImageInfo(width, height)); + using var canvas = new SKCanvas(bitmap); + + var shopLocationData = new ShopSectionLocationData[layout.Sections.Length]; + var iSec = 0; + for (var i = 0; i < columnCount; i++) + { + var columnSections = layout.Sections.Skip(i * sectionsPerColumn).Take(sectionsPerColumn).ToList(); + for (var j = 0; j < columnSections.Count; j++) + { + var section = columnSections[j]; + using var sectionBitmap = new SKBitmap(new SKImageInfo(SECTION_WIDTH, SECTION_HEIGHT)); + using var sectionCanvas = new SKCanvas(sectionBitmap); + + var sectionX = HORIZONTAL_PADDING / 2 + i * SECTION_WIDTH + i * COLUMN_SPACE; + var sectionY = LAYOUT_HEADER_HEIGHT + j * SECTION_HEIGHT + j * CARD_SPACE; + + var position = 0f; + var shopEntryData = new List(); + foreach (var entry in section.Entries) + { + // If the next card is full height, we can't fit it in the current column + if (!MathF.Floor(position).Equals(position) && entry.Size >= 1) position = MathF.Ceiling(position); + var entryX = (int)position * CARD_WIDTH + (int)position * CARD_SPACE; + var entryY = SECTION_HEIGHT - CARD_HEIGHT + + (MathF.Floor(position).Equals(position) ? 0 : (CARD_HEIGHT + CARD_SPACE) / 2); + position += entry.Size; + + using var itemCardBitmap = await GenerateItemCard(entry); + using var itemCardPaint = new SKPaint(); + itemCardPaint.IsAntialias = true; + itemCardPaint.Shader = SKShader.CreateBitmap(itemCardBitmap, SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp, SKMatrix.CreateTranslation(entryX, entryY)); + sectionCanvas.DrawRoundRect(entryX, entryY, itemCardBitmap.Width, itemCardBitmap.Height, 20, 20, + itemCardPaint); + + var nameLocationData = new ShopLocationDataEntry(sectionX + entryX + 13, + sectionY + entryY + itemCardBitmap.Height - 72, itemCardBitmap.Width - 2 * CARD_PADDING); + var priceLocationData = new ShopLocationDataEntry(sectionX + entryX + 13 + 22 + 8, + sectionY + entryY + itemCardBitmap.Height - 8); + ShopLocationDataEntry? bannerLocationData = null; + if (entry.Banner != null) + bannerLocationData = new ShopLocationDataEntry(sectionX + entryX + 8, sectionY + entryY + 8, + itemCardBitmap.Width - 2 * 8); + shopEntryData.Add(new ShopEntryLocationData(entry.Id, nameLocationData, priceLocationData, + bannerLocationData)); + } + + ShopLocationDataEntry? sectionNameLocationData = null; + if (section.Name != null) + sectionNameLocationData = new ShopLocationDataEntry(sectionX, sectionY); + shopLocationData[iSec] = + new ShopSectionLocationData(section.Id, sectionNameLocationData, shopEntryData.ToArray()); + + canvas.DrawBitmap(sectionBitmap, new SKPoint(sectionX, sectionY)); + iSec++; + } + } + + return (shopLocationData, bitmap); } } \ No newline at end of file diff --git a/Models/Shop.cs b/Models/Shop.cs index 35d29d9..8d55266 100644 --- a/Models/Shop.cs +++ b/Models/Shop.cs @@ -10,7 +10,14 @@ public class Shop public string CreatorCodeTitle { get; set; } public string? CreatorCode { get; set; } public string? BackgroundImagePath { get; set; } - public ShopSection[] Sections { get; set; } + public ShopSection[] Sections { get; set; } +} + +public class ShopLayout +{ + public string Id { get; set; } + public string Name { get; set; } + public ShopSection[] Sections { get; set; } } public class ShopSection @@ -50,22 +57,23 @@ public ShopSectionLocationData(string id, ShopLocationDataEntry? name, ShopEntry Name = name; Entries = entries; } - + public string Id { get; } public ShopLocationDataEntry? Name { get; } public ShopEntryLocationData[] Entries { get; } -} +} public class ShopEntryLocationData { - public ShopEntryLocationData(string id, ShopLocationDataEntry name, ShopLocationDataEntry price, ShopLocationDataEntry? banner) + public ShopEntryLocationData(string id, ShopLocationDataEntry name, ShopLocationDataEntry price, + ShopLocationDataEntry? banner) { Id = id; Name = name; Price = price; Banner = banner; } - + public string Id { get; } public ShopLocationDataEntry Name { get; } public ShopLocationDataEntry Price { get; } @@ -79,14 +87,14 @@ public ShopLocationDataEntry(int x, int y) X = x; Y = y; } - + public ShopLocationDataEntry(int x, int y, int maxWidth) { X = x; Y = y; MaxWidth = maxWidth; } - + public int X { get; } public int Y { get; } public int? MaxWidth { get; }