feat: leveling system
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user