package scoresaber import ( "encoding/json" "fmt" "image" "image/color" "io" "io/ioutil" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" "git.lxtend.com/qqbot/util" "github.com/fogleman/gg" "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") 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 := filepath.Join(cwd, "jackets", 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, "/resources/font.ttf"); err != nil { return fmt.Errorf("生成 B50 图片失败: %v", err) } return nil }