Files
velox-bot/internal/rankcard/rankcard.go
T

179 lines
3.6 KiB
Go

package rankcard
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"io"
"math"
"net/http"
"time"
"github.com/fogleman/gg"
"golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/opentype"
)
type Data struct {
Name string
Level int
XP int64
Percent float64 // 0..1 progress towards next level
Avatar string // URL
}
func RenderPNG(ctx context.Context, d Data) ([]byte, error) {
const (
w = 900
h = 300
px = 30
avatarSize = 150
barX = 30
barY = 220
barW = 650
barH = 40
)
percent := clamp01(d.Percent)
dc := gg.NewContext(w, h)
dc.SetHexColor("#141414")
dc.Clear()
// Right white polygon
dc.SetHexColor("#FFFFFF")
dc.NewSubPath()
dc.MoveTo(600, 0)
dc.LineTo(750, 300)
dc.LineTo(900, 300)
dc.LineTo(900, 0)
dc.ClosePath()
dc.Fill()
// Avatar (circle clip)
if img, err := fetchImage(ctx, d.Avatar); err == nil && img != nil {
img = resizeImage(img, avatarSize, avatarSize)
dc.Push()
dc.DrawCircle(px+avatarSize/2, px+avatarSize/2, avatarSize/2)
dc.Clip()
dc.DrawImage(img, px, px)
dc.Pop()
// Defensive: ensure no clip leaks past avatar drawing.
dc.ResetClip()
}
// Progress bar background
dc.SetHexColor("#FFFFFF")
dc.DrawRoundedRectangle(barX, barY, barW, barH, 20)
dc.Fill()
// Progress bar fill
fillW := barW * percent
if fillW > 0 {
dc.SetHexColor("#282828")
// Keep radius from looking weird on small widths
r := math.Min(20, fillW/2)
dc.DrawRoundedRectangle(barX, barY, fillW, barH, r)
dc.Fill()
}
// Text
titleFace, smallFace, err := loadFontFaces()
if err != nil {
return nil, err
}
dc.SetHexColor("#FFFFFF")
dc.SetFontFace(titleFace)
dc.DrawStringAnchored(d.Name, 200, 60, 0, 0.5)
dc.SetLineWidth(2)
dc.DrawLine(200, 100, 550, 100)
dc.Stroke()
dc.SetFontFace(smallFace)
dc.DrawStringAnchored(
fmt.Sprintf("Level - %d | XP - %d | %d%%", d.Level, d.XP, int64(math.Round(percent*100))),
200,
145,
0,
0.5,
)
// Watermark to verify latest render is running
dc.SetFontFace(smallFace)
dc.SetHexColor("#FFCC00")
dc.DrawStringAnchored("velox-go rankcard", 890, 290, 1, 1)
var buf bytes.Buffer
if err := png.Encode(&buf, dc.Image()); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func loadFontFaces() (title font.Face, small font.Face, err error) {
f, err := opentype.Parse(goregular.TTF)
if err != nil {
return nil, nil, err
}
title, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 40, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, nil, err
}
small, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 30, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, nil, err
}
return title, small, nil
}
func fetchImage(ctx context.Context, url string) (image.Image, error) {
if url == "" {
return nil, fmt.Errorf("empty avatar url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("avatar fetch status %d", resp.StatusCode)
}
img, _, err := image.Decode(resp.Body)
return img, err
}
func resizeImage(src image.Image, w, h int) image.Image {
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
return dst
}