diff --git a/.env.example b/.env.example index e44445f2..07940c9b 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,9 @@ BOT_USER_ID="1090704976784916581" # This is not required for local deployment using compose. When deploying with Dokploy, this is the domain aftermath service will be available on. TRAEFIK_HOST="local.amth.one" +# Themes +HIGHLIGHTED_THEME="" # Theme ID applied to users without a custom theme or background (e.g. "spring2026") + # Misc configuration FRONTEND_URL="https://yourdomain.com" WEBAPP_NAME="Aftermath" diff --git a/Dockerfile b/Dockerfile index 06768059..704e33c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,9 +29,10 @@ COPY ./ ./ # COPY --from=builder-node /workspace/static/localization/ ./static/localization/ # generate static assets -RUN --mount=type=cache,target=$GOPATH/pkg/mod go generate ./internal/assets RUN --mount=type=cache,target=$GOPATH/pkg/mod go generate ./internal/external/blitzkit +RUN --mount=type=cache,target=$GOPATH/pkg/mod go generate ./internal/assets RUN --mount=type=cache,target=$GOPATH/pkg/mod go generate ./cmd/frontend/assets/generate +RUN --mount=type=cache,target=$GOPATH/pkg/mod go generate ./internal/stats/render/themes/.../assets # generate frontend RUN --mount=type=cache,target=$GOPATH/pkg/mod go tool templ generate diff --git a/cmd/discord/commands/public/career.go b/cmd/discord/commands/public/career.go index 0e644727..32a4228f 100644 --- a/cmd/discord/commands/public/career.go +++ b/cmd/discord/commands/public/career.go @@ -115,6 +115,15 @@ func careerCommandHandler(ctx common.Context) error { } } + themeID, theme, themeHint := resolveTheme(ctx.User(), ioptions.BackgroundID != "") + if theme != nil { + opts = append(opts, stats.WithTheme(*theme)) + ioptions.ThemeID = themeID + if themeHint != "" && message == "" { + message = themeHint + } + } + image, meta, err := ctx.Core().Stats(ctx.Locale()).PeriodImage(ctx.Ctx(), accountID, options.PeriodStart, opts...) if err != nil { return ctx.Err(err, common.ApplicationError) diff --git a/cmd/discord/commands/public/common.go b/cmd/discord/commands/public/common.go index e7c7308e..c6c82462 100644 --- a/cmd/discord/commands/public/common.go +++ b/cmd/discord/commands/public/common.go @@ -8,8 +8,11 @@ import ( "unicode" "unicode/utf8" + "github.com/cufee/aftermath/internal/constants" "github.com/cufee/aftermath/internal/external/wargaming" "github.com/cufee/aftermath/internal/json" + rendercommon "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/aftermath/internal/stats/render/themes" "github.com/cufee/am-wg-proxy-next/v2/types" "github.com/bwmarrin/discordgo" @@ -24,6 +27,26 @@ type statsOptions struct { commands.StatsOptions BackgroundID string ReferenceID string + ThemeID string +} + +// resolveTheme determines the theme to apply based on user preference and highlighted env. +// When a non-nil theme is returned, the caller should skip loading custom backgrounds. +func resolveTheme(user models.User, hasCustomBackground bool) (themeID string, theme *rendercommon.Theme, hintKey string) { + if content, ok := user.Content(models.UserContentTypeThemePreference); ok { + id := string(content.Value) + if t, ok := themes.GetTheme(id); ok { + return id, &t, "" + } + } + + if !hasCustomBackground && constants.HighlightedTheme != "" { + if t, ok := themes.GetTheme(constants.HighlightedTheme); ok { + return constants.HighlightedTheme, &t, "theme_highlighted_hint" + } + } + + return "", nil, "" } func (o statsOptions) fromInteraction(data models.DiscordInteraction) (statsOptions, error) { diff --git a/cmd/discord/commands/public/my.go b/cmd/discord/commands/public/my.go index 7d212594..fb69d93f 100644 --- a/cmd/discord/commands/public/my.go +++ b/cmd/discord/commands/public/my.go @@ -90,6 +90,15 @@ func init() { accountID = defaultAccount.ReferenceID } + themeID, theme, themeHint := resolveTheme(ctx.User(), ioptions.BackgroundID != "") + if theme != nil { + opts = append(opts, stats.WithTheme(*theme)) + ioptions.ThemeID = themeID + if themeHint != "" && message == "" { + message = themeHint + } + } + var err error var image stats.Image switch subcommand { @@ -122,7 +131,7 @@ func init() { if err != nil { return ctx.Err(err, common.ApplicationError) } - return ctx.Reply().WithAds().File(buf.Bytes(), "session_command_by_aftermath.png").Component(button).Send() + return ctx.Reply().WithAds().Hint(message).File(buf.Bytes(), "session_command_by_aftermath.png").Component(button).Send() }), ) } diff --git a/cmd/discord/commands/public/session.go b/cmd/discord/commands/public/session.go index 0c764cd3..b9f4904b 100644 --- a/cmd/discord/commands/public/session.go +++ b/cmd/discord/commands/public/session.go @@ -115,6 +115,15 @@ func init() { } } + themeID, theme, themeHint := resolveTheme(ctx.User(), ioptions.BackgroundID != "") + if theme != nil { + opts = append(opts, stats.WithTheme(*theme)) + ioptions.ThemeID = themeID + if themeHint != "" && message == "" { + message = themeHint + } + } + image, meta, err := ctx.Core().Stats(ctx.Locale()).SessionImage(ctx.Ctx(), accountID, options.PeriodStart, opts...) if err != nil { if errors.Is(err, stats.ErrAccountNotTracked) || (errors.Is(err, fetch.ErrSessionNotFound) && options.Days < 1) { diff --git a/cmd/discord/commands/public/stats_interactions.go b/cmd/discord/commands/public/stats_interactions.go index fd4e4e64..53b675b5 100644 --- a/cmd/discord/commands/public/stats_interactions.go +++ b/cmd/discord/commands/public/stats_interactions.go @@ -14,9 +14,9 @@ import ( "github.com/cufee/aftermath/internal/glossary" "github.com/cufee/aftermath/internal/logic" "github.com/cufee/aftermath/internal/permissions" - "github.com/cufee/aftermath/internal/stats/fetch/v1" - stats "github.com/cufee/aftermath/internal/stats/client/common" + "github.com/cufee/aftermath/internal/stats/fetch/v1" + "github.com/cufee/aftermath/internal/stats/render/themes" "github.com/cufee/aftermath/internal/log" "github.com/pkg/errors" @@ -66,7 +66,11 @@ func init() { } } - if ioptions.BackgroundID != "" { + if ioptions.ThemeID != "" { + if t, ok := themes.GetTheme(ioptions.ThemeID); ok { + opts = append(opts, stats.WithTheme(t)) + } + } else if ioptions.BackgroundID != "" { background, _ := ctx.Core().Database().GetUserContent(ctx.Ctx(), ioptions.BackgroundID) if img, err := logic.UserContentToImage(background); err == nil { opts = append(opts, stats.WithBackground(img, true)) diff --git a/cmd/discord/commands/public/theme.go b/cmd/discord/commands/public/theme.go new file mode 100644 index 00000000..f2947de7 --- /dev/null +++ b/cmd/discord/commands/public/theme.go @@ -0,0 +1,123 @@ +package public + +import ( + "fmt" + "sort" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/cufee/aftermath/cmd/discord/commands" + "github.com/cufee/aftermath/cmd/discord/commands/builder" + "github.com/cufee/aftermath/cmd/discord/common" + "github.com/cufee/aftermath/cmd/discord/middleware" + "github.com/cufee/aftermath/internal/database" + "github.com/cufee/aftermath/internal/database/models" + "github.com/cufee/aftermath/internal/permissions" + "github.com/cufee/aftermath/internal/stats/render/themes" +) + +func buildThemeChoices() []builder.OptionChoice { + var choices []builder.OptionChoice + for _, id := range themes.AvailableThemes() { + choices = append(choices, builder.NewChoice(id, id)) + } + return choices +} + +func init() { + commands.LoadedPublic.Add( + builder.NewCommand("theme"). + Middleware(middleware.RequirePermissions(permissions.UseTextCommands)). + Ephemeral(). + Params(builder.SetNameKey("command_theme_name"), builder.SetDescKey("command_theme_description")). + Options( + builder.NewOption("select", discordgo.ApplicationCommandOptionString). + Params(builder.SetNameKey("command_theme_option_select_name"), builder.SetDescKey("command_theme_option_select_description")). + Choices(buildThemeChoices()...), + builder.NewOption("clear", discordgo.ApplicationCommandOptionBoolean). + Params(builder.SetNameKey("command_theme_option_clear_name"), builder.SetDescKey("command_theme_option_clear_description")), + ). + Handler(func(ctx common.Context) error { + if clear, ok := common.GetOption[bool](ctx.Options(), "clear"); ok && clear { + existing, err := ctx.Core().Database().GetUserContentFromRef(ctx.Ctx(), ctx.User().ID, models.UserContentTypeThemePreference) + if err != nil && !database.IsNotFound(err) { + return ctx.Err(err, common.ApplicationError) + } + if !database.IsNotFound(err) { + err = ctx.Core().Database().DeleteUserContent(ctx.Ctx(), existing.ID) + if err != nil { + return ctx.Err(err, common.ApplicationError) + } + } + return ctx.Reply().Send("command_theme_reset_success") + } + + selected, hasSelection := common.GetOption[string](ctx.Options(), "select") + if !hasSelection { + currentTheme, hasTheme := ctx.User().Content(models.UserContentTypeThemePreference) + var currentID string + if hasTheme { + currentID = string(currentTheme.Value) + } else { + currentID = "default" + } + + ids := themes.AvailableThemes() + sort.Strings(ids) + + var lines []string + for _, id := range ids { + name := ctx.Localize(fmt.Sprintf("command_theme_option_select_choice_%s_name", id)) + if name == "" { + name = id + } + if id == currentID { + lines = append(lines, "⬢ "+name) + } else { + lines = append(lines, "⬡ "+name) + } + } + + return ctx.Reply().Format("command_theme_current_fmt", strings.Join(lines, "\n")).Send() + } + + if selected == "default" { + existing, err := ctx.Core().Database().GetUserContentFromRef(ctx.Ctx(), ctx.User().ID, models.UserContentTypeThemePreference) + if err != nil && !database.IsNotFound(err) { + return ctx.Err(err, common.ApplicationError) + } + if !database.IsNotFound(err) { + err = ctx.Core().Database().DeleteUserContent(ctx.Ctx(), existing.ID) + if err != nil { + return ctx.Err(err, common.ApplicationError) + } + } + return ctx.Reply().Send("command_theme_reset_success") + } + + if _, ok := themes.GetTheme(selected); !ok { + return ctx.Reply().IsError(common.UserError).Send("command_theme_not_found") + } + + existing, err := ctx.Core().Database().GetUserContentFromRef(ctx.Ctx(), ctx.User().ID, models.UserContentTypeThemePreference) + if err != nil && !database.IsNotFound(err) { + return ctx.Err(err, common.ApplicationError) + } + if database.IsNotFound(err) { + existing = models.UserContent{ + UserID: ctx.User().ID, + ReferenceID: ctx.User().ID, + } + } + + existing.Type = models.UserContentTypeThemePreference + existing.Value = []byte(selected) + _, err = ctx.Core().Database().UpsertUserContent(ctx.Ctx(), existing) + if err != nil { + return ctx.Err(err, common.ApplicationError) + } + + return ctx.Reply().Format("command_theme_set_success_fmt", selected).Send() + }), + ) +} diff --git a/cmd/discord/cta/messages.go b/cmd/discord/cta/messages.go index 561bc406..8cee1783 100644 --- a/cmd/discord/cta/messages.go +++ b/cmd/discord/cta/messages.go @@ -92,6 +92,12 @@ var ( buttons: []discordgo.Button{common.ButtonInviteAftermath("cta_guild_install_button")}, } + ctaCommandTheme = CallToActionMessage{ + TagsBlacklist: []string{"command_theme"}, + headKey: "cta_command_theme_head", + bodyKey: "cta_command_theme_body", + } + ctaAbandonedPositiveGrowth = CallToActionMessage{ TagsWhitelist: []string{"growth", "growth_positive"}, headKey: "cta_abandoned_positive_growth_head", @@ -120,6 +126,7 @@ var defaultCTACollection = MessageCollection{ ctaCommandReplay, ctaCommandLinksAdd, ctaCommandLinksVerify, + ctaCommandTheme, } func (c MessageCollection) NewEmbed(locale language.Tag, tags []string) (discordgo.MessageEmbed, []discordgo.MessageComponent, bool) { diff --git a/go.mod b/go.mod index f6618250..63004803 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/bwmarrin/discordgo v0.29.0 github.com/cufee/aftermath-assets v0.1.0 github.com/cufee/am-wg-proxy-next/v2 v2.2.6 - github.com/cufee/facepaint v0.0.9 + github.com/cufee/facepaint v0.1.0 github.com/fogleman/gg v1.3.0 github.com/go-co-op/gocron v1.37.0 github.com/goccy/go-json v0.10.5 diff --git a/go.sum b/go.sum index 4d766350..719bde5d 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,8 @@ github.com/cufee/aftermath-assets v0.1.0 h1:r8p2mUN+h/cw1T6/oEX7bntD+lrL9Nz27GXO github.com/cufee/aftermath-assets v0.1.0/go.mod h1:6yCITCiz7POJnUMn1oohvadLA4z5YrFNo3p9EKgRdGU= github.com/cufee/am-wg-proxy-next/v2 v2.2.6 h1:6RAnPuYbPGtaLzOPhTk/N2Hx4KJx14x/c/cIik668xA= github.com/cufee/am-wg-proxy-next/v2 v2.2.6/go.mod h1:x6fkRfYry3l4Ykxl+v6pJAw5ISw+CuGzJzSkc5y5SYs= -github.com/cufee/facepaint v0.0.9 h1:cXoQpjqLtrcEKs6KwVt2DKB+uPREIPHPw14ilLRyboc= -github.com/cufee/facepaint v0.0.9/go.mod h1:7zR5lQMN3EO3qNtff0J8nzIhDb258UoYbRzhRToLQdg= +github.com/cufee/facepaint v0.1.0 h1:MKD5HIzuaBGDF84GWtkhi/XsQxCHv1sjCPPFMQuoWvM= +github.com/cufee/facepaint v0.1.0/go.mod h1:7zR5lQMN3EO3qNtff0J8nzIhDb258UoYbRzhRToLQdg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/constants/themes.go b/internal/constants/themes.go new file mode 100644 index 00000000..dbad8785 --- /dev/null +++ b/internal/constants/themes.go @@ -0,0 +1,5 @@ +package constants + +import "os" + +var HighlightedTheme = os.Getenv("HIGHLIGHTED_THEME") diff --git a/internal/database/models/user_content.go b/internal/database/models/user_content.go index 020e7828..1dd408e4 100644 --- a/internal/database/models/user_content.go +++ b/internal/database/models/user_content.go @@ -15,6 +15,7 @@ const ( UserContentTypeInModeration = UserContentType("in-moderation") UserContentTypeClanBackground = UserContentType("clan-background-image") UserContentTypePersonalBackground = UserContentType("personal-background-image") + UserContentTypeThemePreference = UserContentType("theme-preference") ) func (t UserContentType) Valid() bool { diff --git a/internal/render/common/background.go b/internal/render/common/background.go index 630cea1f..450430ea 100644 --- a/internal/render/common/background.go +++ b/internal/render/common/background.go @@ -5,8 +5,11 @@ import ( "image/color" "math" "math/rand" + "slices" "sync" + "time" + "github.com/cufee/aftermath/internal/stats/frame" "github.com/fogleman/gg" "github.com/nao1215/imaging" ) @@ -17,6 +20,39 @@ var GlassEffectBackgroundBlur float64 = DefaultBackgroundBlur * 5 var globalLogoCacheMx sync.Mutex var globalLogoCache = make(map[color.Color]image.Image) +type vehicleWN8 struct { + id string + wn8 frame.Value + sortKey int +} + +func AddWN8BackgroundBranding(background image.Image, vehicles map[string]frame.VehicleStatsFrame, patternSeed int) image.Image { + var values []vehicleWN8 + for _, vehicle := range vehicles { + if wn8 := vehicle.WN8(); !frame.InvalidValue.Equals(wn8) { + values = append(values, vehicleWN8{vehicle.VehicleID, wn8, int(vehicle.LastBattleTime.Unix())}) + } + } + slices.SortFunc(values, func(a, b vehicleWN8) int { return b.sortKey - a.sortKey }) + if len(values) >= 10 { + values = values[:9] + } + + var accentColors []color.Color + for _, value := range values { + c := GetWN8Colors(value.wn8.Float()).Background + if _, _, _, a := c.RGBA(); a > 0 { + accentColors = append(accentColors, c) + } + } + + if patternSeed == 0 { + patternSeed = int(time.Now().Unix()) + } + + return AddDefaultBrandedOverlay(background, accentColors, patternSeed, 0.5) +} + func AddDefaultBrandedOverlay(background image.Image, colors []color.Color, seed int, colorChance float32) image.Image { if len(colors) < 1 { colors = DefaultLogoColorOptions @@ -77,8 +113,7 @@ func NewBrandedBackground(width, height, logoSize, padding int, colors []color.C posX := float64(padding + c*(logoSize+xGap)) posY := float64(padding + r*(logoSize+yGap)) - source := rand.NewSource(int64(hashSeed) + int64(posX)*51 + int64(posY)*37) - rnd := rand.New(source) + rnd := rand.New(rand.NewSource(cellHash(hashSeed, c, r))) if n := rnd.Float32(); n < 0.5 { return @@ -97,7 +132,7 @@ func NewBrandedBackground(width, height, logoSize, padding int, colors []color.C logoAdjusted = imaging.Rotate(logoAdjusted, rotation, color.Transparent) logoAdjusted = imaging.Resize(logoAdjusted, int(float64(logoSize)*scale), int(float64(logoSize)*scale), imaging.Linear) - xJ, yJ := pickPositionJitter(rnd) + xJ, yJ := pickPositionJitter(rnd, xGap, yGap) posX += xJ posY += yJ @@ -115,7 +150,18 @@ func NewBrandedBackground(width, height, logoSize, padding int, colors []color.C return ctx.Image() } -// pickColor function that includes hashSeed in the hash calculation +// cellHash produces a well-distributed hash from a seed and grid coordinates +// using splitmix-style bit mixing to decorrelate neighboring cells. +func cellHash(seed, col, row int) int64 { + h := int64(seed) ^ (int64(col)*2654435761 + int64(row)*340573321) + h ^= h >> 16 + h *= 0x45d9f3b + h ^= h >> 16 + h *= 0x45d9f3b + h ^= h >> 16 + return h +} + func pickColor(colors []color.Color, r *rand.Rand) color.Color { if len(colors) < 1 { return color.White @@ -132,11 +178,10 @@ func pickScaleFactor(r *rand.Rand) float64 { return scaleFactor } -// pickPositionJitter function that generates an x,y position offset based on the hash seed -func pickPositionJitter(r *rand.Rand) (float64, float64) { - // Clamp between 0.5 and 1.5 - xJitter := -0.5 + r.Float64() - yJitter := -0.5 + r.Float64() +func pickPositionJitter(r *rand.Rand, xGap, yGap int) (float64, float64) { + const jitterFraction = 0.5 + xJitter := (r.Float64() - 0.5) * float64(xGap) * jitterFraction + yJitter := (r.Float64() - 0.5) * float64(yGap) * jitterFraction return xJitter, yJitter } diff --git a/internal/render/common/colors.go b/internal/render/common/colors.go index 7b381d1e..ca345329 100644 --- a/internal/render/common/colors.go +++ b/internal/render/common/colors.go @@ -16,7 +16,7 @@ var ( DefaultCardColor = color.NRGBA{0, 0, 0, 170} DefaultCardColorNoAlpha = color.NRGBA{10, 10, 10, 255} - ClanTagBackgroundColor = color.NRGBA{10, 10, 10, 100} + ClanTagBackgroundColor = color.NRGBA{40, 40, 40, 100} ColorAftermathRed = color.NRGBA{255, 0, 120, 255} ColorAftermathBlue = color.NRGBA{72, 167, 250, 255} diff --git a/internal/render/common/options.go b/internal/render/common/options.go index b2ed6e08..b22a6e72 100644 --- a/internal/render/common/options.go +++ b/internal/render/common/options.go @@ -14,10 +14,14 @@ type Options struct { Background image.Image BackgroundIsCustom bool Printer func(string) string + Theme Theme } func DefaultOptions() Options { - return Options{Printer: func(s string) string { return s }} + return Options{ + Printer: func(s string) string { return s }, + Theme: DefaultTheme(), + } } type Option func(*Options) @@ -63,3 +67,13 @@ func WithBackground(image image.Image, isCustom bool) Option { o.Background = image } } + +func WithTheme(theme Theme) Option { + return func(o *Options) { + o.Theme = theme + if theme.Background != nil { + o.Background = theme.Background + o.BackgroundIsCustom = true + } + } +} diff --git a/internal/render/common/shared-blocks.go b/internal/render/common/shared-blocks.go new file mode 100644 index 00000000..e18ba18e --- /dev/null +++ b/internal/render/common/shared-blocks.go @@ -0,0 +1,60 @@ +package common + +import ( + "time" + + "github.com/cufee/aftermath/internal/database/models" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +func NewPlayerNameBlock(account models.Account, theme Theme) *facepaint.Block { + var blocks []*facepaint.Block + + var clanTagBlock *facepaint.Block + if account.ClanTag != "" { + textStl := ClanTagTextStyle(theme) + cardStl := ClanTagCardStyle(theme) + clanTagBlock = facepaint.NewBlocksContent(cardStl.Options(), facepaint.MustNewTextContent(textStl.Options(), account.ClanTag)) + blocks = append(blocks, clanTagBlock) + } + + nameStl := PlayerNameTextStyle(theme) + blocks = append(blocks, facepaint.NewBlocksContent(PlayerNameCardLayout.Options(), + facepaint.MustNewTextContent(nameStl.Options(), account.Nickname), + )) + + if clanTagBlock != nil { + size := clanTagBlock.Dimensions() + stl := style.Style{ + Width: float64(size.Width), + Height: 1, + } + blocks = append(blocks, facepaint.NewEmptyContent(stl.Options())) + } + + wrapperStl := PlayerNameWrapperStyle(theme) + return facepaint.NewBlocksContent(wrapperStl.Options(), blocks...) +} + +func NewFooterBlock(periodStart, periodEnd time.Time, opts Options) *facepaint.Block { + stl := FooterPillStyle(opts.Theme) + var footer []*facepaint.Block + for _, text := range opts.FooterText { + footer = append(footer, facepaint.MustNewTextContent(stl.Options(), text)) + } + + sessionTo := periodEnd.Format("Jan 2, 2006") + sessionFromFormat := "Jan 2, 2006" + if periodStart.Year() == periodEnd.Year() { + sessionFromFormat = "Jan 2" + } + sessionFrom := periodStart.Format(sessionFromFormat) + if periodStart.IsZero() || sessionFrom == sessionTo { + footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionTo)) + } else { + footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionFrom+" - "+sessionTo)) + } + + return facepaint.NewBlocksContent(FooterWrapperLayout.Options(), footer...) +} diff --git a/internal/render/common/styles.go b/internal/render/common/styles.go new file mode 100644 index 00000000..eefb1c31 --- /dev/null +++ b/internal/render/common/styles.go @@ -0,0 +1,156 @@ +package common + +import "github.com/cufee/facepaint/style" + +var ( + CardPaddingX = 35.0 + CardPaddingY = 30.0 +) + +// HighlightCardStyle defines the styles for a highlight/vehicle card with title and stats. +type HighlightCardStyle struct { + Card style.Style + TitleWrapper style.Style + TitleLabel func() *style.Style + TitleVehicle func() *style.Style + StatsWrapper style.Style + Stats style.Style + BlockValue func() *style.Style + BlockLabel func() *style.Style +} + +func NewHighlightCardStyle(theme Theme) HighlightCardStyle { + return HighlightCardStyle{ + Card: ApplyTheme(style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + + GrowHorizontal: true, + Gap: 20, + + PaddingLeft: CardPaddingX / 1.5, + PaddingRight: CardPaddingX / 1.5, + PaddingTop: CardPaddingY / 1.5, + PaddingBottom: CardPaddingY / 1.5, + }, theme.Card), + TitleWrapper: style.Style{ + GrowHorizontal: true, + Direction: style.DirectionVertical, + }, + TitleLabel: func() *style.Style { + s := ApplyTheme(style.Style{Font: FontSmall()}, theme.TextSecondary()) + return &s + }, + TitleVehicle: func() *style.Style { + s := ApplyTheme(style.Style{Font: FontMedium()}, theme.TextPrimary()) + return &s + }, + Stats: style.Style{ + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + }, + StatsWrapper: style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + Gap: 10, + }, + BlockValue: func() *style.Style { + s := ApplyTheme(style.Style{Font: FontMedium()}, theme.TextPrimary()) + return &s + }, + BlockLabel: func() *style.Style { + s := ApplyTheme(style.Style{Font: FontSmall()}, theme.TextAlt()) + return &s + }, + } +} + +// PlayerNameWrapperStyle returns the style for the player name header card, themed. +func PlayerNameWrapperStyle(theme Theme) style.Style { + return ApplyTheme(style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + + PaddingLeft: 5, + PaddingRight: 5, + PaddingTop: 5, + PaddingBottom: 5, + + Height: 50, + + GrowHorizontal: true, + Gap: 20, + }, theme.Card) +} + +// ClanTagCardStyle returns the style for the clan tag pill, themed. +func ClanTagCardStyle(theme Theme) style.Style { + return ApplyTheme(style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + + GrowVertical: true, + + PaddingLeft: 12, + PaddingRight: 12, + PaddingTop: 10, + PaddingBottom: 10, + }, theme.ClanTag) +} + +// FooterPillStyle returns the style for a footer text pill, themed. +func FooterPillStyle(theme Theme) style.Style { + return ApplyTheme(style.Style{ + PaddingLeft: 10, + PaddingRight: 10, + PaddingTop: 5, + PaddingBottom: 5, + }, theme.Footer) +} + +// PlayerNameTextStyle returns the text style for the player name. +func PlayerNameTextStyle(theme Theme) style.Style { + return ApplyTheme(style.Style{Font: FontMedium()}, theme.TextPrimary()) +} + +// ClanTagTextStyle returns the text style for the clan tag. +func ClanTagTextStyle(theme Theme) style.Style { + return ApplyTheme(style.Style{Font: FontSmall()}, theme.TextSecondary()) +} + +// FinalFrameStyle returns the style for the outermost frame, themed. +// A theme can add padding, background color, or border radius around the entire image. +func FinalFrameStyle(theme Theme) style.Style { + return ApplyTheme(style.Style{ + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + Gap: 5, + }, theme.Frame) +} + +// Pure layout styles that have no theme dependency. +var ( + PlayerNameCardLayout = style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + + GrowHorizontal: true, + } + + CardsBackgroundStyle = style.NewStyle( + style.SetBorderRadius(BorderRadius2XL), + style.SetBlur(DefaultBackgroundBlur), + style.SetPosition(style.PositionAbsolute), + style.SetZIndex(-99), + ) + + + FooterWrapperLayout = style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + Gap: 5, + } +) diff --git a/internal/render/common/theme.go b/internal/render/common/theme.go new file mode 100644 index 00000000..73eea5a4 --- /dev/null +++ b/internal/render/common/theme.go @@ -0,0 +1,82 @@ +package common + +import ( + "image" + + "github.com/cufee/facepaint/style" +) + +type Theme struct { + // Frame appearance (outermost container wrapping background + cards + footer) + Frame style.StyleOptions + // Card appearance (BackgroundColor, BlurBackground, BorderRadius) + Card style.StyleOptions + // Clan tag pill appearance + ClanTag style.StyleOptions + // Footer pill appearance + Footer style.StyleOptions + + // Text styles (Color + Font per tier) + TextPrimary func() style.StyleOptions + TextSecondary func() style.StyleOptions + TextAlt func() style.StyleOptions + + // Optional background image bundled with the theme. + // Used as the default background when no explicit background is provided. + Background image.Image + + // BackgroundOverlay is rendered behind cards, on top of the background image. + // seed is derived from the account ID for deterministic patterns. + BackgroundOverlay func(bounds image.Rectangle, seed int) image.Image + // ForegroundOverlay is composited on the final rendered image, on top of everything. + // seed is derived from the account ID for deterministic patterns. + ForegroundOverlay func(rendered image.Image, frame image.Rectangle, seed int) image.Image +} + +func DefaultTheme() Theme { + return Theme{ + Frame: style.NewStyle(), + Card: style.NewStyle( + style.SetBorderRadius(BorderRadiusLG), + func(s *style.Style) { + s.BackgroundColor = DefaultCardColor + s.BlurBackground = 20.0 + }, + ), + ClanTag: style.NewStyle( + style.SetBorderRadius(BorderRadiusMD), + func(s *style.Style) { + s.BackgroundColor = ClanTagBackgroundColor + }, + ), + Footer: style.NewStyle( + style.SetBorderRadius(BorderRadiusSM), + func(s *style.Style) { + s.BackgroundColor = DefaultCardColor + s.Color = TextAlt + s.Font = FontSmall() + }, + ), + TextPrimary: func() style.StyleOptions { + return style.NewStyle(func(s *style.Style) { + s.Color = TextPrimary + }) + }, + TextSecondary: func() style.StyleOptions { + return style.NewStyle(func(s *style.Style) { + s.Color = TextSecondary + }) + }, + TextAlt: func() style.StyleOptions { + return style.NewStyle(func(s *style.Style) { + s.Color = TextAlt + }) + }, + } +} + +// ApplyTheme merges a layout style with theme appearance options. +// The layout is used as the base, then theme options override appearance fields on top. +func ApplyTheme(layout style.Style, appearance style.StyleOptions) style.Style { + return style.NewStyle(style.Parent(layout)).Chain(appearance.Spread()...).Computed() +} diff --git a/internal/render/v1/background.go b/internal/render/v1/background.go index 156a5b0e..e055da4d 100644 --- a/internal/render/v1/background.go +++ b/internal/render/v1/background.go @@ -77,8 +77,7 @@ func NewBrandedBackground(width, height, logoSize, padding int, colors []color.C posX := float64(padding + c*(logoSize+xGap)) posY := float64(padding + r*(logoSize+yGap)) - source := rand.NewSource(int64(hashSeed) + int64(posX)*51 + int64(posY)*37) - rnd := rand.New(source) + rnd := rand.New(rand.NewSource(cellHash(hashSeed, c, r))) if n := rnd.Float32(); n < 0.5 { return @@ -97,7 +96,7 @@ func NewBrandedBackground(width, height, logoSize, padding int, colors []color.C logoAdjusted = imaging.Rotate(logoAdjusted, rotation, color.Transparent) logoAdjusted = imaging.Resize(logoAdjusted, int(float64(logoSize)*scale), int(float64(logoSize)*scale), imaging.Linear) - xJ, yJ := pickPositionJitter(rnd) + xJ, yJ := pickPositionJitter(rnd, xGap, yGap) posX += xJ posY += yJ @@ -115,7 +114,18 @@ func NewBrandedBackground(width, height, logoSize, padding int, colors []color.C return ctx.Image() } -// pickColor function that includes hashSeed in the hash calculation +// cellHash produces a well-distributed hash from a seed and grid coordinates +// using splitmix-style bit mixing to decorrelate neighboring cells. +func cellHash(seed, col, row int) int64 { + h := int64(seed) ^ (int64(col)*2654435761 + int64(row)*340573321) + h ^= h >> 16 + h *= 0x45d9f3b + h ^= h >> 16 + h *= 0x45d9f3b + h ^= h >> 16 + return h +} + func pickColor(colors []color.Color, r *rand.Rand) color.Color { if len(colors) < 1 { return color.White @@ -132,11 +142,10 @@ func pickScaleFactor(r *rand.Rand) float64 { return scaleFactor } -// pickPositionJitter function that generates an x,y position offset based on the hash seed -func pickPositionJitter(r *rand.Rand) (float64, float64) { - // Clamp between 0.5 and 1.5 - xJitter := -0.5 + r.Float64() - yJitter := -0.5 + r.Float64() +func pickPositionJitter(r *rand.Rand, xGap, yGap int) (float64, float64) { + const jitterFraction = 0.5 + xJitter := (r.Float64() - 0.5) * float64(xGap) * jitterFraction + yJitter := (r.Float64() - 0.5) * float64(yGap) * jitterFraction return xJitter, yJitter } diff --git a/internal/stats/client/common/options.go b/internal/stats/client/common/options.go index 1f2ac7ef..a46d8c8b 100644 --- a/internal/stats/client/common/options.go +++ b/internal/stats/client/common/options.go @@ -21,6 +21,7 @@ type requestOptions struct { withWN8 bool VehicleIDs []string Subscriptions []models.UserSubscription + theme *common.Theme vehicleTags []prepare.Tag ratingColumns []prepare.TagColumn[string] @@ -69,6 +70,9 @@ func WithBackgroundURL(url string, isCustom bool) RequestOption { func WithBackground(image image.Image, isCustom bool) RequestOption { return func(o *requestOptions) { o.backgroundImage = image; o.backgroundIsCustom = isCustom } } +func WithTheme(theme common.Theme) RequestOption { + return func(o *requestOptions) { o.theme = &theme } +} func (o requestOptions) RenderOpts(printer func(string) string) []common.Option { var copts []common.Option @@ -88,15 +92,21 @@ func (o requestOptions) RenderOpts(printer func(string) string) []common.Option copts = append(copts, common.WithBackground(o.backgroundImage, o.backgroundIsCustom)) } else if o.backgroundURL != "" { copts = append(copts, common.WithBackgroundURL(o.backgroundURL, o.backgroundIsCustom)) - } else { + } else if o.theme == nil { copts = append(copts, common.WithBackgroundURL("static://bg-default", false)) } + if o.theme != nil { + copts = append(copts, common.WithTheme(*o.theme)) + } return copts } func (o requestOptions) PrepareOpts(printer func(string) string, locale language.Tag) []prepare.Option { var popts []prepare.Option popts = append(popts, prepare.WithPrinter(printer, locale)) + if o.VehicleIDs != nil { + popts = append(popts, prepare.WithVehicleIDs(o.VehicleIDs...)) + } if o.vehicleTags != nil { popts = append(popts, prepare.WithVehicleTags(o.vehicleTags...)) } diff --git a/internal/stats/prepare/common/v1/options.go b/internal/stats/prepare/common/v1/options.go index 0dbdcffa..19490b3d 100644 --- a/internal/stats/prepare/common/v1/options.go +++ b/internal/stats/prepare/common/v1/options.go @@ -12,6 +12,7 @@ type options struct { localePrinter func(string) string locale *language.Tag + VehicleIDs []string VehicleTags []Tag RatingColumns []TagColumn[string] UnratedColumns []TagColumn[string] @@ -45,3 +46,6 @@ func WithRatingColumns(columns ...TagColumn[string]) func(*options) { func WithUnratedColumns(columns ...TagColumn[string]) func(*options) { return func(o *options) { o.UnratedColumns = columns } } +func WithVehicleIDs(ids ...string) func(*options) { + return func(o *options) { o.VehicleIDs = ids } +} diff --git a/internal/stats/prepare/session/v1/card.go b/internal/stats/prepare/session/v1/card.go index 276486b2..ba782d4a 100644 --- a/internal/stats/prepare/session/v1/card.go +++ b/internal/stats/prepare/session/v1/card.go @@ -86,7 +86,9 @@ func NewCards(session, career fetch.AccountStatsOverPeriod, glossary map[string] cards.Unrated.Overview = card } - { // Regular battles vehicles + // Regular battles vehicles + // single vehicle selected means overview will match vehicle stats + if len(options.VehicleIDs) != 1 { var unratedVehicles []frame.VehicleStatsFrame for _, vehicle := range session.RegularBattles.Vehicles { unratedVehicles = append(unratedVehicles, vehicle) diff --git a/internal/stats/render/period/v2/background.go b/internal/stats/render/period/v2/background.go deleted file mode 100644 index 3038fd85..00000000 --- a/internal/stats/render/period/v2/background.go +++ /dev/null @@ -1,38 +0,0 @@ -package period - -import ( - "image" - "image/color" - "slices" - "time" - - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/aftermath/internal/stats/frame" -) - -func addBackgroundBranding(background image.Image, vehicles map[string]frame.VehicleStatsFrame, patternSeed int) image.Image { - var values []vehicleWN8 - for _, vehicle := range vehicles { - if wn8 := vehicle.WN8(); !frame.InvalidValue.Equals(wn8) { - values = append(values, vehicleWN8{vehicle.VehicleID, wn8, int(vehicle.LastBattleTime.Unix())}) - } - } - slices.SortFunc(values, func(a, b vehicleWN8) int { return b.sortKey - a.sortKey }) - if len(values) >= 10 { - values = values[:9] - } - - var accentColors []color.Color - for _, value := range values { - c := common.GetWN8Colors(value.wn8.Float()).Background - if _, _, _, a := c.RGBA(); a > 0 { - accentColors = append(accentColors, c) - } - } - - if patternSeed == 0 { - patternSeed = int(time.Now().Unix()) - } - - return common.AddDefaultBrandedOverlay(background, accentColors, patternSeed, 0.5) -} diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go index f78daf12..57cccbf9 100644 --- a/internal/stats/render/period/v2/cards.go +++ b/internal/stats/render/period/v2/cards.go @@ -4,16 +4,15 @@ import ( "errors" "strconv" - prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" - "github.com/cufee/facepaint/style" - "github.com/nao1215/imaging" - "github.com/cufee/aftermath/internal/database/models" "github.com/cufee/aftermath/internal/log" "github.com/cufee/aftermath/internal/render/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "github.com/cufee/aftermath/internal/stats/prepare/period/v1" "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" + "github.com/nao1215/imaging" ) func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []models.UserSubscription, opts common.Options) (*facepaint.Block, error) { @@ -22,8 +21,12 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []m return nil, errors.New("no cards provided") } + theme := opts.Theme + hlStyle := common.NewHighlightCardStyle(theme) + styledUnratedOverviewCard := newUnratedOverviewCardStyle(theme) + styledRatingOverviewCard := newRatingOverviewCardStyle(theme) + var ( - // renderUnratedVehiclesCount = 3 // minimum number of vehicle cards shouldRenderUnratedOverview = stats.RegularBattles.Battles > 0 || stats.RatingBattles.Battles < 1 shouldRenderRatingOverview = cards.Rating.Meta && stats.RatingBattles.Battles > 0 && opts.VehicleIDs == nil highlightCardsCount = 3 @@ -35,19 +38,22 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []m highlightCardsCount = 0 } - // calculate max overview block width to make all blocks the same size var maxWidthOverviewBlock = make(map[string]float64) if shouldRenderUnratedOverview { for _, column := range cards.Overview.Blocks { for _, block := range column.Blocks { + key := string(block.Data.Flavor) + blockStyle := styledUnratedOverviewCard.styleBlock(block) switch block.Tag { case prepare.TagWN8: block.Label = common.GetWN8TierName(block.Value().Float()) - maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], iconSizeWN8) + maxWidthOverviewBlock[key] = max(maxWidthOverviewBlock[key], iconSizeWN8) } - maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Label, styledUnratedOverviewCard.styleBlock(block).label.Font).TotalWidth) - maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Value().String(), styledUnratedOverviewCard.styleBlock(block).value.Font).TotalWidth) + maxWidthOverviewBlock[key] = max(maxWidthOverviewBlock[key], + facepaint.MeasureStringWidth(block.Label, blockStyle.label.Font), + facepaint.MeasureStringWidth(block.Value().String(), blockStyle.value.Font), + ) } } } @@ -55,18 +61,21 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []m if shouldRenderRatingOverview { for _, column := range cards.Rating.Blocks { for _, block := range column.Blocks { + key := string(block.Data.Flavor) + blockStyle := styledRatingOverviewCard.styleBlock(block) switch block.Tag { case prepare.TagRankedRating: block.Label = common.GetRatingTierName(block.Value().Float()) - maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], iconSizeRating) + maxWidthOverviewBlock[key] = max(maxWidthOverviewBlock[key], iconSizeRating) } - maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Label, styledRatingOverviewCard.styleBlock(block).label.Font).TotalWidth) - maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Value().String(), styledRatingOverviewCard.styleBlock(block).value.Font).TotalWidth) + maxWidthOverviewBlock[key] = max(maxWidthOverviewBlock[key], + facepaint.MeasureStringWidth(block.Label, blockStyle.label.Font), + facepaint.MeasureStringWidth(block.Value().String(), blockStyle.value.Font), + ) } } } - // calculate per block type width of highlight stats to make things even var highlightBlockWidth = make(map[prepare.Tag]float64) for i, highlight := range cards.Highlights { if i >= highlightCardsCount { @@ -74,62 +83,66 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []m } for _, block := range highlight.Blocks { - label := facepaint.MeasureString(block.Label, styledHighlightCard.blockLabel().Font).TotalWidth - value := facepaint.MeasureString(block.Value().String(), styledHighlightCard.blockValue().Font).TotalWidth - highlightBlockWidth[block.Tag] = max(highlightBlockWidth[block.Tag], label, value) + highlightBlockWidth[block.Tag] = max(highlightBlockWidth[block.Tag], + facepaint.MeasureStringWidth(block.Label, hlStyle.BlockLabel().Font), + facepaint.MeasureStringWidth(block.Value().String(), hlStyle.BlockValue().Font), + ) } } var statsCards []*facepaint.Block - // player name card - statsCards = append(statsCards, newPlayerNameCard(stats.Account)) + statsCards = append(statsCards, common.NewPlayerNameBlock(stats.Account, theme)) if shouldRenderUnratedOverview { - if card := newUnratedOverviewCard(cards.Overview, maxWidthOverviewBlock); card != nil { + if card := newUnratedOverviewCard(styledUnratedOverviewCard, cards.Overview, maxWidthOverviewBlock); card != nil { statsCards = append(statsCards, card) } } - // rating battles if shouldRenderRatingOverview { - if card := newRatingOverviewCard(cards.Rating, maxWidthOverviewBlock); card != nil { + if card := newRatingOverviewCard(styledRatingOverviewCard, cards.Rating, maxWidthOverviewBlock); card != nil { statsCards = append(statsCards, card) } } - // highlights for i, card := range cards.Highlights { if i >= highlightCardsCount { break } - statsCards = append(statsCards, newHighlightCard(card, highlightBlockWidth)) + statsCards = append(statsCards, newHighlightCard(hlStyle, card, highlightBlockWidth)) } if len(statsCards) == 0 { return nil, errors.New("no cards to render") } - footer := newFooterCard(stats, cards, opts) + footer := common.NewFooterBlock(stats.PeriodStart, stats.PeriodEnd, opts) cardsFrame := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsFrame)), statsCards...) - // resize and place background if opts.Background != nil { - cardsFrameSize := cardsFrame.Dimensions() - opts.Background = imaging.Fill(opts.Background, cardsFrameSize.Width, cardsFrameSize.Height, imaging.Center, imaging.Lanczos) + bgDims := cardsFrame.Dimensions() + seed, _ := strconv.Atoi(stats.Account.ID) + opts.Background = imaging.Fill(opts.Background, bgDims.Width, bgDims.Height, imaging.Center, imaging.Lanczos) if !opts.BackgroundIsCustom { - seed, _ := strconv.Atoi(stats.Account.ID) - opts.Background = addBackgroundBranding(opts.Background, stats.RegularBattles.Vehicles, seed) + opts.Background = common.AddWN8BackgroundBranding(opts.Background, stats.RegularBattles.Vehicles, seed) + } + + var layers []*facepaint.Block + layers = append(layers, facepaint.MustNewImageContent(common.CardsBackgroundStyle, opts.Background)) + if theme.BackgroundOverlay != nil { + if overlay := theme.BackgroundOverlay(opts.Background.Bounds(), seed); overlay != nil { + layers = append(layers, facepaint.MustNewImageContent(common.CardsBackgroundStyle, overlay)) + } } - cardsFrame = facepaint.NewBlocksContent(style.NewStyle(), - facepaint.MustNewImageContent(styledCardsBackground, opts.Background), cardsFrame, - ) + layers = append(layers, cardsFrame) + cardsFrame = facepaint.NewBlocksContent(style.NewStyle(), layers...) } var frameCards []*facepaint.Block frameCards = append(frameCards, cardsFrame) frameCards = append(frameCards, footer) - return facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledFinalFrame)), frameCards...), nil - + frameStyle := common.FinalFrameStyle(theme) + return facepaint.NewBlocksContent(style.NewStyle(style.Parent(frameStyle)), frameCards...), nil } diff --git a/internal/stats/render/period/v2/highlight-style.go b/internal/stats/render/period/v2/highlight-style.go deleted file mode 100644 index cc35e4b4..00000000 --- a/internal/stats/render/period/v2/highlight-style.go +++ /dev/null @@ -1,85 +0,0 @@ -package period - -import ( - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/facepaint/style" -) - -var styledHighlightTitle = style.Style{} -var styledHighlightStatsRow = style.Style{} - -type highlightCardStyle struct { - card style.Style - titleWrapper style.Style - titleLabel func() *style.Style - titleVehicle func() *style.Style - statsWrapper style.Style - stats style.Style - blockValue func() *style.Style - blockLabel func() *style.Style -} - -var styledHighlightCard = highlightCardStyle{ - card: style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, - - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, - - GrowHorizontal: true, - Gap: 20, - - PaddingLeft: cardPaddingX / 1.5, - PaddingRight: cardPaddingX / 1.5, - PaddingTop: cardPaddingY / 1.5, - PaddingBottom: cardPaddingY / 1.5, - }, - titleWrapper: style.Style{ - // Debug: true, - - GrowHorizontal: true, - Direction: style.DirectionVertical, - }, - titleLabel: func() *style.Style { - return &style.Style{ - Color: common.TextSecondary, - Font: common.FontSmall(), - } - }, - titleVehicle: func() *style.Style { - return &style.Style{ - Color: common.TextPrimary, - Font: common.FontMedium(), - } - }, - stats: style.Style{ - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - }, - statsWrapper: style.Style{ - // Debug: true, - - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - Gap: 10, - }, - blockValue: func() *style.Style { - return &style.Style{ - Color: common.TextPrimary, - Font: common.FontMedium(), - } - }, - blockLabel: func() *style.Style { - return &style.Style{ - Color: common.TextAlt, - Font: common.FontSmall(), - } - }, -} diff --git a/internal/stats/render/period/v2/highlight.go b/internal/stats/render/period/v2/highlight.go index a92d1ca2..e2781d7c 100644 --- a/internal/stats/render/period/v2/highlight.go +++ b/internal/stats/render/period/v2/highlight.go @@ -1,29 +1,29 @@ package period import ( + "github.com/cufee/aftermath/internal/render/common" prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "github.com/cufee/aftermath/internal/stats/prepare/period/v1" "github.com/cufee/facepaint" "github.com/cufee/facepaint/style" ) -func newHighlightCard(data period.VehicleCard, blockSizes map[prepare.Tag]float64) *facepaint.Block { - leftSide := facepaint.NewBlocksContent(styledHighlightCard.titleWrapper.Options(), - facepaint.MustNewTextContent(styledHighlightCard.titleLabel().Options(), data.Meta), - facepaint.MustNewTextContent(styledHighlightCard.titleVehicle().Options(), data.Title), +func newHighlightCard(hlStyle common.HighlightCardStyle, data period.VehicleCard, blockSizes map[prepare.Tag]float64) *facepaint.Block { + leftSide := facepaint.NewBlocksContent(hlStyle.TitleWrapper.Options(), + facepaint.MustNewTextContent(hlStyle.TitleLabel().Options(), data.Meta), + facepaint.MustNewTextContent(hlStyle.TitleVehicle().Options(), data.Title), ) var rightSide []*facepaint.Block for _, block := range data.Blocks { - rightSide = append(rightSide, facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledHighlightCard.stats), style.SetWidth(blockSizes[block.Tag])), - facepaint.MustNewTextContent(styledHighlightCard.blockValue().Options(), block.V.String()), - facepaint.MustNewTextContent(styledHighlightCard.blockLabel().Options(), block.Label), + rightSide = append(rightSide, facepaint.NewBlocksContent(style.NewStyle(style.Parent(hlStyle.Stats), style.SetWidth(blockSizes[block.Tag])), + facepaint.MustNewTextContent(hlStyle.BlockValue().Options(), block.V.String()), + facepaint.MustNewTextContent(hlStyle.BlockLabel().Options(), block.Label), )) } - return facepaint.NewBlocksContent(styledHighlightCard.card.Options(), + return facepaint.NewBlocksContent(hlStyle.Card.Options(), leftSide, - facepaint.NewBlocksContent(styledHighlightCard.statsWrapper.Options(), rightSide...), + facepaint.NewBlocksContent(hlStyle.StatsWrapper.Options(), rightSide...), ) - } diff --git a/internal/stats/render/period/v2/image.go b/internal/stats/render/period/v2/image.go index 0ee41a61..159bc17a 100644 --- a/internal/stats/render/period/v2/image.go +++ b/internal/stats/render/period/v2/image.go @@ -2,33 +2,34 @@ package period import ( "image" + "strconv" "github.com/cufee/aftermath/internal/database/models" "github.com/cufee/aftermath/internal/render/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" - "github.com/cufee/aftermath/internal/stats/frame" "github.com/cufee/aftermath/internal/stats/prepare/period/v1" ) -type vehicleWN8 struct { - id string - wn8 frame.Value - sortKey int -} - func CardsToImage(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts ...common.Option) (image.Image, error) { o := common.DefaultOptions() for _, apply := range opts { apply(&o) } - // Generate cards - cardsBlock, err := generateCards(stats, cards, subs, o) + block, err := generateCards(stats, cards, subs, o) if err != nil { return nil, err } - // Render - return cardsBlock.Render() + rendered, err := block.Render() + if err != nil { + return nil, err + } + + if o.Theme.ForegroundOverlay != nil { + seed, _ := strconv.Atoi(stats.Account.ID) + rendered = o.Theme.ForegroundOverlay(rendered, rendered.Bounds(), seed) + } + return rendered, nil } diff --git a/internal/stats/render/period/v2/misc-style.go b/internal/stats/render/period/v2/misc-style.go index ceb938c7..f134f1c9 100644 --- a/internal/stats/render/period/v2/misc-style.go +++ b/internal/stats/render/period/v2/misc-style.go @@ -1,88 +1,8 @@ package period -import ( - "image/color" - - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/facepaint/style" -) - -var ( - clanTagBackgroundColor = color.NRGBA{40, 40, 40, 100} - - cardBackgroundBlur = 20.0 - cardPaddingX = 35.0 - cardPaddingY = 30.0 -) - -func styledPlayerName() style.Style { - return style.Style{ - Color: common.TextPrimary, - Font: common.FontMedium(), - } -} - -func styledPlayerClanTag() style.Style { - return style.Style{ - Color: common.TextSecondary, - Font: common.FontSmall(), - } -} - -var styledPlayerNameWrapper = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, - - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, - - PaddingLeft: 5, - PaddingRight: 5, - PaddingTop: 5, - PaddingBottom: 5, - - Height: 50, - - GrowHorizontal: true, - Gap: 20, -} - -var styledPlayerNameCard = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, - - GrowHorizontal: true, -} - -var styledPlayerClanTagCard = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, - - BackgroundColor: clanTagBackgroundColor, - - BorderRadiusTopLeft: common.BorderRadiusMD, - BorderRadiusTopRight: common.BorderRadiusMD, - BorderRadiusBottomLeft: common.BorderRadiusMD, - BorderRadiusBottomRight: common.BorderRadiusMD, - - GrowVertical: true, - - PaddingLeft: 12, - PaddingRight: 12, - PaddingTop: 10, - PaddingBottom: 10, -} +import "github.com/cufee/facepaint/style" var styledCardsFrame = style.Style{ - Debug: false, - Direction: style.DirectionVertical, AlignItems: style.AlignItemsCenter, Gap: 10, @@ -94,43 +14,3 @@ var styledCardsFrame = style.Style{ PaddingTop: 30, PaddingBottom: 30, } - -var styledFinalFrame = style.Style{ - Debug: false, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - Gap: 5, -} - -var styledCardsBackground = style.NewStyle( - style.SetBorderRadius(common.BorderRadius2XL), - style.SetBlur(common.DefaultBackgroundBlur), - style.SetPosition(style.PositionAbsolute), - style.SetZIndex(-99), -) - -var styledFooterWrapper = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - Gap: 5, -} - -func styledFooterCard() style.Style { - return style.Style{ - Font: common.FontSmall(), - Color: common.TextAlt, - - BackgroundColor: common.DefaultCardColor, - - BorderRadiusTopLeft: common.BorderRadiusSM, - BorderRadiusTopRight: common.BorderRadiusSM, - BorderRadiusBottomLeft: common.BorderRadiusSM, - BorderRadiusBottomRight: common.BorderRadiusSM, - - PaddingLeft: 10, - PaddingRight: 10, - PaddingTop: 5, - PaddingBottom: 5, - } -} diff --git a/internal/stats/render/period/v2/misc.go b/internal/stats/render/period/v2/misc.go deleted file mode 100644 index da68b2c2..00000000 --- a/internal/stats/render/period/v2/misc.go +++ /dev/null @@ -1,62 +0,0 @@ -package period - -import ( - "github.com/cufee/aftermath/internal/database/models" - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/aftermath/internal/stats/fetch/v1" - "github.com/cufee/aftermath/internal/stats/prepare/period/v1" - "github.com/cufee/facepaint" - "github.com/cufee/facepaint/style" -) - -func newPlayerNameCard(account models.Account) *facepaint.Block { - var blocks []*facepaint.Block - - // clan tag - var clanTagBlock *facepaint.Block - if account.ClanTag != "" { - stl := styledPlayerClanTag() - clanTagBlock = facepaint.NewBlocksContent(styledPlayerClanTagCard.Options(), facepaint.MustNewTextContent(stl.Options(), account.ClanTag)) - blocks = append(blocks, clanTagBlock) - } - - // nickname - stl := styledPlayerName() - blocks = append(blocks, facepaint.NewBlocksContent(styledPlayerNameCard.Options(), - facepaint.MustNewTextContent(stl.Options(), account.Nickname), - )) - - // spacer - if clanTagBlock != nil { - size := clanTagBlock.Dimensions() - stl := style.Style{ - Width: float64(size.Width), - Height: 1, - } - blocks = append(blocks, facepaint.NewEmptyContent(stl.Options())) - } - - return facepaint.NewBlocksContent(styledPlayerNameWrapper.Options(), blocks...) -} - -func newFooterCard(stats fetch.AccountStatsOverPeriod, cards period.Cards, opts common.Options) *facepaint.Block { - stl := styledFooterCard() - var footer []*facepaint.Block - for _, text := range opts.FooterText { - footer = append(footer, facepaint.MustNewTextContent(stl.Options(), text)) - } - - sessionTo := stats.PeriodEnd.Format("Jan 2, 2006") - sessionFromFormat := "Jan 2, 2006" - if stats.PeriodStart.Year() == stats.PeriodEnd.Year() { - sessionFromFormat = "Jan 2" - } - sessionFrom := stats.PeriodStart.Format(sessionFromFormat) - if stats.PeriodStart.IsZero() || sessionFrom == sessionTo { - footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionTo)) - } else { - footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionFrom+" - "+sessionTo)) - } - - return facepaint.NewBlocksContent(styledFooterWrapper.Options(), footer...) -} diff --git a/internal/stats/render/period/v2/overview-style.go b/internal/stats/render/period/v2/overview-style.go index cb62b58d..9a3f0727 100644 --- a/internal/stats/render/period/v2/overview-style.go +++ b/internal/stats/render/period/v2/overview-style.go @@ -26,56 +26,51 @@ type overviewCardStyle struct { styleBlock func(block prepare.StatsBlock[period.BlockData, string]) blockStyle } -// rating - -var styledRatingOverviewCard = overviewCardStyle{ - styleBlock: styleRatingOverviewBlock, - card: styledUnratedOverviewCard.card, +func newRatingOverviewCardStyle(theme common.Theme) overviewCardStyle { + return overviewCardStyle{ + styleBlock: newStyleRatingOverviewBlock(theme), + card: newUnratedOverviewCardStyle(theme).card, + } } -func styleRatingOverviewBlock(block prepare.StatsBlock[period.BlockData, string]) blockStyle { - stl := styleUnratedOverviewBlock(block) - if block.Data.Flavor != period.BlockFlavorSpecial { - return stl - } - stl.wrapper = style.Style{ - Debug: debugOverviewCards, +func newStyleRatingOverviewBlock(theme common.Theme) func(prepare.StatsBlock[period.BlockData, string]) blockStyle { + unrated := newStyleUnratedOverviewBlock(theme) + return func(block prepare.StatsBlock[period.BlockData, string]) blockStyle { + stl := unrated(block) + if block.Data.Flavor != period.BlockFlavorSpecial { + return stl + } + stl.wrapper = style.Style{ + Debug: debugOverviewCards, - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - Gap: 10, + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + Gap: 10, + } + return stl } - return stl } -// unrated - -var styledUnratedOverviewCard = overviewCardStyle{ - styleBlock: styleUnratedOverviewBlock, - card: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceBetween, - - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, +func newUnratedOverviewCardStyle(theme common.Theme) overviewCardStyle { + return overviewCardStyle{ + styleBlock: newStyleUnratedOverviewBlock(theme), + card: common.ApplyTheme(style.Style{ + Debug: debugOverviewCards, - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceBetween, - GrowHorizontal: true, - Gap: 10, + GrowHorizontal: true, + Gap: 10, - PaddingLeft: cardPaddingX, - PaddingRight: cardPaddingX, - PaddingTop: cardPaddingY / 2, - PaddingBottom: cardPaddingY / 2, - }, + PaddingLeft: common.CardPaddingX, + PaddingRight: common.CardPaddingX, + PaddingTop: common.CardPaddingY / 2, + PaddingBottom: common.CardPaddingY / 2, + }, theme.Card), + } } func (overviewCardStyle) column(column period.OverviewColumn) style.Style { @@ -93,8 +88,8 @@ func (overviewCardStyle) column(column period.OverviewColumn) style.Style { PaddingLeft: 10, PaddingRight: 10, - PaddingTop: cardPaddingY / 2, - PaddingBottom: cardPaddingY / 2, + PaddingTop: common.CardPaddingY / 2, + PaddingBottom: common.CardPaddingY / 2, } default: return style.Style{ @@ -106,95 +101,86 @@ func (overviewCardStyle) column(column period.OverviewColumn) style.Style { GrowVertical: false, Gap: 10, - PaddingTop: cardPaddingY / 2, - PaddingBottom: cardPaddingY / 2, + PaddingTop: common.CardPaddingY / 2, + PaddingBottom: common.CardPaddingY / 2, } } } -func styleUnratedOverviewBlock(block prepare.StatsBlock[period.BlockData, string]) blockStyle { - switch block.Data.Flavor { - case period.BlockFlavorSpecial: - return blockStyle{ - wrapper: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, - GrowVertical: true, - Gap: 5, - }, - valueContainer: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentEnd, - // GrowVertical: true, - Gap: 5, - }, - value: style.Style{ - Debug: debugOverviewCards, - - PaddingTop: -6, - Color: common.TextPrimary, - Font: common.FontXL(), - }, - label: style.Style{ - Color: common.TextAlt, - Font: common.FontSmall(), - PaddingTop: -6, - }, - } - case period.BlockFlavorSecondary: - return blockStyle{ - wrapper: style.Style{}, - valueContainer: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - }, - value: style.Style{ - Debug: debugOverviewCards, - - Color: common.TextSecondary, - Font: common.FontMedium(), - }, - label: style.Style{ - Color: common.TextAlt, - Font: common.FontSmall(), - PaddingTop: -3, - }, - } - default: - return blockStyle{ - wrapper: style.Style{}, - valueContainer: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - }, - value: style.Style{ - Debug: debugOverviewCards, - - Color: common.TextPrimary, - Font: common.FontLarge(), - }, - label: style.Style{ - Color: common.TextAlt, - Font: common.FontSmall(), - PaddingTop: -5, - }, +func newStyleUnratedOverviewBlock(theme common.Theme) func(prepare.StatsBlock[period.BlockData, string]) blockStyle { + return func(block prepare.StatsBlock[period.BlockData, string]) blockStyle { + switch block.Data.Flavor { + case period.BlockFlavorSpecial: + return blockStyle{ + wrapper: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + GrowVertical: true, + Gap: 5, + }, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentEnd, + Gap: 5, + }, + value: common.ApplyTheme(style.Style{ + Debug: debugOverviewCards, + PaddingTop: -6, + Font: common.FontXL(), + }, theme.TextPrimary()), + label: common.ApplyTheme(style.Style{ + Font: common.FontSmall(), + PaddingTop: -6, + }, theme.TextAlt()), + } + case period.BlockFlavorSecondary: + return blockStyle{ + wrapper: style.Style{}, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + }, + value: common.ApplyTheme(style.Style{ + Debug: debugOverviewCards, + Font: common.FontMedium(), + }, theme.TextSecondary()), + label: common.ApplyTheme(style.Style{ + Font: common.FontSmall(), + PaddingTop: -3, + }, theme.TextAlt()), + } + default: + return blockStyle{ + wrapper: style.Style{}, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + }, + value: common.ApplyTheme(style.Style{ + Debug: debugOverviewCards, + Font: common.FontLarge(), + }, theme.TextPrimary()), + label: common.ApplyTheme(style.Style{ + Font: common.FontSmall(), + PaddingTop: -5, + }, theme.TextAlt()), + } } } } -// wrapped around special block text and icon var styledOverviewSpecialBlockWrapper = style.Style{ Debug: debugOverviewCards, diff --git a/internal/stats/render/period/v2/overview.go b/internal/stats/render/period/v2/overview.go index c041069c..a4ee77f0 100644 --- a/internal/stats/render/period/v2/overview.go +++ b/internal/stats/render/period/v2/overview.go @@ -8,30 +8,28 @@ import ( "github.com/cufee/facepaint/style" ) -func newRatingOverviewCard(data period.RatingOverviewCard, columnWidth map[string]float64) *facepaint.Block { +func newRatingOverviewCard(stl overviewCardStyle, data period.RatingOverviewCard, columnWidth map[string]float64) *facepaint.Block { if len(data.Blocks) == 0 { return nil } var columns []*facepaint.Block for _, column := range data.Blocks { - columns = append(columns, newOverviewColumn(styledRatingOverviewCard, column, columnWidth[string(column.Flavor)])) + columns = append(columns, newOverviewColumn(stl, column, columnWidth[string(column.Flavor)])) } - // card - return facepaint.NewBlocksContent(styledRatingOverviewCard.card.Options(), columns...) + return facepaint.NewBlocksContent(stl.card.Options(), columns...) } -func newUnratedOverviewCard(data period.OverviewCard, columnWidth map[string]float64) *facepaint.Block { +func newUnratedOverviewCard(stl overviewCardStyle, data period.OverviewCard, columnWidth map[string]float64) *facepaint.Block { if len(data.Blocks) == 0 { return nil } var columns []*facepaint.Block for _, column := range data.Blocks { - columns = append(columns, newOverviewColumn(styledUnratedOverviewCard, column, columnWidth[string(column.Flavor)])) + columns = append(columns, newOverviewColumn(stl, column, columnWidth[string(column.Flavor)])) } - // card - return facepaint.NewBlocksContent(styledUnratedOverviewCard.card.Options(), columns...) + return facepaint.NewBlocksContent(stl.card.Options(), columns...) } func newOverviewColumn(stl overviewCardStyle, data period.OverviewColumn, columnWidth float64) *facepaint.Block { @@ -46,7 +44,6 @@ func newOverviewColumn(stl overviewCardStyle, data period.OverviewColumn, column columnBlocks = append(columnBlocks, newOverviewRatingBlock(stl.styleBlock(block), block)) } } - // column return facepaint.NewBlocksContent(style.NewStyle( style.Parent(stl.column(data)), style.SetMinWidth(columnWidth), @@ -55,22 +52,15 @@ func newOverviewColumn(stl overviewCardStyle, data period.OverviewColumn, column func newOverviewBlockWithIcon(blockStyle blockStyle, block prepare.StatsBlock[period.BlockData, string], icon *facepaint.Block) *facepaint.Block { if icon == nil { - // block return facepaint.NewBlocksContent(blockStyle.valueContainer.Options(), - // value facepaint.MustNewTextContent(blockStyle.value.Options(), block.V.String()), - // label facepaint.MustNewTextContent(blockStyle.label.Options(), block.Label), ) } - // wrapper return facepaint.NewBlocksContent(blockStyle.wrapper.Options(), icon, - // block facepaint.NewBlocksContent(blockStyle.valueContainer.Options(), - // value facepaint.MustNewTextContent(blockStyle.value.Options(), block.V.String()), - // label facepaint.MustNewTextContent(blockStyle.label.Options(), block.Label), )) } diff --git a/internal/stats/render/session/v2/background.go b/internal/stats/render/session/v2/background.go deleted file mode 100644 index b1286de7..00000000 --- a/internal/stats/render/session/v2/background.go +++ /dev/null @@ -1,38 +0,0 @@ -package session - -import ( - "image" - "image/color" - "slices" - "time" - - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/aftermath/internal/stats/frame" -) - -func addBackgroundBranding(background image.Image, vehicles map[string]frame.VehicleStatsFrame, patternSeed int) image.Image { - var values []vehicleWN8 - for _, vehicle := range vehicles { - if wn8 := vehicle.WN8(); !frame.InvalidValue.Equals(wn8) { - values = append(values, vehicleWN8{vehicle.VehicleID, wn8, int(vehicle.LastBattleTime.Unix())}) - } - } - slices.SortFunc(values, func(a, b vehicleWN8) int { return b.sortKey - a.sortKey }) - if len(values) >= 10 { - values = values[:9] - } - - var accentColors []color.Color - for _, value := range values { - c := common.GetWN8Colors(value.wn8.Float()).Background - if _, _, _, a := c.RGBA(); a > 0 { - accentColors = append(accentColors, c) - } - } - - if patternSeed == 0 { - patternSeed = int(time.Now().Unix()) - } - - return common.AddDefaultBrandedOverlay(background, accentColors, patternSeed, 0.5) -} diff --git a/internal/stats/render/session/v2/cards.go b/internal/stats/render/session/v2/cards.go index e830650a..1e08a443 100644 --- a/internal/stats/render/session/v2/cards.go +++ b/internal/stats/render/session/v2/cards.go @@ -3,145 +3,128 @@ package session import ( "strconv" - prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" - "github.com/cufee/aftermath/internal/stats/prepare/session/v1" - "github.com/cufee/facepaint/style" - "github.com/nao1215/imaging" - "github.com/cufee/aftermath/internal/database/models" "github.com/cufee/aftermath/internal/render/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" + "github.com/cufee/aftermath/internal/stats/prepare/session/v1" "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" + "github.com/nao1215/imaging" ) func generateCards(sessionData, careerData fetch.AccountStatsOverPeriod, cards session.Cards, _ []models.UserSubscription, opts common.Options) (*facepaint.Block, error) { + theme := opts.Theme + hlStyle := common.NewHighlightCardStyle(theme) + styledOverviewCard := newOverviewCardStyle(theme) + vStyle := newVehicleCardStyle(theme) + legendPillText := newVehicleLegendPillText(theme) + var ( - renderUnratedVehiclesCount = 3 // minimum number of vehicle cards - // primary cards - // when there are some unrated battles or no battles at all - shouldRenderUnratedOverview = sessionData.RegularBattles.Battles > 0 || sessionData.RatingBattles.Battles < 1 - // when there are 3 vehicle cards and no rating overview cards or there are 6 vehicle cards and some rating battles - shouldRenderUnratedHighlights = (sessionData.RegularBattles.Battles > 0 && sessionData.RatingBattles.Battles < 1 && len(cards.Unrated.Vehicles) > renderUnratedVehiclesCount) || - (sessionData.RegularBattles.Battles > 0 && len(cards.Unrated.Vehicles) > 3) - shouldRenderRatingOverview = sessionData.RatingBattles.Battles > 0 && opts.VehicleIDs == nil - // secondary cards - shouldRenderUnratedVehicles = sessionData.RegularBattles.Battles > 0 && len(cards.Unrated.Vehicles) > 0 + renderUnratedVehiclesCount = 8 + shouldRenderUnratedOverview = sessionData.RegularBattles.Battles > 0 || sessionData.RatingBattles.Battles < 1 + shouldRenderUnratedHighlights = sessionData.RegularBattles.Battles > 0 && len(cards.Unrated.Vehicles) > len(cards.Unrated.Highlights) + shouldRenderRatingOverview = sessionData.RatingBattles.Battles > 0 ) - // try to make the columns height roughly similar to primary column - if shouldRenderUnratedHighlights { - renderUnratedVehiclesCount += len(cards.Unrated.Highlights) - } - if shouldRenderRatingOverview { - renderUnratedVehiclesCount += 1 - } - if len(opts.VehicleIDs) == 1 { - renderUnratedVehiclesCount = 0 - } - - // calculate max overview block width to make all blocks the same size var maxWidthOverviewColumn = make(map[bool]float64) - for _, column := range cards.Unrated.Overview.Blocks { + for _, column := range append(cards.Unrated.Overview.Blocks, cards.Rating.Overview.Blocks...) { for _, block := range column.Blocks { + key := column.Flavor == session.BlockFlavorDefault + blockStyle := styledOverviewCard.styleBlock(block) switch block.Tag { case prepare.TagWN8: block.Label = common.GetWN8TierName(block.Value().Float()) - maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault] = max(maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault], iconSizeWN8) - } - maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault] = max(maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault], facepaint.MeasureString(block.Label, styledOverviewCard.styleBlock(block).label.Font).TotalWidth) - maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault] = max(maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault], facepaint.MeasureString(block.Value().String(), styledOverviewCard.styleBlock(block).value.Font).TotalWidth) - } - } - for _, column := range cards.Rating.Overview.Blocks { - for _, block := range column.Blocks { - switch block.Tag { + maxWidthOverviewColumn[key] = max(maxWidthOverviewColumn[key], iconSizeWN8) case prepare.TagRankedRating: block.Label = common.GetRatingTierName(block.Value().Float()) - maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault] = max(maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault], iconSizeRating) + maxWidthOverviewColumn[key] = max(maxWidthOverviewColumn[key], iconSizeRating) } - maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault] = max(maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault], facepaint.MeasureString(block.Label, styledOverviewCard.styleBlock(block).label.Font).TotalWidth) - maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault] = max(maxWidthOverviewColumn[column.Flavor == session.BlockFlavorDefault], facepaint.MeasureString(block.Value().String(), styledOverviewCard.styleBlock(block).value.Font).TotalWidth) - } - } - - // calculate per block type width of highlight stats to make things even - var highlightBlockWidth = make(map[prepare.Tag]float64) - for _, highlight := range cards.Unrated.Highlights { - for _, block := range highlight.Blocks { - label := facepaint.MeasureString(block.Label, styledHighlightCard.blockLabel().Font).TotalWidth - value := facepaint.MeasureString(block.Value().String(), styledHighlightCard.blockValue().Font).TotalWidth - highlightBlockWidth[block.Tag] = max(highlightBlockWidth[block.Tag], label, value) + maxWidthOverviewColumn[key] = max(maxWidthOverviewColumn[key], + facepaint.MeasureStringWidth(block.Label, blockStyle.label.Font), + facepaint.MeasureStringWidth(block.Value().String(), blockStyle.value.Font), + ) } } - // calculate per block type width of vehicle stats to make things even var vehicleBlockWidth = make(map[prepare.Tag]float64) for _, card := range cards.Unrated.Vehicles { for _, block := range card.Blocks { - labelStyle := styledVehicleLegendPillText() - label := facepaint.MeasureString(block.Label, labelStyle.Font).TotalWidth + labelStyle.PaddingLeft + labelStyle.PaddingRight - value := facepaint.MeasureString(block.Value().String(), styledVehicleCard.value().Font).TotalWidth - vehicleBlockWidth[block.Tag] = max(vehicleBlockWidth[block.Tag], label, value) + vehicleBlockWidth[block.Tag] = max(vehicleBlockWidth[block.Tag], + facepaint.MeasureBlockWidth(block.Label, *legendPillText), + facepaint.MeasureStringWidth(block.Value().String(), vStyle.value().Font), + ) } } - var overviewCards = []*facepaint.Block{newPlayerNameCard(careerData.Account)} - // unrated overview + var overviewCards = []*facepaint.Block{common.NewPlayerNameBlock(careerData.Account, theme)} if shouldRenderUnratedOverview { - if card := newUnratedOverviewCard(cards.Unrated.Overview, maxWidthOverviewColumn); card != nil { + if card := newUnratedOverviewCard(styledOverviewCard, cards.Unrated.Overview, maxWidthOverviewColumn); card != nil { overviewCards = append(overviewCards, card) } } - // rating battles if shouldRenderRatingOverview { - if card := newRatingOverviewCard(cards.Rating, maxWidthOverviewColumn); card != nil { + if card := newRatingOverviewCard(styledOverviewCard, cards.Rating, maxWidthOverviewColumn); card != nil { overviewCards = append(overviewCards, card) } } - // highlights if shouldRenderUnratedHighlights { + var highlightBlockWidth = make(map[prepare.Tag]float64) + for _, highlight := range cards.Unrated.Highlights { + for _, block := range highlight.Blocks { + highlightBlockWidth[block.Tag] = max(highlightBlockWidth[block.Tag], + facepaint.MeasureStringWidth(block.Label, hlStyle.BlockLabel().Font), + facepaint.MeasureStringWidth(block.Value().String(), hlStyle.BlockValue().Font), + ) + } + } + for _, card := range cards.Unrated.Highlights { - overviewCards = append(overviewCards, newHighlightCard(card, highlightBlockWidth)) + overviewCards = append(overviewCards, newHighlightCard(hlStyle, card, highlightBlockWidth)) } } - // vehicles var vehicleCards []*facepaint.Block - if shouldRenderUnratedVehicles { - for i, card := range cards.Unrated.Vehicles { - if i == renderUnratedVehiclesCount { - break - } - vehicleCards = append(vehicleCards, newVehicleCard(card, vehicleBlockWidth)) + for i, card := range cards.Unrated.Vehicles { + if i == renderUnratedVehiclesCount { + break } + vehicleCards = append(vehicleCards, newVehicleCard(vStyle, card, vehicleBlockWidth)) } var sectionBlocks []*facepaint.Block sectionBlocks = append(sectionBlocks, facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsSection)), overviewCards...)) if len(vehicleCards) > 0 { - vehicleCards = append(vehicleCards, newVehicleLegendCard(cards.Unrated.Vehicles[0], vehicleBlockWidth)) + vehicleCards = append(vehicleCards, newVehicleLegendCard(vStyle, legendPillText, cards.Unrated.Vehicles[0], vehicleBlockWidth)) sectionBlocks = append(sectionBlocks, facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsSection)), vehicleCards...)) } statsCardsBlock := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsSectionsWrapper)), sectionBlocks...) cardsFrame := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledStatsFrame)), statsCardsBlock) - // resize and place background if opts.Background != nil { - cardsFrameSize := cardsFrame.Dimensions() - opts.Background = imaging.Fill(opts.Background, cardsFrameSize.Width, cardsFrameSize.Height, imaging.Center, imaging.Lanczos) + bgDims := cardsFrame.Dimensions() + seed, _ := strconv.Atoi(careerData.Account.ID) + opts.Background = imaging.Fill(opts.Background, bgDims.Width, bgDims.Height, imaging.Center, imaging.Lanczos) if !opts.BackgroundIsCustom { - seed, _ := strconv.Atoi(careerData.Account.ID) - opts.Background = addBackgroundBranding(opts.Background, sessionData.RegularBattles.Vehicles, seed) + opts.Background = common.AddWN8BackgroundBranding(opts.Background, sessionData.RegularBattles.Vehicles, seed) + } + + var layers []*facepaint.Block + layers = append(layers, facepaint.MustNewImageContent(common.CardsBackgroundStyle, opts.Background)) + if theme.BackgroundOverlay != nil { + if overlay := theme.BackgroundOverlay(opts.Background.Bounds(), seed); overlay != nil { + layers = append(layers, facepaint.MustNewImageContent(common.CardsBackgroundStyle, overlay)) + } } - cardsFrame = facepaint.NewBlocksContent(style.NewStyle(), - facepaint.MustNewImageContent(styledCardsBackground, opts.Background), cardsFrame, - ) + layers = append(layers, cardsFrame) + cardsFrame = facepaint.NewBlocksContent(style.NewStyle(), layers...) } var frameCards []*facepaint.Block frameCards = append(frameCards, cardsFrame) - frameCards = append(frameCards, newFooterCard(sessionData, cards, opts)) + frameCards = append(frameCards, common.NewFooterBlock(sessionData.PeriodStart, sessionData.PeriodEnd, opts)) - return facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledFinalFrame)), frameCards...), nil + frameStyle := common.FinalFrameStyle(theme) + return facepaint.NewBlocksContent(style.NewStyle(style.Parent(frameStyle)), frameCards...), nil } diff --git a/internal/stats/render/session/v2/highlight-style.go b/internal/stats/render/session/v2/highlight-style.go deleted file mode 100644 index af4f74c4..00000000 --- a/internal/stats/render/session/v2/highlight-style.go +++ /dev/null @@ -1,85 +0,0 @@ -package session - -import ( - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/facepaint/style" -) - -var styledHighlightTitle = style.Style{} -var styledHighlightStatsRow = style.Style{} - -type highlightCardStyle struct { - card style.Style - titleWrapper style.Style - titleLabel func() *style.Style - titleVehicle func() *style.Style - statsWrapper style.Style - stats style.Style - blockValue func() *style.Style - blockLabel func() *style.Style -} - -var styledHighlightCard = highlightCardStyle{ - card: style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, - - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, - - GrowHorizontal: true, - Gap: 20, - - PaddingLeft: cardPaddingX / 1.5, - PaddingRight: cardPaddingX / 1.5, - PaddingTop: cardPaddingY / 1.5, - PaddingBottom: cardPaddingY / 1.5, - }, - titleWrapper: style.Style{ - // Debug: true, - - GrowHorizontal: true, - Direction: style.DirectionVertical, - }, - titleLabel: func() *style.Style { - return &style.Style{ - Color: common.TextSecondary, - Font: common.FontSmall(), - } - }, - titleVehicle: func() *style.Style { - return &style.Style{ - Color: common.TextPrimary, - Font: common.FontMedium(), - } - }, - stats: style.Style{ - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - }, - statsWrapper: style.Style{ - // Debug: true, - - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - Gap: 10, - }, - blockValue: func() *style.Style { - return &style.Style{ - Color: common.TextPrimary, - Font: common.FontMedium(), - } - }, - blockLabel: func() *style.Style { - return &style.Style{ - Color: common.TextAlt, - Font: common.FontSmall(), - } - }, -} diff --git a/internal/stats/render/session/v2/highlight.go b/internal/stats/render/session/v2/highlight.go index 811d6ecf..6a45a460 100644 --- a/internal/stats/render/session/v2/highlight.go +++ b/internal/stats/render/session/v2/highlight.go @@ -1,29 +1,29 @@ package session import ( + "github.com/cufee/aftermath/internal/render/common" prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "github.com/cufee/aftermath/internal/stats/prepare/session/v1" "github.com/cufee/facepaint" "github.com/cufee/facepaint/style" ) -func newHighlightCard(data session.VehicleCard, blockSizes map[prepare.Tag]float64) *facepaint.Block { - leftSide := facepaint.NewBlocksContent(styledHighlightCard.titleWrapper.Options(), - facepaint.MustNewTextContent(styledHighlightCard.titleLabel().Options(), data.Meta), - facepaint.MustNewTextContent(styledHighlightCard.titleVehicle().Options(), data.Title), +func newHighlightCard(hlStyle common.HighlightCardStyle, data session.VehicleCard, blockSizes map[prepare.Tag]float64) *facepaint.Block { + leftSide := facepaint.NewBlocksContent(hlStyle.TitleWrapper.Options(), + facepaint.MustNewTextContent(hlStyle.TitleLabel().Options(), data.Meta), + facepaint.MustNewTextContent(hlStyle.TitleVehicle().Options(), data.Title), ) var rightSide []*facepaint.Block for _, block := range data.Blocks { - rightSide = append(rightSide, facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledHighlightCard.stats), style.SetWidth(blockSizes[block.Tag])), - facepaint.MustNewTextContent(styledHighlightCard.blockValue().Options(), block.V.String()), - facepaint.MustNewTextContent(styledHighlightCard.blockLabel().Options(), block.Label), + rightSide = append(rightSide, facepaint.NewBlocksContent(style.NewStyle(style.Parent(hlStyle.Stats), style.SetWidth(blockSizes[block.Tag])), + facepaint.MustNewTextContent(hlStyle.BlockValue().Options(), block.V.String()), + facepaint.MustNewTextContent(hlStyle.BlockLabel().Options(), block.Label), )) } - return facepaint.NewBlocksContent(styledHighlightCard.card.Options(), + return facepaint.NewBlocksContent(hlStyle.Card.Options(), leftSide, - facepaint.NewBlocksContent(styledHighlightCard.statsWrapper.Options(), rightSide...), + facepaint.NewBlocksContent(hlStyle.StatsWrapper.Options(), rightSide...), ) - } diff --git a/internal/stats/render/session/v2/image.go b/internal/stats/render/session/v2/image.go index 2d6b255f..28c4fcfc 100644 --- a/internal/stats/render/session/v2/image.go +++ b/internal/stats/render/session/v2/image.go @@ -2,33 +2,34 @@ package session import ( "image" + "strconv" "github.com/cufee/aftermath/internal/database/models" "github.com/cufee/aftermath/internal/render/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" - "github.com/cufee/aftermath/internal/stats/frame" "github.com/cufee/aftermath/internal/stats/prepare/session/v1" ) -type vehicleWN8 struct { - id string - wn8 frame.Value - sortKey int -} - func CardsToImage(session, career fetch.AccountStatsOverPeriod, cards session.Cards, subs []models.UserSubscription, opts ...common.Option) (image.Image, error) { o := common.DefaultOptions() for _, apply := range opts { apply(&o) } - // Generate cards - cardsBlock, err := generateCards(session, career, cards, subs, o) + block, err := generateCards(session, career, cards, subs, o) if err != nil { return nil, err } - // Render - return cardsBlock.Render() + rendered, err := block.Render() + if err != nil { + return nil, err + } + + if o.Theme.ForegroundOverlay != nil { + seed, _ := strconv.Atoi(career.Account.ID) + rendered = o.Theme.ForegroundOverlay(rendered, rendered.Bounds(), seed) + } + return rendered, nil } diff --git a/internal/stats/render/session/v2/misc-style.go b/internal/stats/render/session/v2/misc-style.go index ccce2327..24dd29ff 100644 --- a/internal/stats/render/session/v2/misc-style.go +++ b/internal/stats/render/session/v2/misc-style.go @@ -1,103 +1,19 @@ package session -import ( - "image/color" - - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/facepaint/style" -) - -var ( - clanTagBackgroundColor = color.NRGBA{0, 0, 0, 100} - - cardBackgroundBlur = 20.0 - cardPaddingX = 35.0 - cardPaddingY = 30.0 -) - -func styledPlayerName() style.Style { - return style.Style{ - Color: common.TextPrimary, - Font: common.FontMedium(), - } -} - -func styledPlayerClanTag() style.Style { - return style.Style{ - Color: common.TextSecondary, - Font: common.FontSmall(), - } -} - -var styledPlayerNameWrapper = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, - - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, - - PaddingLeft: 5, - PaddingRight: 5, - PaddingTop: 5, - PaddingBottom: 5, - - Height: 50, - - GrowHorizontal: true, - Gap: 20, -} - -var styledPlayerNameCard = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, - - GrowHorizontal: true, -} - -var styledPlayerClanTagCard = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, - - BackgroundColor: clanTagBackgroundColor, - - BorderRadiusTopLeft: common.BorderRadiusMD, - BorderRadiusTopRight: common.BorderRadiusMD, - BorderRadiusBottomLeft: common.BorderRadiusMD, - BorderRadiusBottomRight: common.BorderRadiusMD, - - GrowVertical: true, - - PaddingLeft: 12, - PaddingRight: 12, - PaddingTop: 10, - PaddingBottom: 10, -} +import "github.com/cufee/facepaint/style" var styledCardsSection = style.Style{ - Debug: false, - Direction: style.DirectionVertical, AlignItems: style.AlignItemsCenter, Gap: 10, } var styledCardsSectionsWrapper = style.Style{ - Debug: false, - Direction: style.DirectionHorizontal, Gap: 20, } var styledStatsFrame = style.Style{ - Debug: false, - Direction: style.DirectionVertical, Gap: 10, @@ -106,43 +22,3 @@ var styledStatsFrame = style.Style{ PaddingTop: 30, PaddingBottom: 30, } - -var styledFinalFrame = style.Style{ - Debug: false, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - Gap: 5, -} - -var styledCardsBackground = style.NewStyle( - style.SetBorderRadius(common.BorderRadius2XL), - style.SetBlur(common.DefaultBackgroundBlur), - style.SetPosition(style.PositionAbsolute), - style.SetZIndex(-99), -) - -var styledFooterWrapper = style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - Gap: 5, -} - -func styledFooterCard() style.Style { - return style.Style{ - Font: common.FontSmall(), - Color: common.TextAlt, - - BackgroundColor: common.DefaultCardColor, - - BorderRadiusTopLeft: common.BorderRadiusSM, - BorderRadiusTopRight: common.BorderRadiusSM, - BorderRadiusBottomLeft: common.BorderRadiusSM, - BorderRadiusBottomRight: common.BorderRadiusSM, - - PaddingLeft: 10, - PaddingRight: 10, - PaddingTop: 5, - PaddingBottom: 5, - } -} diff --git a/internal/stats/render/session/v2/misc.go b/internal/stats/render/session/v2/misc.go deleted file mode 100644 index 8d7e1520..00000000 --- a/internal/stats/render/session/v2/misc.go +++ /dev/null @@ -1,62 +0,0 @@ -package session - -import ( - "github.com/cufee/aftermath/internal/database/models" - "github.com/cufee/aftermath/internal/render/common" - "github.com/cufee/aftermath/internal/stats/fetch/v1" - "github.com/cufee/aftermath/internal/stats/prepare/session/v1" - "github.com/cufee/facepaint" - "github.com/cufee/facepaint/style" -) - -func newPlayerNameCard(account models.Account) *facepaint.Block { - var blocks []*facepaint.Block - - // clan tag - var clanTagBlock *facepaint.Block - if account.ClanTag != "" { - stl := styledPlayerClanTag() - clanTagBlock = facepaint.NewBlocksContent(styledPlayerClanTagCard.Options(), facepaint.MustNewTextContent(stl.Options(), account.ClanTag)) - blocks = append(blocks, clanTagBlock) - } - - // nickname - stl := styledPlayerName() - blocks = append(blocks, facepaint.NewBlocksContent(styledPlayerNameCard.Options(), - facepaint.MustNewTextContent(stl.Options(), account.Nickname), - )) - - // spacer - if clanTagBlock != nil { - size := clanTagBlock.Dimensions() - stl := style.Style{ - Width: float64(size.Width), - Height: 1, - } - blocks = append(blocks, facepaint.NewEmptyContent(stl.Options())) - } - - return facepaint.NewBlocksContent(styledPlayerNameWrapper.Options(), blocks...) -} - -func newFooterCard(stats fetch.AccountStatsOverPeriod, cards session.Cards, opts common.Options) *facepaint.Block { - stl := styledFooterCard() - var footer []*facepaint.Block - for _, text := range opts.FooterText { - footer = append(footer, facepaint.MustNewTextContent(stl.Options(), text)) - } - - sessionTo := stats.PeriodEnd.Format("Jan 2, 2006") - sessionFromFormat := "Jan 2, 2006" - if stats.PeriodStart.Year() == stats.PeriodEnd.Year() { - sessionFromFormat = "Jan 2" - } - sessionFrom := stats.PeriodStart.Format(sessionFromFormat) - if stats.PeriodStart.IsZero() || sessionFrom == sessionTo { - footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionTo)) - } else { - footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionFrom+" - "+sessionTo)) - } - - return facepaint.NewBlocksContent(styledFooterWrapper.Options(), footer...) -} diff --git a/internal/stats/render/session/v2/overview-style.go b/internal/stats/render/session/v2/overview-style.go index 97536114..5391226f 100644 --- a/internal/stats/render/session/v2/overview-style.go +++ b/internal/stats/render/session/v2/overview-style.go @@ -27,33 +27,25 @@ type overviewCardStyle struct { styleBlock func(block prepare.StatsBlock[session.BlockData, string]) blockStyle } -// unrated - -var styledOverviewCard = overviewCardStyle{ - styleBlock: styleOverviewBlock, - card: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceBetween, - - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, +func newOverviewCardStyle(theme common.Theme) overviewCardStyle { + return overviewCardStyle{ + styleBlock: newStyleOverviewBlock(theme), + card: common.ApplyTheme(style.Style{ + Debug: debugOverviewCards, - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceBetween, - PaddingLeft: cardPaddingX, - PaddingRight: cardPaddingX, - PaddingTop: cardPaddingY / 2, - PaddingBottom: cardPaddingY / 2, + PaddingLeft: common.CardPaddingX, + PaddingRight: common.CardPaddingX, + PaddingTop: common.CardPaddingY / 2, + PaddingBottom: common.CardPaddingY / 2, - GrowHorizontal: true, - Gap: 15, - }, + GrowHorizontal: true, + Gap: 15, + }, theme.Card), + } } func (overviewCardStyle) column(column session.OverviewColumn) style.Style { @@ -65,7 +57,6 @@ func (overviewCardStyle) column(column session.OverviewColumn) style.Style { Direction: style.DirectionVertical, AlignItems: style.AlignItemsCenter, JustifyContent: style.JustifyContentCenter, - // GrowVertical: true, GrowHorizontal: true, BorderRadiusTopLeft: common.BorderRadiusSM, @@ -75,8 +66,8 @@ func (overviewCardStyle) column(column session.OverviewColumn) style.Style { PaddingLeft: 10, PaddingRight: 10, - PaddingTop: cardPaddingY / 2, - PaddingBottom: cardPaddingY / 2, + PaddingTop: common.CardPaddingY / 2, + PaddingBottom: common.CardPaddingY / 2, Gap: 15, } @@ -89,77 +80,72 @@ func (overviewCardStyle) column(column session.OverviewColumn) style.Style { JustifyContent: style.JustifyContentCenter, Gap: 15, - PaddingTop: cardPaddingY / 2, - PaddingBottom: cardPaddingY / 2, + PaddingTop: common.CardPaddingY / 2, + PaddingBottom: common.CardPaddingY / 2, } } } -func styleOverviewBlock(block prepare.StatsBlock[session.BlockData, string]) blockStyle { - defaultStyle := blockStyle{ - wrapper: style.Style{}, - valueContainer: style.Style{ - Debug: debugOverviewCards, - - Gap: 6, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - }, - value: style.Style{ - Debug: debugOverviewCards, - - Color: common.TextPrimary, - Font: common.FontLarge(), - }, - label: style.Style{ - Color: common.TextAlt, - Font: common.FontSmall(), - PaddingTop: -5, - }, - } - - switch block.Tag { - case prepare.TagWN8, prepare.TagRankedRating: - return blockStyle{ - wrapper: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - Gap: 10, - - GrowVertical: true, - }, +func newStyleOverviewBlock(theme common.Theme) func(prepare.StatsBlock[session.BlockData, string]) blockStyle { + return func(block prepare.StatsBlock[session.BlockData, string]) blockStyle { + defaultStyle := blockStyle{ + wrapper: style.Style{}, valueContainer: style.Style{ Debug: debugOverviewCards, + Gap: 6, + Direction: style.DirectionVertical, AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentEnd, - Gap: 0, + JustifyContent: style.JustifyContentCenter, }, - value: style.Style{ + value: common.ApplyTheme(style.Style{ Debug: debugOverviewCards, - - PaddingTop: -6, - Color: common.TextPrimary, - Font: common.FontXL(), - }, - label: style.Style{ - Color: common.TextAlt, + Font: common.FontLarge(), + }, theme.TextPrimary()), + label: common.ApplyTheme(style.Style{ Font: common.FontSmall(), - PaddingTop: -6, - }, + PaddingTop: -5, + }, theme.TextAlt()), + } + + switch block.Tag { + case prepare.TagWN8, prepare.TagRankedRating: + return blockStyle{ + wrapper: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + Gap: 10, + + GrowVertical: true, + }, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentEnd, + Gap: 0, + }, + value: common.ApplyTheme(style.Style{ + Debug: debugOverviewCards, + PaddingTop: -6, + Font: common.FontXL(), + }, theme.TextPrimary()), + label: common.ApplyTheme(style.Style{ + Font: common.FontSmall(), + PaddingTop: -6, + }, theme.TextAlt()), + } + default: + return defaultStyle } - default: - return defaultStyle } } -// wrapped around special block text and icon var styledOverviewSpecialBlockWrapper = style.Style{ Debug: debugOverviewCards, diff --git a/internal/stats/render/session/v2/overview.go b/internal/stats/render/session/v2/overview.go index 337156fa..c99d9c7e 100644 --- a/internal/stats/render/session/v2/overview.go +++ b/internal/stats/render/session/v2/overview.go @@ -11,30 +11,28 @@ import ( "github.com/cufee/facepaint/style" ) -func newRatingOverviewCard(data session.RatingCards, columnWidth map[bool]float64) *facepaint.Block { +func newRatingOverviewCard(stl overviewCardStyle, data session.RatingCards, columnWidth map[bool]float64) *facepaint.Block { if len(data.Overview.Blocks) == 0 { return nil } var columns []*facepaint.Block for _, column := range data.Overview.Blocks { - columns = append(columns, newOverviewColumn(styledOverviewCard, column, columnWidth[column.Flavor == session.BlockFlavorDefault])) + columns = append(columns, newOverviewColumn(stl, column, columnWidth[column.Flavor == session.BlockFlavorDefault])) } - // card - return facepaint.NewBlocksContent(styledOverviewCard.card.Options(), columns...) + return facepaint.NewBlocksContent(stl.card.Options(), columns...) } -func newUnratedOverviewCard(data session.OverviewCard, columnWidth map[bool]float64) *facepaint.Block { +func newUnratedOverviewCard(stl overviewCardStyle, data session.OverviewCard, columnWidth map[bool]float64) *facepaint.Block { if len(data.Blocks) == 0 { return nil } var columns []*facepaint.Block for _, column := range data.Blocks { - columns = append(columns, newOverviewColumn(styledOverviewCard, column, columnWidth[column.Flavor == session.BlockFlavorDefault])) + columns = append(columns, newOverviewColumn(stl, column, columnWidth[column.Flavor == session.BlockFlavorDefault])) } - // card - return facepaint.NewBlocksContent(styledOverviewCard.card.Options(), columns...) + return facepaint.NewBlocksContent(stl.card.Options(), columns...) } func newOverviewColumn(stl overviewCardStyle, data session.OverviewColumn, columnWidth float64) *facepaint.Block { @@ -49,7 +47,6 @@ func newOverviewColumn(stl overviewCardStyle, data session.OverviewColumn, colum columnBlocks = append(columnBlocks, newOverviewRatingBlock(stl.styleBlock(block), block)) } } - // column return facepaint.NewBlocksContent(style.NewStyle( style.Parent(stl.column(data)), style.SetMinWidth(columnWidth), @@ -58,14 +55,10 @@ func newOverviewColumn(stl overviewCardStyle, data session.OverviewColumn, colum func newOverviewBlockWithIcon(blockStyle blockStyle, block prepare.StatsBlock[session.BlockData, string], icon *facepaint.Block) *facepaint.Block { if icon == nil { - // block return facepaint.NewBlocksContent(blockStyle.wrapper.Options(), newOverviewBlock(blockStyle, block)) } - // wrapper return facepaint.NewBlocksContent(blockStyle.wrapper.Options(), - // icon facepaint.NewBlocksContent(blockStyle.iconWrapper.Options(), icon), - // block newOverviewBlock(blockStyle, block), ) } @@ -75,9 +68,7 @@ func newOverviewBlock(blockStyle blockStyle, block prepare.StatsBlock[session.Bl case prepare.TagBattles, prepare.TagWN8, prepare.TagRankedRating: return facepaint.NewBlocksContent(blockStyle.wrapper.Options(), facepaint.NewBlocksContent(blockStyle.valueContainer.Options(), - // value facepaint.MustNewTextContent(blockStyle.value.Options(), block.V.String()), - // label facepaint.MustNewTextContent(blockStyle.label.Options(), block.Label), ), ) @@ -108,9 +99,7 @@ func newOverviewBlock(blockStyle blockStyle, block prepare.StatsBlock[session.Bl return facepaint.NewBlocksContent(blockStyle.wrapper.Options(), facepaint.NewBlocksContent(blockStyle.valueContainer.Options(), - // value facepaint.NewBlocksContent(style.NewStyle(), indicator, facepaint.MustNewTextContent(blockStyle.value.Options(), block.V.String())), - // label facepaint.MustNewTextContent(blockStyle.label.Options(), block.Label), ), ) diff --git a/internal/stats/render/session/v2/vehicle-style.go b/internal/stats/render/session/v2/vehicle-style.go index 669743c1..af3efd20 100644 --- a/internal/stats/render/session/v2/vehicle-style.go +++ b/internal/stats/render/session/v2/vehicle-style.go @@ -22,13 +22,15 @@ type vehicleCardStyle struct { valueWrapper func(float64) *style.Style } -var styledVehicleLegendPillWrapper = style.NewStyle(style.Parent(style.Style{ - Direction: style.DirectionHorizontal, - JustifyContent: style.JustifyContentSpaceBetween, - Gap: 5, -})) +func newVehicleLegendPillWrapper() style.StyleOptions { + return style.NewStyle(style.Parent(style.Style{ + Direction: style.DirectionHorizontal, + JustifyContent: style.JustifyContentSpaceBetween, + Gap: 5, + })) +} -func styledVehicleLegendPill(width float64) style.StyleOptions { +func newVehicleLegendPill(width float64) style.StyleOptions { return style.NewStyle(style.Parent(style.Style{ Debug: debugVehicleCards, Width: width, @@ -37,84 +39,72 @@ func styledVehicleLegendPill(width float64) style.StyleOptions { })) } -func styledVehicleLegendPillText() *style.Style { - return &style.Style{ - Color: common.TextAlt, - Font: common.FontSmall(), - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, - - BorderRadiusTopLeft: common.BorderRadiusSM, - BorderRadiusTopRight: common.BorderRadiusSM, - BorderRadiusBottomLeft: common.BorderRadiusSM, - BorderRadiusBottomRight: common.BorderRadiusSM, +func newVehicleLegendPillText(theme common.Theme) *style.Style { + pillCard := theme.Card.Chain(style.SetBorderRadius(common.BorderRadiusSM)) + s := common.ApplyTheme(style.Style{ + Font: common.FontSmall(), PaddingLeft: 15, PaddingRight: 15, PaddingTop: 5, PaddingBottom: 5, - } + }, pillCard) + themed := common.ApplyTheme(s, theme.TextAlt()) + return &themed } -var styledVehicleCard = vehicleCardStyle{ - card: style.NewStyle(style.Parent(style.Style{ - Debug: debugVehicleCards, +func newVehicleCardStyle(theme common.Theme) vehicleCardStyle { + return vehicleCardStyle{ + card: style.NewStyle(style.Parent(common.ApplyTheme(style.Style{ + Debug: debugVehicleCards, - Direction: style.DirectionVertical, + Direction: style.DirectionVertical, - BackgroundColor: common.DefaultCardColor, - BlurBackground: cardBackgroundBlur, + GrowHorizontal: true, + Gap: 5, - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, + PaddingLeft: common.CardPaddingX / 1.5, + PaddingRight: common.CardPaddingX / 1.5, + PaddingTop: common.CardPaddingY / 2, + PaddingBottom: common.CardPaddingY / 2, + }, theme.Card))), - GrowHorizontal: true, - Gap: 5, + titleIconWrapper: style.NewStyle(style.Parent(style.Style{})), + titleWrapper: style.NewStyle(style.Parent(style.Style{ + Debug: debugVehicleCards, + AlignItems: style.AlignItemsCenter, - PaddingLeft: cardPaddingX / 1.5, - PaddingRight: cardPaddingX / 1.5, - PaddingTop: cardPaddingY / 2, - PaddingBottom: cardPaddingY / 2, - })), - - titleIconWrapper: style.NewStyle(style.Parent(style.Style{})), - titleWrapper: style.NewStyle(style.Parent(style.Style{ - Debug: debugVehicleCards, - AlignItems: style.AlignItemsCenter, - - GrowHorizontal: true, - Gap: 10, - })), - titleText: func() style.StyleOptions { - return style.NewStyle(style.Parent(style.Style{ - Color: common.TextSecondary, - Font: common.FontMedium(), GrowHorizontal: true, - })) - }, - - stats: style.NewStyle(style.Parent(style.Style{ - Debug: debugVehicleCards, - - Direction: style.DirectionHorizontal, - JustifyContent: style.JustifyContentSpaceBetween, - GrowHorizontal: true, - Gap: 10, - })), - value: func() *style.Style { - return &style.Style{ - Color: common.TextPrimary, - Font: common.FontLarge(), - JustifyContent: style.JustifyContentCenter, - } - }, - valueWrapper: func(width float64) *style.Style { - return &style.Style{ - Width: width, - JustifyContent: style.JustifyContentCenter, - AlignItems: style.AlignItemsCenter, - } - }, + Gap: 10, + })), + titleText: func() style.StyleOptions { + return style.NewStyle(style.Parent(common.ApplyTheme(style.Style{ + Font: common.FontMedium(), + GrowHorizontal: true, + }, theme.TextSecondary()))) + }, + + stats: style.NewStyle(style.Parent(style.Style{ + Debug: debugVehicleCards, + + Direction: style.DirectionHorizontal, + JustifyContent: style.JustifyContentSpaceBetween, + GrowHorizontal: true, + Gap: 10, + })), + value: func() *style.Style { + s := common.ApplyTheme(style.Style{ + Font: common.FontLarge(), + JustifyContent: style.JustifyContentCenter, + }, theme.TextPrimary()) + return &s + }, + valueWrapper: func(width float64) *style.Style { + return &style.Style{ + Width: width, + JustifyContent: style.JustifyContentCenter, + AlignItems: style.AlignItemsCenter, + } + }, + } } diff --git a/internal/stats/render/session/v2/vehicle.go b/internal/stats/render/session/v2/vehicle.go index c011ae17..6d5ee87f 100644 --- a/internal/stats/render/session/v2/vehicle.go +++ b/internal/stats/render/session/v2/vehicle.go @@ -11,24 +11,24 @@ import ( "github.com/cufee/facepaint/style" ) -func newVehicleCard(data session.VehicleCard, blockWidth map[prepare.Tag]float64) *facepaint.Block { - title := facepaint.NewBlocksContent(styledVehicleCard.titleWrapper, - facepaint.MustNewTextContent(styledVehicleCard.titleText(), data.Meta+" "+data.Title), - newVehicleWN8Icon(data), +func newVehicleCard(vStyle vehicleCardStyle, data session.VehicleCard, blockWidth map[prepare.Tag]float64) *facepaint.Block { + title := facepaint.NewBlocksContent(vStyle.titleWrapper, + facepaint.MustNewTextContent(vStyle.titleText(), data.Meta+" "+data.Title), + newVehicleWN8Icon(vStyle, data), ) var statsBlocks []*facepaint.Block for _, block := range data.Blocks { - statsBlocks = append(statsBlocks, newVehicleBlockValue(block, blockWidth)) + statsBlocks = append(statsBlocks, newVehicleBlockValue(vStyle, block, blockWidth)) } - return facepaint.NewBlocksContent(styledVehicleCard.card, + return facepaint.NewBlocksContent(vStyle.card, title, - facepaint.NewBlocksContent(styledVehicleCard.stats, statsBlocks...), + facepaint.NewBlocksContent(vStyle.stats, statsBlocks...), ) } -func newVehicleBlockValue(block prepare.StatsBlock[session.BlockData, string], blockWidth map[prepare.Tag]float64) *facepaint.Block { +func newVehicleBlockValue(vStyle vehicleCardStyle, block prepare.StatsBlock[session.BlockData, string], blockWidth map[prepare.Tag]float64) *facepaint.Block { switch block.Tag { default: var indicatorColor color.Color = color.Transparent @@ -54,36 +54,34 @@ func newVehicleBlockValue(block prepare.StatsBlock[session.BlockData, string], b Bottom: 20, }))) - return facepaint.NewBlocksContent(styledVehicleCard.valueWrapper(blockWidth[block.Tag]).Options(), + return facepaint.NewBlocksContent(vStyle.valueWrapper(blockWidth[block.Tag]).Options(), indicator, - facepaint.MustNewTextContent(styledVehicleCard.value().Options(), block.Value().String()), + facepaint.MustNewTextContent(vStyle.value().Options(), block.Value().String()), ) case prepare.TagBattles: - return facepaint.NewBlocksContent(styledVehicleCard.valueWrapper(blockWidth[block.Tag]).Options(), - facepaint.MustNewTextContent(styledVehicleCard.value().Options(), block.Value().String()), + return facepaint.NewBlocksContent(vStyle.valueWrapper(blockWidth[block.Tag]).Options(), + facepaint.MustNewTextContent(vStyle.value().Options(), block.Value().String()), ) } - } -func newVehicleLegendCard(data session.VehicleCard, blockWidth map[prepare.Tag]float64) *facepaint.Block { +func newVehicleLegendCard(vStyle vehicleCardStyle, legendPillText *style.Style, data session.VehicleCard, blockWidth map[prepare.Tag]float64) *facepaint.Block { var legendBlocks []*facepaint.Block for _, block := range data.Blocks { legendBlocks = append(legendBlocks, - facepaint.NewBlocksContent(styledVehicleLegendPill(blockWidth[block.Tag]), - facepaint.MustNewTextContent(styledVehicleLegendPillText().Options(), block.Label), + facepaint.NewBlocksContent(newVehicleLegendPill(blockWidth[block.Tag]), + facepaint.MustNewTextContent(legendPillText.Options(), block.Label), ), ) } - return facepaint.NewBlocksContent(styledVehicleLegendPillWrapper, - facepaint.NewBlocksContent(styledVehicleCard.stats, legendBlocks...), + return facepaint.NewBlocksContent(newVehicleLegendPillWrapper(), + facepaint.NewBlocksContent(vStyle.stats, legendBlocks...), ) - } -func newVehicleWN8Icon(data session.VehicleCard) *facepaint.Block { +func newVehicleWN8Icon(vStyle vehicleCardStyle, data session.VehicleCard) *facepaint.Block { for _, block := range data.Blocks { if block.Tag != prepare.TagWN8 { continue @@ -92,7 +90,7 @@ func newVehicleWN8Icon(data session.VehicleCard) *facepaint.Block { if block.Value().Float() <= 0 { ratingColors.Background = common.TextAlt } - icon := facepaint.NewBlocksContent(styledVehicleCard.titleIconWrapper, + icon := facepaint.NewBlocksContent(vStyle.titleIconWrapper, facepaint.MustNewImageContent( style.NewStyle(style.SetWidth(vehicleIconSizeWN8), style.SetWidth(vehicleIconSizeWN8)), common.AftermathLogo(ratingColors.Background, common.TinyLogoOptions()), diff --git a/internal/stats/render/themes/registry.go b/internal/stats/render/themes/registry.go new file mode 100644 index 00000000..c1e38170 --- /dev/null +++ b/internal/stats/render/themes/registry.go @@ -0,0 +1,27 @@ +package themes + +import ( + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/aftermath/internal/stats/render/themes/spring2026" +) + +var registry = map[string]func() common.Theme{ + "default": common.DefaultTheme, + "spring2026": spring2026.Theme, +} + +func GetTheme(id string) (common.Theme, bool) { + fn, ok := registry[id] + if !ok { + return common.Theme{}, false + } + return fn(), true +} + +func AvailableThemes() []string { + var ids []string + for id := range registry { + ids = append(ids, id) + } + return ids +} diff --git a/internal/stats/render/themes/spring2026/assets.go b/internal/stats/render/themes/spring2026/assets.go new file mode 100644 index 00000000..3612e543 --- /dev/null +++ b/internal/stats/render/themes/spring2026/assets.go @@ -0,0 +1,54 @@ +package spring2026 + +import ( + "bytes" + "embed" + "image" + _ "image/jpeg" + _ "image/png" + "path" + "strings" + + "github.com/nao1215/imaging" +) + +//go:embed assets/background.jpg +var backgroundBytes []byte + +//go:embed assets/petals/processed +var petalsFS embed.FS + +var ( + backgroundImage image.Image + processedPetals []image.Image +) + +func init() { + var err error + backgroundImage, _, err = image.Decode(bytes.NewReader(backgroundBytes)) + if err != nil { + panic("spring2026: failed to decode background: " + err.Error()) + } + backgroundImage = imaging.Blur(backgroundImage, 3) + backgroundBytes = nil + + entries, err := petalsFS.ReadDir("assets/petals/processed") + if err != nil { + panic("spring2026: failed to read assets/petals/processed: " + err.Error()) + } + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".png") { + continue + } + data, err := petalsFS.ReadFile(path.Join("assets/petals/processed", name)) + if err != nil { + panic("spring2026: failed to read " + name + ": " + err.Error()) + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + panic("spring2026: failed to decode " + name + ": " + err.Error()) + } + processedPetals = append(processedPetals, img) + } +} diff --git a/internal/stats/render/themes/spring2026/assets/background.jpg b/internal/stats/render/themes/spring2026/assets/background.jpg new file mode 100644 index 00000000..65673ed3 Binary files /dev/null and b/internal/stats/render/themes/spring2026/assets/background.jpg differ diff --git a/internal/stats/render/themes/spring2026/assets/gen.go b/internal/stats/render/themes/spring2026/assets/gen.go new file mode 100644 index 00000000..2601dd36 --- /dev/null +++ b/internal/stats/render/themes/spring2026/assets/gen.go @@ -0,0 +1,3 @@ +package assets + +//go:generate go run ./generate.go diff --git a/internal/stats/render/themes/spring2026/assets/generate.go b/internal/stats/render/themes/spring2026/assets/generate.go new file mode 100644 index 00000000..35212e04 --- /dev/null +++ b/internal/stats/render/themes/spring2026/assets/generate.go @@ -0,0 +1,170 @@ +//go:build ignore + +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "strings" + + "github.com/nao1215/imaging" +) + +const ( + baseSize = 14 + padding = 10 + blurLength = 10 + gaussSigma = 2.0 + rotations = 8 +) + +var scales = []float64{0.5, 0.75, 1.0, 1.5} + +type tintPreset struct { + color color.NRGBA + opacity float64 +} + +var tints = []tintPreset{ + {color.NRGBA{255, 180, 200, 255}, 0.35}, // light pink + {color.NRGBA{200, 120, 150, 255}, 0.40}, // muted rose + {color.NRGBA{140, 70, 100, 255}, 0.45}, // dark plum +} + +func main() { + sourceDir := filepath.Join("petals", "source") + outDir := filepath.Join("petals", "processed") + + os.RemoveAll(outDir) + if err := os.MkdirAll(outDir, 0o755); err != nil { + panic(err) + } + + entries, err := os.ReadDir(sourceDir) + if err != nil { + panic(err) + } + + idx := 0 + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".png") { + continue + } + src, err := imaging.Open(filepath.Join(sourceDir, name)) + if err != nil { + panic(fmt.Sprintf("failed to open %s: %v", name, err)) + } + + small := imaging.Resize(src, baseSize, 0, imaging.Lanczos) + + for _, tint := range tints { + tinted := tintImage(small, tint.color, tint.opacity) + canvasSize := baseSize + padding*2 + + for v := range rotations { + rotAngle := float64(v) * (360.0 / float64(rotations)) + blurAngle := rotAngle + 30 + + canvas := imaging.New(canvasSize, canvasSize, color.Transparent) + rotated := imaging.Rotate(tinted, rotAngle, color.Transparent) + canvas = imaging.PasteCenter(canvas, rotated) + canvas = motionBlur(canvas, blurAngle, blurLength) + canvas = imaging.Blur(canvas, gaussSigma) + canvas = trimAlpha(canvas, 2) + + for _, scale := range scales { + var scaled image.Image = canvas + if scale != 1.0 { + sw := max(int(float64(canvas.Bounds().Dx())*scale), 1) + scaled = imaging.Resize(canvas, sw, 0, imaging.Linear) + } + + outPath := filepath.Join(outDir, fmt.Sprintf("petal_%03d.png", idx)) + f, err := os.Create(outPath) + if err != nil { + panic(err) + } + if err := png.Encode(f, scaled); err != nil { + f.Close() + panic(err) + } + f.Close() + idx++ + } + } + } + } + fmt.Printf("generated %d processed petal images\n", idx) +} + +// trimAlpha crops transparent borders, keeping at least minPad pixels of padding. +func trimAlpha(img *image.NRGBA, minPad int) *image.NRGBA { + b := img.Bounds() + minX, minY, maxX, maxY := b.Max.X, b.Max.Y, b.Min.X, b.Min.Y + + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if img.NRGBAAt(x, y).A > 0 { + minX = min(minX, x) + minY = min(minY, y) + maxX = max(maxX, x+1) + maxY = max(maxY, y+1) + } + } + } + if maxX <= minX || maxY <= minY { + return img + } + + minX = max(minX-minPad, b.Min.X) + minY = max(minY-minPad, b.Min.Y) + maxX = min(maxX+minPad, b.Max.X) + maxY = min(maxY+minPad, b.Max.Y) + + return imaging.Crop(img, image.Rect(minX, minY, maxX, maxY)) +} + +func tintImage(img image.Image, tint color.NRGBA, opacity float64) *image.NRGBA { + b := img.Bounds() + out := imaging.New(b.Dx(), b.Dy(), color.Transparent) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r0, g0, b0, a0 := img.At(x, y).RGBA() + if a0 == 0 { + continue + } + r := lerp(float64(r0>>8), float64(tint.R), opacity) + g := lerp(float64(g0>>8), float64(tint.G), opacity) + bl := lerp(float64(b0>>8), float64(tint.B), opacity) + out.SetNRGBA(x-b.Min.X, y-b.Min.Y, color.NRGBA{uint8(r), uint8(g), uint8(bl), uint8(a0 >> 8)}) + } + } + return out +} + +func lerp(a, b, t float64) float64 { + return a*(1-t) + b*t +} + +func motionBlur(img image.Image, angleDeg float64, length int) *image.NRGBA { + w := img.Bounds().Dx() + h := img.Bounds().Dy() + angleRad := angleDeg * math.Pi / 180.0 + dx := math.Cos(angleRad) + dy := math.Sin(angleRad) + + result := imaging.New(w, h, color.Transparent) + for i := range length { + t := float64(i)/float64(length-1) - 0.5 + ox := int(math.Round(t * float64(length) * dx)) + oy := int(math.Round(t * float64(length) * dy)) + result = imaging.Overlay(result, img, image.Pt(ox, oy), 1.0/float64(length)) + } + return result +} diff --git a/internal/stats/render/themes/spring2026/assets/petals/.gitignore b/internal/stats/render/themes/spring2026/assets/petals/.gitignore new file mode 100644 index 00000000..12a1aa95 --- /dev/null +++ b/internal/stats/render/themes/spring2026/assets/petals/.gitignore @@ -0,0 +1 @@ +processed/ \ No newline at end of file diff --git a/internal/stats/render/themes/spring2026/assets/petals/source/petal.png b/internal/stats/render/themes/spring2026/assets/petals/source/petal.png new file mode 100644 index 00000000..311d578b Binary files /dev/null and b/internal/stats/render/themes/spring2026/assets/petals/source/petal.png differ diff --git a/internal/stats/render/themes/spring2026/overlays.go b/internal/stats/render/themes/spring2026/overlays.go new file mode 100644 index 00000000..a168e6ac --- /dev/null +++ b/internal/stats/render/themes/spring2026/overlays.go @@ -0,0 +1,209 @@ +package spring2026 + +import ( + "image" + "image/color" + "image/draw" + "math" + "math/rand" + + "github.com/nao1215/imaging" +) + +func makeForegroundOverlay(petals []image.Image) func(image.Image, image.Rectangle, int) image.Image { + nrgbaPetals := make([]*image.NRGBA, len(petals)) + for i, p := range petals { + if n, ok := p.(*image.NRGBA); ok { + nrgbaPetals[i] = n + } else { + b := p.Bounds() + n := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(n, n.Bounds(), p, b.Min, draw.Src) + nrgbaPetals[i] = n + } + } + + return func(rendered image.Image, frame image.Rectangle, seed int) image.Image { + if len(nrgbaPetals) == 0 { + return rendered + } + + rng := rand.New(rand.NewSource(int64(seed))) + + w := frame.Dx() + h := frame.Dy() + overlay := image.NewNRGBA(image.Rect(0, 0, w, h)) + + band := min(w, h) / 4 + perimeter := 2 * (w + h) + basePetals := float64(perimeter) * 0.024 + petalCount := max(10, int(basePetals*(0.85+rng.Float64()*0.30))) + + for range petalCount { + petal := nrgbaPetals[rng.Intn(len(nrgbaPetals))] + opacity := 0.35 + rng.Float64()*0.45 + + sw := petal.Bounds().Dx() + sh := petal.Bounds().Dy() + + depth := int(float64(band) * rng.Float64() * rng.Float64()) + + var x, y int + edge := rng.Intn(4) + switch edge { + case 0: // top + x = rng.Intn(w) - sw/2 + y = depth + case 1: // right + x = w - depth - sw + y = rng.Intn(h) - sh/2 + case 2: // bottom + x = rng.Intn(w) - sw/2 + y = h - depth - sh + case 3: // left + x = depth + y = rng.Intn(h) - sh/2 + } + + x = clamp(x, 0, w-sw) + y = clamp(y, 0, h-sh) + + blitNRGBA(overlay, petal, x, y, opacity) + } + + dst := toNRGBA(rendered) + compositeOver(dst, overlay) + return dst + } +} + +// blitNRGBA alpha-blends src onto dst at (ox, oy) with an extra opacity multiplier. +func blitNRGBA(dst *image.NRGBA, src *image.NRGBA, ox, oy int, opacity float64) { + sb := src.Bounds() + dstStride := dst.Stride + srcStride := src.Stride + + for sy := sb.Min.Y; sy < sb.Max.Y; sy++ { + dy := oy + sy - sb.Min.Y + if dy < 0 || dy >= dst.Bounds().Dy() { + continue + } + srcRow := sy * srcStride + dstRow := dy * dstStride + for sx := sb.Min.X; sx < sb.Max.X; sx++ { + dx := ox + sx - sb.Min.X + if dx < 0 || dx >= dst.Bounds().Dx() { + continue + } + si := srcRow + sx*4 + sa := float64(src.Pix[si+3]) / 255.0 * opacity + if sa < 1.0/255.0 { + continue + } + di := dstRow + dx*4 + sr := float64(src.Pix[si]) + sg := float64(src.Pix[si+1]) + sb := float64(src.Pix[si+2]) + + da := float64(dst.Pix[di+3]) / 255.0 + outA := sa + da*(1-sa) + if outA > 0 { + dst.Pix[di] = uint8((sr*sa + float64(dst.Pix[di])*(da*(1-sa))) / outA) + dst.Pix[di+1] = uint8((sg*sa + float64(dst.Pix[di+1])*(da*(1-sa))) / outA) + dst.Pix[di+2] = uint8((sb*sa + float64(dst.Pix[di+2])*(da*(1-sa))) / outA) + dst.Pix[di+3] = uint8(outA * 255) + } + } + } +} + +// compositeOver alpha-composites src over dst, mutating dst in place. +func compositeOver(dst *image.NRGBA, src *image.NRGBA) { + db := dst.Bounds() + sb := src.Bounds() + h := min(db.Dy(), sb.Dy()) + w := min(db.Dx(), sb.Dx()) + + for y := range h { + dstOff := y * dst.Stride + srcOff := y * src.Stride + for x := range w { + si := srcOff + x*4 + sa := src.Pix[si+3] + if sa == 0 { + continue + } + di := dstOff + x*4 + if sa == 255 { + dst.Pix[di] = src.Pix[si] + dst.Pix[di+1] = src.Pix[si+1] + dst.Pix[di+2] = src.Pix[si+2] + dst.Pix[di+3] = 255 + continue + } + srcA := float64(sa) / 255.0 + dstA := float64(dst.Pix[di+3]) / 255.0 + outA := srcA + dstA*(1-srcA) + if outA > 0 { + inv := dstA * (1 - srcA) + dst.Pix[di] = uint8((float64(src.Pix[si])*srcA + float64(dst.Pix[di])*inv) / outA) + dst.Pix[di+1] = uint8((float64(src.Pix[si+1])*srcA + float64(dst.Pix[di+1])*inv) / outA) + dst.Pix[di+2] = uint8((float64(src.Pix[si+2])*srcA + float64(dst.Pix[di+2])*inv) / outA) + dst.Pix[di+3] = uint8(outA * 255) + } + } + } +} + +func toNRGBA(img image.Image) *image.NRGBA { + if n, ok := img.(*image.NRGBA); ok { + cp := image.NewNRGBA(n.Bounds()) + copy(cp.Pix, n.Pix) + return cp + } + b := img.Bounds() + dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return dst +} + +func makeBackgroundOverlay() func(image.Rectangle, int) image.Image { + return func(bounds image.Rectangle, _ int) image.Image { + w := bounds.Dx() + h := bounds.Dy() + + // quarter-res vignette, upscaled for smoothness + sw, sh := max(w/4, 1), max(h/4, 1) + img := image.NewNRGBA(image.Rect(0, 0, sw, sh)) + cx := float64(sw) / 2 + cy := float64(sh) / 2 + maxDist := math.Sqrt(cx*cx + cy*cy) + + for y := range sh { + for x := range sw { + dx := float64(x) - cx + dy := float64(y) - cy + dist := math.Sqrt(dx*dx+dy*dy) / maxDist + + if dist > 0.35 { + alpha := (dist - 0.35) / 0.65 + alpha = alpha * alpha + alpha = min(alpha*0.55, 1.0) + img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, uint8(alpha * 255)}) + } + } + } + + return imaging.Resize(img, w, h, imaging.Linear) + } +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/internal/stats/render/themes/spring2026/theme.go b/internal/stats/render/themes/spring2026/theme.go new file mode 100644 index 00000000..8517119a --- /dev/null +++ b/internal/stats/render/themes/spring2026/theme.go @@ -0,0 +1,67 @@ +package spring2026 + +import ( + "image/color" + + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/facepaint/style" +) + +var ( + cardColor = color.NRGBA{30, 10, 20, 180} + clanTagColor = color.NRGBA{60, 25, 40, 120} + textPrimary = color.NRGBA{255, 240, 245, 255} + textSecondary = color.NRGBA{220, 190, 200, 255} + textAlt = color.NRGBA{170, 140, 155, 255} + footerCardColor = color.NRGBA{30, 10, 20, 180} +) + +func Theme() common.Theme { + return common.Theme{ + Background: backgroundImage, + Frame: style.NewStyle(func(s *style.Style) { + s.PaddingLeft = 15 + s.PaddingRight = 15 + s.PaddingTop = 15 + s.PaddingBottom = 15 + }), + Card: style.NewStyle( + style.SetBorderRadius(common.BorderRadiusLG), + func(s *style.Style) { + s.BackgroundColor = cardColor + s.BlurBackground = 20.0 + }, + ), + ClanTag: style.NewStyle( + style.SetBorderRadius(common.BorderRadiusMD), + func(s *style.Style) { + s.BackgroundColor = clanTagColor + }, + ), + Footer: style.NewStyle( + style.SetBorderRadius(common.BorderRadiusSM), + func(s *style.Style) { + s.BackgroundColor = footerCardColor + s.Color = textAlt + s.Font = common.FontSmall() + }, + ), + TextPrimary: func() style.StyleOptions { + return style.NewStyle(func(s *style.Style) { + s.Color = textPrimary + }) + }, + TextSecondary: func() style.StyleOptions { + return style.NewStyle(func(s *style.Style) { + s.Color = textSecondary + }) + }, + TextAlt: func() style.StyleOptions { + return style.NewStyle(func(s *style.Style) { + s.Color = textAlt + }) + }, + BackgroundOverlay: makeBackgroundOverlay(), + ForegroundOverlay: makeForegroundOverlay(processedPetals), + } +} diff --git a/render_test.go b/render_test.go index 46e7ee75..88c0044c 100644 --- a/render_test.go +++ b/render_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "io" "os" "slices" "testing" @@ -36,12 +37,21 @@ func init() { zerolog.SetGlobalLevel(level) } +func saveTestImage(t *testing.T, dir, filename string, encode func(io.Writer) error) { + t.Helper() + assert.NoError(t, os.MkdirAll("tmp/"+dir, 0o755), "failed to create directory") + f, err := os.Create("tmp/" + dir + "/" + filename) + assert.NoError(t, err, "failed to create a file") + defer f.Close() + assert.NoError(t, encode(f), "failed to encode image") +} + func TestRenderReplay(t *testing.T) { is := is.New(t) env.LoadTestEnv(t) db := tests.StaticTestingDatabase() - wg, _ := wargamingClientsFromEnv() + wg, _ := wargamingClientsFromEnv(nil) printer, err := localization.NewPrinter("stats", language.English) is.NoErr(err) @@ -81,7 +91,8 @@ func TestRenderReplay(t *testing.T) { assert.NoError(t, err, "failed to render a replay image") assert.NotNil(t, image, "image is nil") - out := "tmp/render_test_" + name + ".png" + out := "tmp/replay/" + name + ".png" + assert.NoError(t, os.MkdirAll("tmp/replay", 0o755), "failed to create directory") f, err := os.Create(out) assert.NoError(t, err, "failed to create a file") defer f.Close() diff --git a/render_v2_test.go b/render_v2_test.go index 653f47bf..2902561d 100644 --- a/render_v2_test.go +++ b/render_v2_test.go @@ -2,13 +2,12 @@ package main import ( "context" - "os" "testing" "time" common "github.com/cufee/aftermath/internal/stats/client/common" - options "github.com/cufee/aftermath/internal/stats/client/common" client "github.com/cufee/aftermath/internal/stats/client/v2" + "github.com/cufee/aftermath/internal/stats/render/themes/spring2026" "github.com/cufee/aftermath/tests" "github.com/cufee/aftermath/tests/env" "github.com/stretchr/testify/assert" @@ -25,65 +24,35 @@ func TestRenderPeriodV2(t *testing.T) { image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), common.WithWN8()) assert.NoError(t, err, "failed to render a period image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_period_v2_full_small.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "period", "full_small.png", image.PNG) }) t.Run("render period image for large nickname", func(t *testing.T) { image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_period_v2_full_large.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "period", "full_large.png", image.PNG) }) t.Run("render period image with large name no highlights", func(t *testing.T) { image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleIDs("0"), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_period_v2_single_large.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "period", "single_large.png", image.PNG) }) t.Run("render period image with small name and no highlights", func(t *testing.T) { image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleIDs("0"), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_period_v2_single_small.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "period", "single_small.png", image.PNG) }) t.Run("render period image with no vehicles", func(t *testing.T) { image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleIDs("-"), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_period_v2_none_small.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "period", "none_small.png", image.PNG) }) } @@ -92,80 +61,94 @@ func TestRenderSessionV2(t *testing.T) { stats := client.NewClient(tests.StaticTestingFetch(), tests.StaticTestingDatabase(), nil, language.English) t.Run("render session image for small nickname", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), options.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_session_full_small.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "session", "full_small.png", image.PNG) }) t.Run("render session image for large nickname", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_session_full_large.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "session", "full_large.png", image.PNG) }) t.Run("render session image for large nickname and no vehicles", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithVehicleIDs("-"), options.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleIDs("-"), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_session_0_large.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "session", "0_large.png", image.PNG) }) t.Run("render session image for large nickname and 1 vehicle", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithVehicleIDs("1"), options.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleIDs("1"), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") - - f, err := os.Create("tmp/render_test_session_1_large.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() - - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + saveTestImage(t, "session", "1_large.png", image.PNG) }) t.Run("render session image for large nickname and 3 vehicles", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithVehicleIDs("1", "2", "3"), options.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleIDs("1", "2", "3"), common.WithWN8()) + assert.NoError(t, err, "failed to render a session image") + assert.NotNil(t, image, "image is nil") + saveTestImage(t, "session", "3_large.png", image.PNG) + }) + + t.Run("render session image for large nickname and 5 vehicles", func(t *testing.T) { + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleIDs("1", "2", "3", "4", "5"), common.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") + saveTestImage(t, "session", "5_large.png", image.PNG) + }) +} + +func TestRenderSessionV2Spring2026(t *testing.T) { + env.LoadTestEnv(t) + stats := client.NewClient(tests.StaticTestingFetch(), tests.StaticTestingDatabase(), nil, language.English) - f, err := os.Create("tmp/render_test_session_3_large.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() + theme := spring2026.Theme() - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + t.Run("render session image for large nickname", func(t *testing.T) { + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), + common.WithTheme(theme), common.WithWN8(), + ) + assert.NoError(t, err, "failed to render a session image") + assert.NotNil(t, image, "image is nil") + saveTestImage(t, "session-spring2026", "full_large.png", image.PNG) }) - t.Run("render session image for large nickname and 5 vehicles", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithVehicleIDs("1", "2", "3", "4", "5"), options.WithWN8()) + t.Run("render session image for small nickname", func(t *testing.T) { + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), + common.WithTheme(theme), common.WithWN8(), + ) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") + saveTestImage(t, "session-spring2026", "full_small.png", image.PNG) + }) +} - f, err := os.Create("tmp/render_test_session_5_large.png") - assert.NoError(t, err, "failed to create a file") - defer f.Close() +func TestRenderPeriodV2Spring2026(t *testing.T) { + env.LoadTestEnv(t) + stats := client.NewClient(tests.StaticTestingFetch(), tests.StaticTestingDatabase(), nil, language.English) + + theme := spring2026.Theme() - err = image.PNG(f) - assert.NoError(t, err, "failed to encode a png image") + t.Run("render period image for large nickname", func(t *testing.T) { + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), + common.WithTheme(theme), common.WithWN8(), + ) + assert.NoError(t, err, "failed to render a period image") + assert.NotNil(t, image, "image is nil") + saveTestImage(t, "period-spring2026", "full_large.png", image.PNG) + }) + + t.Run("render period image for small nickname", func(t *testing.T) { + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), + common.WithTheme(theme), common.WithWN8(), + ) + assert.NoError(t, err, "failed to render a period image") + assert.NotNil(t, image, "image is nil") + saveTestImage(t, "period-spring2026", "full_small.png", image.PNG) }) } diff --git a/static/localization/en/cta.yaml b/static/localization/en/cta.yaml index 6fd9d30c..fafb76c4 100644 --- a/static/localization/en/cta.yaml +++ b/static/localization/en/cta.yaml @@ -29,6 +29,11 @@ cta_command_links_verify_body: |- This will make your custom background images visible to other users and unlock the `/my` command! cta_command_links_verify_head: "Verify your Blitz account!" +cta_command_theme_body: |- + You can customize the look of your stats images with `/theme`! + + Try out our seasonal themes for a fresh new look. +cta_command_theme_head: "Make your stats stand out!" cta_command_replay_body: |- You can upload a replay using `/replay` to get an overview of your battle with stats for every player! diff --git a/static/localization/en/discord.yaml b/static/localization/en/discord.yaml index e649cfd1..f7133d5c 100644 --- a/static/localization/en/discord.yaml +++ b/static/localization/en/discord.yaml @@ -239,6 +239,23 @@ stats_refresh_interaction_error_expired: "This refresh button has expired. Pleas wargaming_error_outage: "It looks like Wargaming is having some temporary issues. Please try again in a few seconds." wargaming_error_outage_short: "It looks like Wargaming is having some issues. Please try again." wargaming_error_private_account: "This account is marked private by Wargaming and no stats are available for it at this time." +command_theme_name: "theme" +command_theme_description: "Change the visual theme of your stats images" +command_theme_option_select_name: "select" +command_theme_option_select_description: "Pick a theme to apply to your stats" +command_theme_option_select_choice_default_name: "Default" +command_theme_option_select_choice_spring2026_name: "Sakura Flutter" +command_theme_option_clear_name: "clear" +command_theme_option_clear_description: "Remove your current theme and go back to default" +command_theme_set_success_fmt: "Your theme has been updated to **%s**! It will be applied to your next stats image." +command_theme_reset_success: "Your theme has been reset to default." +command_theme_current_fmt: |- + **Available Themes** + %s + + Use `/theme select` to change or `/theme clear` to reset. +command_theme_not_found: "This theme does not exist. Please select a theme from the available options." +theme_highlighted_hint: "This image uses a seasonal theme! Use `/theme` to change or disable it." automod_unverified_user_message_deleted_fmt: |- Hey <@%s>, your message was removed because your account has not been verified yet. To start chatting, please use any Aftermath command first (try `/help`). diff --git a/static/localization/pl/cta.yaml b/static/localization/pl/cta.yaml index 0a11f90f..6ec1a173 100644 --- a/static/localization/pl/cta.yaml +++ b/static/localization/pl/cta.yaml @@ -29,6 +29,11 @@ cta_command_links_verify_body: |- To sprawi, że Twoje niestandardowe obrazy tła będą widoczne dla innych użytkowników i odblokuje polecenie `/my`! cta_command_links_verify_head: "Zweryfikuj swoje konto Blitz!" +cta_command_theme_body: |- + Możesz dostosować wygląd swoich statystyk za pomocą `/theme`! + + Wypróbuj nasze sezonowe motywy, aby nadać swoim statystykom nowy wygląd. +cta_command_theme_head: "Wyróżnij swoje statystyki!" cta_command_replay_body: |- Możesz przesłać powtórkę za pomocą `/replay`, aby uzyskać przegląd swojej bitwy ze statystykami każdego gracza! diff --git a/static/localization/pl/discord.yaml b/static/localization/pl/discord.yaml index 0826b0d7..8866cc00 100644 --- a/static/localization/pl/discord.yaml +++ b/static/localization/pl/discord.yaml @@ -239,6 +239,23 @@ stats_refresh_interaction_error_expired: "Ten przycisk odświeżania wygasł. Pr wargaming_error_outage: "Wygląda na to, że Wargaming ma chwilowe problemy. Spróbuj ponownie za kilka sekund." wargaming_error_outage_short: "Wygląda na to, że Wargaming ma pewne problemy. Spróbuj ponownie" wargaming_error_private_account: "To konto zostało oznaczone przez Wargaming jako prywatne i w chwili obecnej nie są dla niego dostępne żadne statystyki." +command_theme_name: "motyw" +command_theme_description: "Zmień motyw wizualny swoich obrazów statystyk" +command_theme_option_select_name: "wybierz" +command_theme_option_select_description: "Wybierz motyw do zastosowania w statystykach" +command_theme_option_select_choice_default_name: "Domyślny" +command_theme_option_select_choice_spring2026_name: "Sakura Flutter" +command_theme_option_clear_name: "wyczyść" +command_theme_option_clear_description: "Usuń aktualny motyw i wróć do domyślnego" +command_theme_set_success_fmt: "Twój motyw został zmieniony na **%s**! Zostanie zastosowany do następnego obrazu statystyk." +command_theme_reset_success: "Twój motyw został przywrócony do domyślnego." +command_theme_current_fmt: |- + **Dostępne Motywy** + %s + + Użyj `/theme select`, aby zmienić lub `/theme clear`, aby zresetować. +command_theme_not_found: "Ten motyw nie istnieje. Wybierz motyw z dostępnych opcji." +theme_highlighted_hint: "Ten obraz używa sezonowego motywu! Użyj `/theme`, aby go zmienić lub wyłączyć." automod_unverified_user_message_deleted_fmt: |- Hej <@%s>, Twoja wiadomość została usunięta, ponieważ Twoje konto nie zostało jeszcze zweryfikowane. Aby rozpocząć czat, użyj najpierw dowolnej komendy Aftermath (spróbuj `/help`). diff --git a/static/localization/pt-BR/cta.yaml b/static/localization/pt-BR/cta.yaml index 19cc8c77..49c54b36 100644 --- a/static/localization/pt-BR/cta.yaml +++ b/static/localization/pt-BR/cta.yaml @@ -29,6 +29,11 @@ cta_command_links_verify_body: |- Isso fará com que outros usuários possam ver o seu fundo e desbloqueará o comando `/my`! cta_command_links_verify_head: "Verifique sua conta Blitz!" +cta_command_theme_body: |- + Você pode personalizar a aparência das suas estatísticas com `/theme`! + + Experimente nossos temas sazonais para um visual renovado. +cta_command_theme_head: "Destaque suas estatísticas!" cta_command_replay_body: |- Você pode enviar um replay usando `/replay` para obter uma visão geral da sua batalha com estatísticas para cada jogador! diff --git a/static/localization/pt-BR/discord.yaml b/static/localization/pt-BR/discord.yaml index a816098e..41c65e18 100644 --- a/static/localization/pt-BR/discord.yaml +++ b/static/localization/pt-BR/discord.yaml @@ -239,6 +239,23 @@ stats_refresh_interaction_error_expired: "Este botão de atualização expirou. wargaming_error_outage: "Parece que a Wargaming está com problemas temporários. Tente novamente em alguns segundos." wargaming_error_outage_short: "Parece que a Wargaming está com problemas. Tente novamente." wargaming_error_private_account: "Esta conta foi marcada como privada pela Wargaming e não há estatísticas disponíveis para ela no momento." +command_theme_name: "tema" +command_theme_description: "Altere o tema visual das suas imagens de estatísticas" +command_theme_option_select_name: "selecionar" +command_theme_option_select_description: "Escolha um tema para aplicar às suas estatísticas" +command_theme_option_select_choice_default_name: "Padrão" +command_theme_option_select_choice_spring2026_name: "Sakura Flutter" +command_theme_option_clear_name: "limpar" +command_theme_option_clear_description: "Remova seu tema atual e volte ao padrão" +command_theme_set_success_fmt: "Seu tema foi atualizado para **%s**! Ele será aplicado na sua próxima imagem de estatísticas." +command_theme_reset_success: "Seu tema foi redefinido para o padrão." +command_theme_current_fmt: |- + **Temas Disponíveis** + %s + + Use `/theme select` para alterar ou `/theme clear` para redefinir. +command_theme_not_found: "Este tema não existe. Selecione um tema entre as opções disponíveis." +theme_highlighted_hint: "Esta imagem usa um tema sazonal! Use `/theme` para alterar ou desativar." automod_unverified_user_message_deleted_fmt: |- Olá <@%s>, sua mensagem foi removida porque sua conta ainda não foi verificada. Para começar a conversar, use qualquer comando do Aftermath primeiro (tente `/help`). diff --git a/tests/static_fetch.go b/tests/static_fetch.go index 6b567bfc..efbca5cd 100644 --- a/tests/static_fetch.go +++ b/tests/static_fetch.go @@ -53,6 +53,40 @@ func (c *staticTestingFetch) CurrentStats(ctx context.Context, id string, opts . continue } f := DefaultVehicleStatsFrameBig1(fmt.Sprint(id)) + // scale some vehicles so they exceed the minBattles threshold in + // GetHighlightedVehicles and win different highlight categories + switch id { + case 0: // high battles leader + f.Battles *= 3 + f.BattlesWon *= 3 + f.BattlesSurvived *= 3 + f.DamageDealt *= 3 + f.DamageReceived *= 3 + f.ShotsHit *= 3 + f.ShotsFired *= 3 + f.Frags *= 3 + f.EnemiesSpotted *= 3 + case 1: // high avg damage leader + f.Battles *= 2 + f.BattlesWon *= 2 + f.BattlesSurvived *= 2 + f.DamageDealt *= 6 + f.DamageReceived *= 2 + f.ShotsHit *= 2 + f.ShotsFired *= 2 + f.Frags *= 2 + f.EnemiesSpotted *= 2 + case 2: // moderate all-around, eligible for WN8 highlight + f.Battles *= 2 + f.BattlesWon *= 2 + f.BattlesSurvived *= 2 + f.DamageDealt *= 2 + f.DamageReceived *= 2 + f.ShotsHit *= 2 + f.ShotsFired *= 2 + f.Frags *= 2 + f.EnemiesSpotted *= 2 + } f.SetWN8(9999) vehicles[fmt.Sprint(id)] = f }