704 lines
22 KiB
Go
704 lines
22 KiB
Go
package scoresaber
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"io"
|
||
"io/ioutil"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.lxtend.com/qqbot/util"
|
||
"github.com/fogleman/gg"
|
||
"github.com/google/uuid"
|
||
"github.com/nfnt/resize"
|
||
"golang.org/x/image/font/opentype"
|
||
)
|
||
|
||
var (
|
||
imageCache = make(map[string]image.Image)
|
||
imageCacheLock sync.RWMutex
|
||
)
|
||
|
||
func init() {
|
||
util.AddCycleTask("bs50_image_cache_cleaner", 10*time.Minute, 10*time.Minute, func() {
|
||
imageCacheLock.Lock()
|
||
defer imageCacheLock.Unlock()
|
||
for k := range imageCache {
|
||
delete(imageCache, k)
|
||
}
|
||
})
|
||
}
|
||
|
||
func loadImageFromCache(path string) (image.Image, error) {
|
||
imageCacheLock.RLock()
|
||
if img, exists := imageCache[path]; exists {
|
||
imageCacheLock.RUnlock()
|
||
return img, nil
|
||
}
|
||
imageCacheLock.RUnlock()
|
||
|
||
img, err := gg.LoadImage(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
imageCacheLock.Lock()
|
||
imageCache[path] = img
|
||
imageCacheLock.Unlock()
|
||
|
||
return img, nil
|
||
}
|
||
|
||
// SongData 代表一首歌曲的数据
|
||
type SongData struct {
|
||
JacketPath string
|
||
Title string
|
||
Beatmapid string
|
||
Difficulty string
|
||
DifficultyColor color.Color
|
||
Stars float64
|
||
Accuracy float64
|
||
Days_ago string
|
||
UserMap_pp float64
|
||
IsBest30 bool
|
||
}
|
||
|
||
// PlayerData 代表玩家的整体数据
|
||
type PlayerData struct {
|
||
PlayerName string // 玩家昵称
|
||
PlayerID string // 玩家ID (可选)
|
||
AvatarPath string // 玩家头像路径 (可选)
|
||
PlayerCurrentPP float64 // 玩家总 UserMap_pp
|
||
B50Songs []SongData // B50 歌曲列表 (通常是按 UserMap_pp 降序排列)
|
||
PlayerRank int // 玩家排名
|
||
PlayerCountryRank int // 玩家国家排名
|
||
Country string // 玩家所在国家
|
||
}
|
||
|
||
// ScoreSaber用户信息结构体
|
||
type ScoreSaberUser struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
ProfilePicture string `json:"profilePicture"`
|
||
Country string `json:"country"`
|
||
PP float64 `json:"pp"`
|
||
Rank int `json:"rank"`
|
||
CountryRank int `json:"countryRank"`
|
||
}
|
||
|
||
// ScoreSaber 相关结构体
|
||
type ScoreSaberScore struct {
|
||
ID int64 `json:"id"`
|
||
LeaderboardPlayerInfo interface{} `json:"leaderboardPlayerInfo"`
|
||
Rank int `json:"rank"`
|
||
BaseScore int `json:"baseScore"`
|
||
ModifiedScore int `json:"modifiedScore"`
|
||
PP float64 `json:"pp"`
|
||
Weight float64 `json:"weight"`
|
||
Modifiers string `json:"modifiers"`
|
||
Multiplier float64 `json:"multiplier"`
|
||
BadCuts int `json:"badCuts"`
|
||
MissedNotes int `json:"missedNotes"`
|
||
MaxCombo int `json:"maxCombo"`
|
||
FullCombo bool `json:"fullCombo"`
|
||
HMD int `json:"hmd"`
|
||
TimeSet string `json:"timeSet"`
|
||
HasReplay bool `json:"hasReplay"`
|
||
DeviceHMD string `json:"deviceHmd"`
|
||
DeviceControllerLeft string `json:"deviceControllerLeft"`
|
||
DeviceControllerRight string `json:"deviceControllerRight"`
|
||
}
|
||
|
||
type ScoreSaberLeaderboard struct {
|
||
ID int64 `json:"id"`
|
||
SongHash string `json:"songHash"`
|
||
SongName string `json:"songName"`
|
||
SongSubName string `json:"songSubName"`
|
||
SongAuthorName string `json:"songAuthorName"`
|
||
LevelAuthorName string `json:"levelAuthorName"`
|
||
Difficulty struct {
|
||
LeaderboardID int `json:"leaderboardId"`
|
||
Difficulty int `json:"difficulty"`
|
||
GameMode string `json:"gameMode"`
|
||
DifficultyRaw string `json:"difficultyRaw"`
|
||
} `json:"difficulty"`
|
||
MaxScore int `json:"maxScore"`
|
||
CreatedDate string `json:"createdDate"`
|
||
RankedDate string `json:"rankedDate"`
|
||
QualifiedDate string `json:"qualifiedDate"`
|
||
LovedDate interface{} `json:"lovedDate"`
|
||
Ranked bool `json:"ranked"`
|
||
Qualified bool `json:"qualified"`
|
||
Loved bool `json:"loved"`
|
||
MaxPP int `json:"maxPP"`
|
||
Stars float64 `json:"stars"`
|
||
Plays int `json:"plays"`
|
||
DailyPlays int `json:"dailyPlays"`
|
||
PositiveModifiers bool `json:"positiveModifiers"`
|
||
PlayerScore interface{} `json:"playerScore"`
|
||
CoverImage string `json:"coverImage"`
|
||
Difficulties interface{} `json:"difficulties"`
|
||
}
|
||
|
||
type ScoreSaberPlayerScore struct {
|
||
Score ScoreSaberScore `json:"score"`
|
||
Leaderboard ScoreSaberLeaderboard `json:"leaderboard"`
|
||
}
|
||
|
||
type ScoreSaberResponse struct {
|
||
PlayerScores []ScoreSaberPlayerScore `json:"playerScores"`
|
||
Metadata struct {
|
||
Total int `json:"total"`
|
||
Page int `json:"page"`
|
||
ItemsPerPage int `json:"itemsPerPage"`
|
||
} `json:"metadata"`
|
||
}
|
||
|
||
// loadFont 加载字体文件的辅助函数
|
||
func loadFont(path string) (*opentype.Font, error) {
|
||
fontBytes, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
f, err := opentype.Parse(fontBytes)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return f, nil
|
||
}
|
||
|
||
// generateB50Image 生成 B50 图片
|
||
func generateB50Image(player PlayerData, outputPath string, fontPath string) error {
|
||
const (
|
||
imgWidth = 2000 // 增加画布宽度以适应四列
|
||
padding = 40.0 // 边距
|
||
headerHeight = 150.0 // 头部区域高度
|
||
songEntryHeight = 100.0 // 每首歌曲条目高度
|
||
songEntryPadding = 10.0 // 歌曲条目内边距
|
||
jacketSize = songEntryHeight - 2*songEntryPadding // 封面大小
|
||
songsPerRow = 4 // 改为4列
|
||
maxSongsPerColumn = 10 // 每列最多显示10首歌曲
|
||
columnGap = 25.0 // 列之间的间距
|
||
)
|
||
var imgHeight int
|
||
// 计算实际需要的画布高度
|
||
totalRows := (len(player.B50Songs) + songsPerRow - 1) / songsPerRow
|
||
calculatedHeight := float64(headerHeight + padding*2 + totalRows*(songEntryHeight+padding))
|
||
if calculatedHeight > float64(imgHeight) {
|
||
imgHeight = int(calculatedHeight) + 100 // 额外加一些空间
|
||
}
|
||
|
||
dc := gg.NewContext(imgWidth, imgHeight)
|
||
mainFont, err := loadFont(fontPath)
|
||
if err != nil {
|
||
return fmt.Errorf("加载主字体失败: %w", err)
|
||
}
|
||
|
||
yOffset := padding * 1.5 // 增加顶部间距
|
||
|
||
dc.SetRGBA(0, 0, 0, 0.5)
|
||
dc.DrawRoundedRectangle(padding, yOffset, float64(imgWidth)-padding*2, headerHeight, 15)
|
||
dc.Fill()
|
||
|
||
// 玩家头像 (可选)
|
||
avatarSpace := padding * 2 // 默认空间
|
||
if player.AvatarPath != "" {
|
||
avatarImg, err := loadImageFromCache(player.AvatarPath)
|
||
if err == nil {
|
||
avatarSize := headerHeight - padding
|
||
dc.DrawImageAnchored(avatarImg, int(padding*2+avatarSize/2), int(yOffset+headerHeight/2), 0.5, 0.5)
|
||
avatarSpace = padding*2 + avatarSize + padding
|
||
} else {
|
||
log.Printf("警告: 加载头像 %s 失败: %v", player.AvatarPath, err)
|
||
}
|
||
}
|
||
|
||
// 玩家昵称
|
||
faceTitle, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
|
||
Size: 54, // 增大字体
|
||
DPI: 72,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("创建标题字体失败: %w", err)
|
||
}
|
||
dc.SetFontFace(faceTitle)
|
||
dc.SetHexColor("#FFFFFF")
|
||
dc.DrawStringAnchored(player.PlayerName, avatarSpace, yOffset+headerHeight/4, 0.0, 0.5)
|
||
|
||
faceCountry, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
|
||
Size: 30,
|
||
DPI: 72,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("创建国家信息字体失败: %w", err)
|
||
}
|
||
dc.SetFontFace(faceCountry)
|
||
|
||
// 排名信息
|
||
rankInfo := fmt.Sprintf("全球排名: #%d | [%s] 排名: #%d", player.PlayerRank, player.Country, player.PlayerCountryRank)
|
||
dc.DrawStringAnchored(rankInfo, avatarSpace, yOffset+headerHeight*0.75, 0.0, 0.5)
|
||
|
||
// 总 UserMap_pp
|
||
faceUserMap_pp, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
|
||
Size: 42, // 增大字体
|
||
DPI: 72,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("创建评分字体失败: %w", err)
|
||
}
|
||
dc.SetFontFace(faceUserMap_pp)
|
||
dc.SetHexColor("#FFD700") // 金色
|
||
ppText := fmt.Sprintf("%.2f pp", player.PlayerCurrentPP)
|
||
dc.DrawStringAnchored(ppText, imgWidth-padding, yOffset+headerHeight/2, 1.0, 0.5)
|
||
|
||
// B30 / R10 平均
|
||
faceSubtitle, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
|
||
Size: 28,
|
||
DPI: 72,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("创建副标题字体失败: %w", err)
|
||
}
|
||
dc.SetFontFace(faceSubtitle)
|
||
dc.SetHexColor("#DDDDDD")
|
||
|
||
yOffset += headerHeight + padding*1.5 // 增加间距
|
||
|
||
// --- 5. 绘制列标题 ---
|
||
dc.SetFontFace(faceSubtitle)
|
||
dc.SetHexColor("#FFFFFF")
|
||
|
||
// 计算每列的宽度和间距
|
||
totalContentWidth := imgWidth - padding*2
|
||
songEntryWidth := (totalContentWidth - columnGap*(float64(songsPerRow)-1)) / float64(songsPerRow)
|
||
|
||
// 绘制标题栏
|
||
sectionNames := []string{"Best 30 (1-10)", "Best 30 (11-20)", "Best 30 (21-30)", "Recent 10"}
|
||
for i := 0; i < songsPerRow; i++ {
|
||
sectionX := padding + float64(i)*(songEntryWidth+columnGap) + songEntryWidth/2
|
||
dc.DrawStringAnchored(sectionNames[i], sectionX, yOffset, 0.5, 0.5)
|
||
}
|
||
|
||
yOffset += padding * 1.5
|
||
|
||
// --- 6. 为每个区域添加半透明背景 ---
|
||
for i := 0; i < songsPerRow; i++ {
|
||
sectionX := padding + float64(i)*(songEntryWidth+columnGap)
|
||
sectionHeight := float64(maxSongsPerColumn) * (songEntryHeight + padding)
|
||
dc.SetRGBA(0, 0, 0, 0.3)
|
||
dc.DrawRoundedRectangle(sectionX, yOffset, songEntryWidth, sectionHeight, 10)
|
||
dc.Fill()
|
||
}
|
||
|
||
// --- 7. 绘制歌曲列表 ---
|
||
faceSongTitle, err := opentype.NewFace(mainFont, &opentype.FaceOptions{Size: 24, DPI: 72})
|
||
if err != nil {
|
||
return fmt.Errorf("创建歌曲标题字体失败: %w", err)
|
||
}
|
||
faceSongDetail, err := opentype.NewFace(mainFont, &opentype.FaceOptions{Size: 20, DPI: 72})
|
||
if err != nil {
|
||
return fmt.Errorf("创建歌曲详情字体失败: %w", err)
|
||
}
|
||
faceSongUserMap_pp, err := opentype.NewFace(mainFont, &opentype.FaceOptions{Size: 28, DPI: 72})
|
||
if err != nil {
|
||
return fmt.Errorf("创建歌曲评分字体失败: %w", err)
|
||
}
|
||
// 计算每列的起始位置
|
||
columnStartX := make([]float64, songsPerRow)
|
||
for i := 0; i < songsPerRow; i++ {
|
||
columnStartX[i] = padding + float64(i)*(songEntryWidth+columnGap)
|
||
}
|
||
for i, song := range player.B50Songs {
|
||
if i >= 40 {
|
||
continue
|
||
}
|
||
var col, row int
|
||
if i < 30 {
|
||
col = i / 10
|
||
row = i % 10
|
||
} else {
|
||
col = 3
|
||
row = i - 30
|
||
}
|
||
startX := columnStartX[col]
|
||
startY := yOffset + float64(row)*(songEntryHeight+padding)
|
||
dc.SetRGBA(1, 1, 1, 0.1)
|
||
dc.DrawRoundedRectangle(startX+songEntryPadding/2, startY+songEntryPadding/2,
|
||
songEntryWidth-songEntryPadding, songEntryHeight-songEntryPadding, 8)
|
||
dc.Fill()
|
||
jacketX := startX + songEntryPadding*2
|
||
jacketY := startY + songEntryPadding*2
|
||
if song.JacketPath != "" {
|
||
jacketImg, err := loadImageFromCache(song.JacketPath)
|
||
if err == nil {
|
||
jacketImg = resizeImage(jacketImg, int(jacketSize), int(jacketSize))
|
||
dc.DrawImageAnchored(jacketImg, int(jacketX+jacketSize/2), int(jacketY+jacketSize/2), 0.5, 0.5)
|
||
} else {
|
||
log.Printf("警告: 加载封面 %s 失败: %v. 绘制占位符.", song.JacketPath, err)
|
||
dc.SetHexColor("#505050")
|
||
dc.DrawRoundedRectangle(jacketX, jacketY, jacketSize, jacketSize, 5)
|
||
dc.Fill()
|
||
dc.SetHexColor("#FFFFFF")
|
||
dc.SetFontFace(faceSongDetail)
|
||
dc.DrawStringAnchored("No Art", jacketX+jacketSize/2, jacketY+jacketSize/2, 0.5, 0.5)
|
||
}
|
||
} else {
|
||
dc.SetHexColor("#505050")
|
||
dc.DrawRoundedRectangle(jacketX, jacketY, jacketSize, jacketSize, 5)
|
||
dc.Fill()
|
||
dc.SetHexColor("#FFFFFF")
|
||
dc.SetFontFace(faceSongDetail)
|
||
dc.DrawStringAnchored("No Art", jacketX+jacketSize/2, jacketY+jacketSize/2, 0.5, 0.5)
|
||
}
|
||
textStartX := jacketX + jacketSize + songEntryPadding
|
||
textWidth := songEntryWidth - jacketSize - 4*songEntryPadding
|
||
dc.SetFontFace(faceSongDetail)
|
||
dc.SetHexColor("#AAAAAA")
|
||
indexText := fmt.Sprintf("#%d", i+1)
|
||
dc.DrawStringAnchored(indexText, startX+songEntryPadding, startY+songEntryPadding, 0, 0)
|
||
dc.SetFontFace(faceSongTitle)
|
||
dc.SetHexColor("#FFFFFF")
|
||
titleToShow := song.Title
|
||
if w, _ := dc.MeasureString(titleToShow); w > textWidth*0.85 {
|
||
ellipsisWidth, _ := dc.MeasureString("...")
|
||
for len(titleToShow) > 0 && w > textWidth*0.85-ellipsisWidth {
|
||
titleToShow = titleToShow[:len(titleToShow)-1]
|
||
w, _ = dc.MeasureString(titleToShow)
|
||
}
|
||
titleToShow += "..."
|
||
}
|
||
dc.DrawString(titleToShow, textStartX, startY+songEntryPadding*2+20)
|
||
dc.SetFontFace(faceSongDetail)
|
||
dc.SetHexColor("#BBBBBB")
|
||
BeatmapidToShow := song.Beatmapid
|
||
if w, _ := dc.MeasureString(BeatmapidToShow); w > textWidth*0.85 {
|
||
ellipsisWidth, _ := dc.MeasureString("...")
|
||
for len(BeatmapidToShow) > 0 && w > textWidth*0.85-ellipsisWidth {
|
||
BeatmapidToShow = BeatmapidToShow[:len(BeatmapidToShow)-1]
|
||
w, _ = dc.MeasureString(BeatmapidToShow)
|
||
}
|
||
BeatmapidToShow += "..."
|
||
}
|
||
dc.DrawString(BeatmapidToShow, textStartX, startY+songEntryPadding*2+50)
|
||
dc.SetFontFace(faceSongDetail)
|
||
if song.DifficultyColor != nil {
|
||
dc.SetColor(song.DifficultyColor)
|
||
} else {
|
||
dc.SetHexColor("#FFFFFF")
|
||
}
|
||
diffText := fmt.Sprintf("[%s]", song.Difficulty)
|
||
dc.DrawString(diffText, textStartX, startY+songEntryPadding*2+80)
|
||
rightSideX := startX + songEntryWidth - songEntryPadding*2
|
||
dc.SetFontFace(faceSongDetail)
|
||
dc.SetHexColor("#FFCC22")
|
||
starsText := fmt.Sprintf("%.2f★", song.Stars)
|
||
dc.DrawStringAnchored(starsText, rightSideX, startY+songEntryHeight*0.25, 1.0, 0.5)
|
||
dc.SetFontFace(faceSongUserMap_pp)
|
||
if i < 30 {
|
||
dc.SetHexColor("#FFEE77")
|
||
} else {
|
||
dc.SetHexColor("#ADD8E6")
|
||
}
|
||
dc.DrawStringAnchored(fmt.Sprintf("%.2fpp", song.UserMap_pp),
|
||
rightSideX, startY+songEntryHeight*0.45, 1.0, 0.5)
|
||
dc.SetFontFace(faceSongDetail)
|
||
dc.SetHexColor("#AAAAAA")
|
||
accText := fmt.Sprintf("Acc: %.2f%%", song.Accuracy)
|
||
dc.DrawStringAnchored(accText, rightSideX, startY+songEntryHeight*0.75, 1.0, 0.5)
|
||
daysText := song.Days_ago
|
||
dc.DrawStringAnchored(daysText, rightSideX, startY+songEntryHeight*0.95, 1.0, 0.5)
|
||
}
|
||
|
||
dc.SetFontFace(faceSongDetail)
|
||
dc.SetHexColor("#999999")
|
||
generatedTime := time.Now().Format("2006-01-02 15:04:05")
|
||
dc.DrawStringAnchored(fmt.Sprintf("Generated: %s", generatedTime), padding, float64(imgHeight)-padding, 0, 1.0)
|
||
return dc.SavePNG(outputPath)
|
||
}
|
||
|
||
// 获取ScoreSaber用户信息
|
||
func fetchScoreSaberUser(userID string) (*ScoreSaberUser, error) {
|
||
url := "https://scoresaber.com/api/player/" + userID + "/full"
|
||
resp, err := http.Get(url)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("HTTP状态码错误: %d", resp.StatusCode)
|
||
}
|
||
body, err := ioutil.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var user ScoreSaberUser
|
||
err = json.Unmarshal(body, &user)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &user, nil
|
||
}
|
||
|
||
// 下载图片到本地临时文件,返回本地路径
|
||
func downloadImageToLocal(url string) (string, error) {
|
||
resp, err := http.Get(url)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 生成临时文件
|
||
// tmpFile, err := os.CreateTemp("", "avatar_*.jpg")
|
||
uuid := uuid.New().String()
|
||
tmpFile, err := os.Create(util.GenTempFilePath(uuid + ".jpg"))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer tmpFile.Close()
|
||
|
||
_, err = io.Copy(tmpFile, resp.Body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return tmpFile.Name(), nil
|
||
}
|
||
|
||
// 缩放图片到指定宽高
|
||
func resizeImage(img image.Image, width, height int) image.Image {
|
||
return resize.Resize(uint(width), uint(height), img, resize.Lanczos3)
|
||
}
|
||
|
||
// getScoreSaberScores 获取玩家的 ScoreSaber 分数
|
||
func getScoreSaberScores(playerID string, sort string, page int) (*ScoreSaberResponse, error) {
|
||
url := fmt.Sprintf("https://scoresaber.com/api/player/%s/scores?sort=%s&page=%d", playerID, sort, page)
|
||
|
||
resp, err := http.Get(url)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请求 ScoreSaber API 失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("ScoreSaber API 返回错误状态码: %d", resp.StatusCode)
|
||
}
|
||
|
||
var result ScoreSaberResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, fmt.Errorf("解析 ScoreSaber API 响应失败: %v", err)
|
||
}
|
||
|
||
return &result, nil
|
||
}
|
||
|
||
// 难度颜色映射
|
||
var difficultyColors = map[string]color.Color{
|
||
"Easy": color.RGBA{R: 0x00, G: 0xFF, B: 0x00, A: 0xFF}, // 绿色
|
||
"Normal": color.RGBA{R: 0x00, G: 0x7F, B: 0xFF, A: 0xFF}, // 蓝色
|
||
"Hard": color.RGBA{R: 0xFF, G: 0x7F, B: 0x00, A: 0xFF}, // 橙色
|
||
"Expert": color.RGBA{R: 0xFF, G: 0x00, B: 0x00, A: 0xFF}, // 红色
|
||
"ExpertPlus": color.RGBA{R: 0xB4, G: 0x00, B: 0xB4, A: 0xFF}, // 紫色
|
||
}
|
||
|
||
// 将 ScoreSaber 分数转换为 SongData
|
||
func convertScoreSaberToSongData(score ScoreSaberPlayerScore, cwd string) (SongData, error) {
|
||
// 下载封面图片
|
||
jacketPath := util.GenTempFilePath(score.Leaderboard.SongHash + ".png")
|
||
if _, err := os.Stat(jacketPath); os.IsNotExist(err) {
|
||
resp, err := http.Get(score.Leaderboard.CoverImage)
|
||
if err != nil {
|
||
return SongData{}, fmt.Errorf("下载封面图片失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
file, err := os.Create(jacketPath)
|
||
if err != nil {
|
||
return SongData{}, fmt.Errorf("创建封面文件失败: %v", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
_, err = io.Copy(file, resp.Body)
|
||
if err != nil {
|
||
return SongData{}, fmt.Errorf("保存封面图片失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// 获取难度名称
|
||
difficultyName := "Unknown"
|
||
switch score.Leaderboard.Difficulty.Difficulty {
|
||
case 1:
|
||
difficultyName = "Easy"
|
||
case 3:
|
||
difficultyName = "Normal"
|
||
case 5:
|
||
difficultyName = "Hard"
|
||
case 7:
|
||
difficultyName = "Expert"
|
||
case 9:
|
||
difficultyName = "ExpertPlus"
|
||
}
|
||
|
||
// 计算准确率
|
||
accuracy := float64(score.Score.ModifiedScore) / float64(score.Leaderboard.MaxScore) * 100
|
||
|
||
// 计算游玩时间
|
||
timeSet, err := time.Parse(time.RFC3339, score.Score.TimeSet)
|
||
if err != nil {
|
||
return SongData{}, fmt.Errorf("解析时间失败: %v", err)
|
||
}
|
||
daysAgo := time.Since(timeSet)
|
||
daysAgoStr := formatTimeAgo(daysAgo)
|
||
|
||
return SongData{
|
||
JacketPath: jacketPath,
|
||
Title: score.Leaderboard.SongName,
|
||
Beatmapid: score.Leaderboard.SongAuthorName,
|
||
Difficulty: difficultyName,
|
||
DifficultyColor: difficultyColors[difficultyName],
|
||
Stars: score.Leaderboard.Stars,
|
||
Accuracy: accuracy,
|
||
Days_ago: daysAgoStr,
|
||
UserMap_pp: score.Score.PP,
|
||
IsBest30: true, // 这个值需要根据排序来确定
|
||
}, nil
|
||
}
|
||
|
||
// 格式化时间差
|
||
func formatTimeAgo(d time.Duration) string {
|
||
days := int(d.Hours() / 24)
|
||
if days > 0 {
|
||
return fmt.Sprintf("%dd", days)
|
||
}
|
||
hours := int(d.Hours())
|
||
if hours > 0 {
|
||
return fmt.Sprintf("%dh", hours)
|
||
}
|
||
minutes := int(d.Minutes())
|
||
if minutes > 0 {
|
||
return fmt.Sprintf("%dm", minutes)
|
||
}
|
||
return "now"
|
||
}
|
||
|
||
// 获取Best30分数(最多30个)
|
||
func getBest30Scores(playerID string) ([]ScoreSaberPlayerScore, error) {
|
||
var result []ScoreSaberPlayerScore
|
||
page := 1
|
||
for len(result) < 30 {
|
||
resp, err := getScoreSaberScores(playerID, "top", page)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(resp.PlayerScores) == 0 {
|
||
break
|
||
}
|
||
result = append(result, resp.PlayerScores...)
|
||
page++
|
||
}
|
||
if len(result) > 30 {
|
||
result = result[:30]
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// 获取Recent10分数(最多10个)
|
||
func getRecent10Scores(playerID string) ([]ScoreSaberPlayerScore, error) {
|
||
var result []ScoreSaberPlayerScore
|
||
page := 1
|
||
for len(result) < 10 {
|
||
resp, err := getScoreSaberScores(playerID, "recent", page)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(resp.PlayerScores) == 0 {
|
||
break
|
||
}
|
||
result = append(result, resp.PlayerScores...)
|
||
page++
|
||
}
|
||
if len(result) > 10 {
|
||
result = result[:10]
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// 获取玩家的所有分数并转换为 SongData
|
||
func getAllScoreSaberScores(playerID string) ([]SongData, error) {
|
||
var allSongs []SongData
|
||
cwd, _ := os.Getwd()
|
||
|
||
// 获取Best30
|
||
topScores, err := getBest30Scores(playerID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取Best30失败: %v", err)
|
||
}
|
||
for i, score := range topScores {
|
||
songData, err := convertScoreSaberToSongData(score, cwd)
|
||
if err != nil {
|
||
log.Printf("转换分数失败: %v", err)
|
||
continue
|
||
}
|
||
songData.IsBest30 = i < 30
|
||
allSongs = append(allSongs, songData)
|
||
}
|
||
|
||
// 获取Recent10
|
||
recentScores, err := getRecent10Scores(playerID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取Recent10失败: %v", err)
|
||
}
|
||
for _, score := range recentScores {
|
||
songData, err := convertScoreSaberToSongData(score, cwd)
|
||
if err != nil {
|
||
log.Printf("转换分数失败: %v", err)
|
||
continue
|
||
}
|
||
songData.IsBest30 = false
|
||
allSongs = append(allSongs, songData)
|
||
}
|
||
|
||
return allSongs, nil
|
||
}
|
||
|
||
func GenBs50(ssID string, outputPath string) error {
|
||
userID := ssID
|
||
ssUser, err := fetchScoreSaberUser(userID)
|
||
if err != nil {
|
||
log.Fatalf("获取ScoreSaber用户信息失败: %v", err)
|
||
}
|
||
songs, err := getAllScoreSaberScores(userID)
|
||
if err != nil {
|
||
log.Fatalf("获取分数失败: %v", err)
|
||
}
|
||
avatarPath := ssUser.ProfilePicture
|
||
if strings.HasPrefix(avatarPath, "http") {
|
||
localAvatar, err := downloadImageToLocal(avatarPath)
|
||
if err == nil {
|
||
avatarPath = localAvatar
|
||
} else {
|
||
log.Printf("下载头像失败: %v", err)
|
||
avatarPath = ""
|
||
}
|
||
}
|
||
player := PlayerData{
|
||
PlayerName: ssUser.Name,
|
||
PlayerID: ssUser.ID,
|
||
AvatarPath: avatarPath,
|
||
PlayerCurrentPP: ssUser.PP,
|
||
PlayerRank: ssUser.Rank,
|
||
PlayerCountryRank: ssUser.CountryRank,
|
||
B50Songs: songs,
|
||
Country: ssUser.Country,
|
||
}
|
||
if err = generateB50Image(player, outputPath, "resource/font.ttf"); err != nil {
|
||
return fmt.Errorf("生成 B50 图片失败: %v", err)
|
||
}
|
||
return nil
|
||
}
|