commit b7c28cbf0de38e76714e9ea907a2c015488668f2 Author: iegod Date: Sun Sep 17 13:34:03 2023 -0400 First commit >:] diff --git a/asb.go b/asb.go new file mode 100644 index 0000000..d0839f0 --- /dev/null +++ b/asb.go @@ -0,0 +1,200 @@ +package main + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/color" + "log" + "math" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/text" +) + +const ( + ASB_HEIGHT = 50 + ASB_BUFFER = 10 + ASB_WIDTH = 1400 + ASB_COLOR = 0x5d2f6a + ASB_MARGIN = 10 + + ASB_GRAD_TOP = 0x9933cc + ASB_GRAD_BOTTOM = 0x5d2f6a + + ASB_FLAME_SCALE = 1.56 + ASB_FLAME_OFFSET = 15 + + ASB_MASK_WIDTH = 10 + ASB_MASK_HEIGHT = ASB_HEIGHT + + ASB_STRFADE_XOFFSET = 80 + ASB_STRFADE_YOFFSET = 50 + ASB_STRFADE_VALUE = 0x50 +) + +var ( + //go:embed resources/images/barmask.png + barmaskAsset_png []byte + assetsBarmask *ebiten.Image +) + +type AnimatedScoreBar struct { + ScoreImage *ebiten.Image + name string + target float64 + value float64 + gradient *image.RGBA + character *Character + flames *Flame + barbuffer *ebiten.Image + order int //ranking + sf float64 //scaling factor +} + +func NewAnimatedScoreBar(t CharacterType, s string) *AnimatedScoreBar { + a := &AnimatedScoreBar{ + name: s, + target: 0, + value: 0, + sf: 1, + ScoreImage: ebiten.NewImage(ASB_WIDTH, ASB_HEIGHT), + barbuffer: ebiten.NewImage(ASB_WIDTH, ASB_HEIGHT), + character: NewCharacter(t), + flames: NewFlame(), + order: 0, + } + + a.character.RandomizeCycleStart() + + //fill bar gradient + a.gradient = image.NewRGBA(image.Rect(0, 0, ASB_WIDTH, ASB_HEIGHT)) + FillGradient(a.gradient, a.barbuffer.Bounds().Dx(), a.barbuffer.Bounds().Dy(), HexToRGBA(ASB_GRAD_TOP), HexToRGBA(ASB_GRAD_BOTTOM)) + a.barbuffer.WritePixels(a.gradient.Pix) + + //mask start of the bar for smooth edges + op := &ebiten.DrawImageOptions{} + op.Blend = ebiten.BlendDestinationOut + a.barbuffer.DrawImage(assetsBarmask.SubImage(image.Rect(0, 0, ASB_MASK_WIDTH, ASB_MASK_HEIGHT)).(*ebiten.Image), op) + + //add name to the fill bar, which will gradually display as the value increases + text.Draw(a.barbuffer, a.name, DashFont.TeamBarName, ASB_MARGIN, ASB_HEIGHT-ASB_MARGIN, color.White) + return a +} + +func (a *AnimatedScoreBar) GetCharacterType() CharacterType { + return a.character.GetType() +} + +func (a *AnimatedScoreBar) Name() string { + return a.name +} + +func (a *AnimatedScoreBar) SetTarget(t float64) { + a.target = t +} + +func (a *AnimatedScoreBar) GetTarget() float64 { + return a.target +} + +func (a *AnimatedScoreBar) GetValue() float64 { + return a.value +} + +func (a *AnimatedScoreBar) Animate() { + + a.ScoreImage.Clear() + + a.AddFadedTeamName() + a.AddScoreBar() + a.AddCharacter() + a.AddTextScore() + a.AdjustValue() +} + +func (a *AnimatedScoreBar) AddFadedTeamName() { + //alpha blended black for the team name + c := HexToRGBA(0x000000) + c.A = ASB_STRFADE_VALUE + text.Draw(a.ScoreImage, a.name, DashFont.TeamBackgroundName, ASB_STRFADE_XOFFSET, ASB_STRFADE_YOFFSET, c) +} + +func (a *AnimatedScoreBar) SetOrder(order int) { + a.order = order +} + +func (a *AnimatedScoreBar) GetOrder() int { + return a.order +} + +// draw our score bar by using subset of full bar +func (a *AnimatedScoreBar) AddScoreBar() { + //ss = subset start, se = subset end + ssx := 0 + ssy := 0 + sex := int(a.value * a.sf) + sey := ASB_HEIGHT + barSubImage := a.barbuffer.SubImage(image.Rect(ssx, ssy, sex, sey)).(*ebiten.Image) + a.ScoreImage.DrawImage(barSubImage, nil) + + //add trailing image mask to round out the edges + op := &ebiten.DrawImageOptions{} + op.Blend = ebiten.BlendDestinationOut + op.GeoM.Translate(float64(sex)-ASB_MASK_WIDTH, float64(sey)-ASB_MASK_HEIGHT) + + msx := ASB_MASK_WIDTH + msy := 0 + mex := ASB_MASK_WIDTH + ASB_MASK_WIDTH + mey := ASB_MASK_HEIGHT + a.ScoreImage.DrawImage(assetsBarmask.SubImage(image.Rect(msx, msy, mex, mey)).(*ebiten.Image), op) +} + +func (a *AnimatedScoreBar) AddCharacter() { + + op := &ebiten.DrawImageOptions{} + + //if we're on the move, let's add some flair to the character + if a.target != a.value { + op.GeoM.Scale(ASB_FLAME_SCALE, ASB_FLAME_SCALE) + op.GeoM.Rotate(-math.Pi / 4) + op.GeoM.Translate(a.value*a.sf+ASB_BUFFER-ASB_FLAME_OFFSET*5/2, ASB_FLAME_OFFSET*3/2) + a.ScoreImage.DrawImage(a.flames.Sprite, op) + a.flames.Animate() + } + + op = &ebiten.DrawImageOptions{} + op.GeoM.Translate(a.value*a.sf+ASB_BUFFER, 0) + a.ScoreImage.DrawImage(a.character.Sprite, op) + a.character.Animate() +} + +func (a *AnimatedScoreBar) AddTextScore() { + tx := int(a.value*a.sf) + ASB_BUFFER + a.character.GetWidth() + ty := ASB_HEIGHT + text.Draw(a.ScoreImage, fmt.Sprintf("%0.f", a.value), DashFont.Title, tx, ty, color.White) + text.Draw(a.ScoreImage, "POINTS", DashFont.Title, tx+ASB_BUFFER*7, ty, color.White) +} + +func (a *AnimatedScoreBar) AdjustValue() { + if a.value < a.target { + a.value++ + } +} + +func (a *AnimatedScoreBar) SetScaleFactor(sf float64) { + a.sf = sf +} + +func (a *AnimatedScoreBar) Reset() { + a.value = 0 +} + +func init() { + img, _, err := image.Decode(bytes.NewReader(barmaskAsset_png)) + if err != nil { + log.Fatal(err) + } + assetsBarmask = ebiten.NewImageFromImage(img) +} diff --git a/characters.go b/characters.go new file mode 100644 index 0000000..4492cca --- /dev/null +++ b/characters.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + _ "embed" + "image" + _ "image/png" + "log" + "math/rand" + + "github.com/hajimehoshi/ebiten/v2" +) + +type CharacterType uint + +const ( + WhiteCharacter CharacterType = 0 + PinkCharacter CharacterType = 1 + BlueCharacter CharacterType = 2 + GreenCharacter CharacterType = 3 + BlackCharacter CharacterType = 4 + OrangeCharacter CharacterType = 5 + RedCharacter CharacterType = 6 +) + +func (c CharacterType) IsValid() bool { + result := false + switch c { + case WhiteCharacter, PinkCharacter, BlueCharacter, GreenCharacter, BlackCharacter, OrangeCharacter, RedCharacter: + result = true + } + return result +} + +const ( + CHARACTER_NUMTYPES = 7 + CHARACTER_WIDTH = 50 + CHARACTER_HEIGHT = 50 + + CHARACTER_ANIMATION_CYCLES = 6 + CHARACTER_WINIMATION_CYCLES = 8 +) + +var ( + //go:embed resources/images/idle.png + assets_png []byte + assetsImage *ebiten.Image + + //go:embed resources/images/win.png + winnerAssets_png []byte + assetsWinner *ebiten.Image +) + +type CharacterPosition struct { + X float64 + Y float64 +} + +type Character struct { + Sprite *ebiten.Image //our principle sprite representing the character + chartype CharacterType //character type, determines offset position to use from asset image + cycle int //current animation cycle for the character + targetcycles int //specified animation cycle + pos CharacterPosition //character coordinates +} + +func (c *Character) RandomizeCycleStart() { + c.cycle = rand.Int() % c.targetcycles +} + +func NewCharacter(t CharacterType) *Character { + c := &Character{ + Sprite: ebiten.NewImage(CHARACTER_WIDTH, CHARACTER_HEIGHT), + pos: CharacterPosition{X: 0, Y: 0}, + targetcycles: CHARACTER_ANIMATION_CYCLES, + } + + if t.IsValid() { + c.chartype = t + } else { + c.chartype = WhiteCharacter + } + + return c +} + +func (c *Character) SetTargetCycles(cycles int) { + c.targetcycles = cycles +} + +func (c *Character) CycleUpdate() { + c.cycle = (c.cycle + 1) % c.targetcycles +} + +func (c *Character) Animate() { + c.AnimateFromTarget(assetsImage) +} + +func (c *Character) AnimateWinner() { + c.AnimateFromTarget(assetsWinner) +} + +func (c *Character) AnimateFromTarget(source *ebiten.Image) { + //compute start and end location of asset to use for animation frame cycle + sx := CHARACTER_WIDTH * c.cycle + sy := CHARACTER_HEIGHT * int(c.chartype) + ex := CHARACTER_WIDTH * (c.cycle + 1) + ey := CHARACTER_HEIGHT * (int(c.chartype) + 1) + + c.Sprite.Clear() + c.Sprite.DrawImage(source.SubImage(image.Rect(sx, sy, ex, ey)).(*ebiten.Image), nil) + + c.CycleUpdate() +} + +func (c *Character) SetPosition(p CharacterPosition) { + c.pos = p +} + +func (c *Character) Position() CharacterPosition { + return c.pos +} + +func (c *Character) GetWidth() int { + return c.Sprite.Bounds().Dx() +} + +func (c *Character) GetHeight() int { + return c.Sprite.Bounds().Dy() +} + +func (c *Character) GetType() CharacterType { + return c.chartype +} + +func init() { + img, _, err := image.Decode(bytes.NewReader(assets_png)) + if err != nil { + log.Fatal(err) + } + assetsImage = ebiten.NewImageFromImage(img) + + img, _, err = image.Decode(bytes.NewReader(winnerAssets_png)) + if err != nil { + log.Fatal(err) + } + assetsWinner = ebiten.NewImageFromImage(img) +} diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..36f41bc --- /dev/null +++ b/constants.go @@ -0,0 +1,41 @@ +package main + +const ( + CUSTOM_FIELD_TEAM = "customfield_14580" +) + +func GetDeadlineResourcePath() string { + return "resources/settings/deadline.txt" +} + +func GetDashboardTitle() string { + return "PI-8 DASHBOARD" +} + +func GetTeamNames() []string { + return []string{"devops", "gaia", "igaluk", "orion", "pulsar", "solaris", "supernova"} +} + +func GetPOList() []string { + return []string{"dbenavid", "el076320", "em031838", "ktsui", "ma031420"} +} + +func GetBountyJQL() string { + return `project = GERSSW AND ("Epic Link" =GERSSW-4971 OR "Epic Link" =GERSSW-3759) AND Status = Accepted` +} + +func GetCompletionText() string { + return "PHASE B COMPLETE" +} + +func GetTimerFormatString() string { + return "D:H:M:S" +} + +func GetWinnerText(qty int) string { + str := "OUR WINNER" + if qty > 1 { + str = str + "S" + } + return str +} diff --git a/dashboard.go b/dashboard.go new file mode 100644 index 0000000..8350225 --- /dev/null +++ b/dashboard.go @@ -0,0 +1,371 @@ +package main + +import ( + "bufio" + _ "embed" + "fmt" + "image" + "image/color" + "log" + "math" + "os" + "syscall" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text" + "golang.org/x/term" +) + +const ( + DIMWIDTH = 1920 + DIMHEIGHT = 1080 + + COLOR_GRAD_TOP = 0xff887c + COLOR_GRAD_BOTTOM = 0xb6325f + COLOR_GRAD_ETOP = 0xffcc33 + COLOR_GRAD_EBOTTOM = 0xcc9933 + + SCORE_UPDATE_INTERVAL = 60 * 60 * 5 //in seconds, 60 updates per second, 60 seconds per minute, 5 minutes + SCORE_VERTICAL_OFFSET = 40 //pixels + REWARD_SCALE = 5 + TIMER_UPDATE_RATE = 3 //updates per frame + WINNER_CHARACTER_SPACING = 150 + TIMER_XOFFSET_DIST = 500 + POOMOJI_SIN_SCALER = 10 + DASH_SCALE_FACTOR = 3 //pixels per team-points on results bar + DASH_ANIMATIONS_PER_CYCLE = 12 //our effective animation rate + + //various textual offset + DASH_TIMESTR_XOFFSET = 50 + DASH_TIMESTR_YOFFSET = 40 + DASH_DEBUG_OFFSET = 50 + DASH_WINSTR_XOFFSET = 80 + DASH_WINSTR_YOFFSET = 55 + + MAX_DEBUG_TARGET = 200 +) + +type TeamData struct { + TeamName string + Points int +} + +type AllTeams struct { + Teams []TeamData +} + +type Dashboard struct { + WindowTitle string + Width int + Height int + + //timer and animation related + tick int + deadline time.Time + timeleft time.Duration + expired bool + aps int //animation-cycles per second + endcondition bool + + //dashboard elements and backgrounds + gradientImage *image.RGBA + gradColorBottom color.RGBA + gradColorTop color.RGBA + reward *Reward + + //functional/logical elements + leaderboard *Leaderboard + issuereader *IssueReader + leaders []*AnimatedScoreBar + winnerCharacters []*Character +} + +func (d *Dashboard) Update() error { + + //exit condition + if ebiten.IsKeyPressed(ebiten.KeyQ) { + os.Exit(0) + } + + //perform animation rate update check + d.UpdateAnimationRate() + + /********** instantaneous score updates, bypassing timer bypass timer *****************/ + //END IMMEDIATELY + if inpututil.IsKeyJustPressed(ebiten.KeyE) { + d.deadline = time.Now() + } + + //GENERATE RANDOM SCORES + if inpututil.IsKeyJustPressed(ebiten.KeyP) { + d.leaderboard.RandomizeTeamTargets() + d.leaders = d.leaderboard.GetLeaders() + } + + //SET ALL TEAMS TO PREDETERMINED VALUE + if inpututil.IsKeyJustPressed(ebiten.KeyL) { + d.leaderboard.SetAllTargets(MAX_DEBUG_TARGET) + d.leaders = d.leaderboard.GetLeaders() + } + /**************************************************************************************/ + + //perform timer calculation + if d.tick%TIMER_UPDATE_RATE == 0 { + d.timeleft = time.Until(d.deadline) + } + + //reload scores in new thread + if d.tick%SCORE_UPDATE_INTERVAL == 0 { + go d.UpdateScoreboard() + } + + //have we expired? + d.expired = d.timeleft.Seconds() <= 0 + d.tick++ + + return nil +} + +// update score from jira actuals +func (d *Dashboard) UpdateScoreboard() { + + if d.issuereader == nil { + return + } + + if !d.expired { + scores := d.issuereader.RefreshScores() + for k, v := range scores { + d.leaderboard.UpdateScoreForTeam(k, v) + } + + d.leaders = d.leaderboard.GetLeaders() + } +} + +func (d *Dashboard) Draw(screen *ebiten.Image) { + screen.WritePixels(d.gradientImage.Pix) + d.DrawUpdate(screen) + + //d.DrawDebug(screen) +} + +func (d *Dashboard) DrawUpdate(screen *ebiten.Image) { + if d.expired { + d.DrawRewardScreen(screen) + d.DrawExpiration(screen) + } else { + d.RenderTimer(screen) + d.DrawLeaderboard(screen) + } +} + +func (d *Dashboard) DrawLeaderboard(screen *ebiten.Image) { + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(200, float64(d.Height/2)) + screen.DrawImage(d.leaderboard.Board, op) + if d.tick%(ebiten.TPS()/d.aps) == 0 { + d.leaderboard.Animate() + } +} + +func (d *Dashboard) DrawRewardScreen(screen *ebiten.Image) { + + d.IniitializeEndCondition() + d.DrawReward(screen) + d.DrawWinners(screen) + d.AnimateRewards() +} + +func (d *Dashboard) DrawReward(screen *ebiten.Image) { + var ypos float64 + //compute relative position and offset for reward + rpos := CharacterPosition{X: float64(d.Width) / 2, Y: float64(d.Height) / 2} + xpos := rpos.X - float64(d.reward.Width())/2*REWARD_SCALE + + //animate the y position + incr := d.tick * 2 + if incr < int(rpos.Y) { + ypos = float64(incr) + } else { + ypos = rpos.Y + math.Sin((float64(d.tick)-rpos.Y)/(math.Pi*4))*POOMOJI_SIN_SCALER + } + + //adjust y offset to center + ypos = ypos - float64(d.reward.Width())/2*REWARD_SCALE + + //apply transformation + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(REWARD_SCALE, REWARD_SCALE) + op.GeoM.Translate(xpos, ypos) + screen.DrawImage(d.reward.Sprite, op) +} + +// draw winning characteres + scores +func (d *Dashboard) DrawWinners(screen *ebiten.Image) { + winnertext := GetWinnerText(len(d.leaders)) + text.Draw(screen, winnertext, DashFont.Debug, d.Width/2-DASH_WINSTR_XOFFSET, d.Height*2/3-DASH_WINSTR_YOFFSET, color.White) + var i int = 0 + for _, team := range d.leaders { + name := team.Name() + + mascot := d.winnerCharacters[i] + op := &ebiten.DrawImageOptions{} + + totallen := len(d.leaders) * WINNER_CHARACTER_SPACING + + xpos := d.Width/2 - totallen/2 + i*WINNER_CHARACTER_SPACING + ypos := d.Height * 2 / 3 + + op.GeoM.Scale(2, 2) + op.GeoM.Translate(float64(xpos), float64(ypos)) + screen.DrawImage(mascot.Sprite, op) + text.Draw(screen, name, DashFont.Normal, xpos, ypos+150, color.Black) + + str := fmt.Sprintf("%0.fPTS", team.GetTarget()) + text.Draw(screen, str, DashFont.Points, xpos, ypos+150+20, color.Black) + i++ + } +} + +// update reward animation cycles +func (d *Dashboard) AnimateRewards() { + if d.tick%(ebiten.TPS()/d.aps) == 0 { + d.reward.Animate() + for i := range d.leaders { + d.winnerCharacters[i].AnimateWinner() + } + } +} + +// performs end condition settings exactly once per dashboard reset +func (d *Dashboard) IniitializeEndCondition() { + if !d.endcondition { + FillGradient(d.gradientImage, d.Width, d.Height, HexToRGBA(COLOR_GRAD_ETOP), HexToRGBA(COLOR_GRAD_EBOTTOM)) + d.endcondition = true + d.tick = 0 + + d.winnerCharacters = d.winnerCharacters[:0] + var pidx int = 0 + for _, team := range d.leaders { + d.winnerCharacters = append(d.winnerCharacters, NewCharacter(team.GetCharacterType())) + d.winnerCharacters[pidx].SetTargetCycles(CHARACTER_WINIMATION_CYCLES) + d.winnerCharacters[pidx].RandomizeCycleStart() + pidx++ + } + + } +} + +func (d *Dashboard) DrawDebug(screen *ebiten.Image) { + x, y := ebiten.CursorPosition() + msg := fmt.Sprintf("(%d, %d )", x, y) + text.Draw(screen, msg, DashFont.Debug, DASH_DEBUG_OFFSET, d.Height-DASH_DEBUG_OFFSET, color.White) +} + +func (d *Dashboard) RenderTimer(screen *ebiten.Image) { + + hoursRaw := d.timeleft.Hours() + days := math.Floor(float64(hoursRaw) / 24) + hours := int(hoursRaw) % 24 + minutes := int(d.timeleft.Minutes()) % 60 //minutes per hour + seconds := int(d.timeleft.Seconds()) % 60 //seconds per minute + + formattedTime := fmt.Sprintf("%3.f:%02d:%02d:%02d", days, hours, minutes, seconds) + ypos := d.Height / 3 + xpos := d.Width/2 - TIMER_XOFFSET_DIST + + text.Draw(screen, formattedTime, DashFont.PrincipleTimer, xpos, ypos, color.Black) + text.Draw(screen, GetTimerFormatString(), DashFont.Normal, d.Width/2-DASH_TIMESTR_XOFFSET, ypos+DASH_TIMESTR_YOFFSET, color.Black) +} + +func (d *Dashboard) DrawExpiration(screen *ebiten.Image) { + if d.tick%90 < 45 { + text.Draw(screen, GetCompletionText(), DashFont.Celebration, d.Width/2-600, d.Height/3, color.Black) + } +} + +func (d *Dashboard) Layout(width, height int) (int, int) { + return d.Width, d.Height +} + +func (d *Dashboard) SetDimensions(w, h int) { + if w > 0 { + d.Width = w + } + if h > 0 { + d.Height = h + } + + d.gradientImage = image.NewRGBA(image.Rect(0, 0, w, h)) + FillGradient(d.gradientImage, d.Width, d.Height, d.gradColorTop, d.gradColorBottom) +} + +func (d *Dashboard) SetTime(t time.Time) { + d.deadline = t +} + +// adjust animation rate based on keyboard input (up/down arrow keys) +func (d *Dashboard) UpdateAnimationRate() { + var keys []ebiten.Key + keys = inpututil.AppendJustPressedKeys(keys[0:]) + for _, k := range keys { + switch k { + case ebiten.KeyArrowUp: + if d.aps < ebiten.TPS()-1 { + d.aps = d.aps + 1 + } + case ebiten.KeyArrowDown: + if d.aps > 1 { + d.aps = d.aps - 1 + } + } + } +} + +// adds a jira issue reader to the dashboard - prompts for credentials +func (d *Dashboard) AddIssueReader() { + r := bufio.NewReader(os.Stdin) + fmt.Print("Jira Username: ") + username, _ := r.ReadString('\n') + fmt.Print("Jira Password: ") + pass, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(pass) + d.issuereader = NewIssueReader(username, password) +} + +func NewDashboard() *Dashboard { + d := &Dashboard{ + tick: 0, + WindowTitle: GetDashboardTitle(), + gradColorTop: HexToRGBA(COLOR_GRAD_TOP), + gradColorBottom: HexToRGBA(COLOR_GRAD_BOTTOM), + aps: DASH_ANIMATIONS_PER_CYCLE, + reward: NewReward(), + endcondition: false, + winnerCharacters: []*Character{}, + } + + //d.AddIssueReader() + + teamnames := GetTeamNames() + d.leaderboard = NewLeaderboard(ASB_WIDTH, (len(teamnames)+1)*ASB_HEIGHT+ASB_BUFFER, teamnames) + d.leaderboard.SetScaleFactor(DASH_SCALE_FACTOR) + + return d +} + +func RunDashboard(t time.Time) { + dash := NewDashboard() + ebiten.SetWindowSize(ebiten.ScreenSizeInFullscreen()) + + ebiten.SetWindowTitle(dash.WindowTitle) + dash.SetDimensions(DIMWIDTH, DIMHEIGHT) + ebiten.SetFullscreen(true) + dash.SetTime(t) + if err := ebiten.RunGame(dash); err != nil { + log.Fatal(err) + } +} diff --git a/flame.go b/flame.go new file mode 100644 index 0000000..150e5d6 --- /dev/null +++ b/flame.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + _ "embed" + "image" + _ "image/png" + "log" + + "github.com/hajimehoshi/ebiten/v2" +) + +const ( + FLAME_ANIMATION_CYCLES = 8 + FLAME_WIDTH = 24 + FLAME_HEIGHT = 32 +) + +var ( + //go:embed resources/images/hot.png + flameAssets_png []byte + assetsFlame *ebiten.Image +) + +type Flame struct { + Sprite *ebiten.Image //our principle sprite representing the flame + cycle int //current animation cycle for the character +} + +func NewFlame() *Flame { + return &Flame{ + cycle: 0, + Sprite: ebiten.NewImage(FLAME_WIDTH, FLAME_HEIGHT), + } +} + +func (f *Flame) CycleUpdate() { + f.cycle = (f.cycle + 1) % CHARACTER_ANIMATION_CYCLES +} + +func (f *Flame) Animate() { + + //compute start and end location of asset to use for animation frame cycle + sx := FLAME_WIDTH * f.cycle + sy := 0 + ex := FLAME_WIDTH * (f.cycle + 1) + ey := FLAME_HEIGHT + + f.Sprite.Clear() + f.Sprite.DrawImage(assetsFlame.SubImage(image.Rect(sx, sy, ex, ey)).(*ebiten.Image), nil) + + f.CycleUpdate() +} + +func (f *Flame) GetWidth() int { + return f.Sprite.Bounds().Dx() +} + +func (f *Flame) GetHeight() int { + return f.Sprite.Bounds().Dy() +} + +func init() { + img, _, err := image.Decode(bytes.NewReader(flameAssets_png)) + if err != nil { + log.Fatal(err) + } + assetsFlame = ebiten.NewImageFromImage(img) +} diff --git a/fonts.go b/fonts.go new file mode 100644 index 0000000..f5778c5 --- /dev/null +++ b/fonts.go @@ -0,0 +1,129 @@ +package main + +import ( + _ "embed" + "log" + + "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" +) + +var ( + DashFont *FontBase + + //go:embed resources/fonts/bahnschrift.ttf + banschrift_ttf []byte +) + +type FontBase struct { + PrincipleTimer font.Face + Title font.Face + Normal font.Face + Expiry font.Face + Debug font.Face + Celebration font.Face + Points font.Face + TeamBackgroundName font.Face + TeamBarName font.Face +} + +func init() { + + const dpi = 72 + DashFont = &FontBase{} + tt, err := opentype.ParseCollection(banschrift_ttf) + if err != nil { + log.Fatal(err) + } + + //fmt.Printf("%d\n", tt.NumFonts()) + + fnt, _ := tt.Font(0) + DashFont.PrincipleTimer, err = opentype.NewFace(fnt, &opentype.FaceOptions{ + Size: 200, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + DashFont.Expiry, err = opentype.NewFace(fnt, &opentype.FaceOptions{ + Size: 120, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + DashFont.Title, err = opentype.NewFace(fnt, &opentype.FaceOptions{ + Size: 40, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + DashFont.Normal, err = opentype.NewFace(fnt, &opentype.FaceOptions{ + Size: 20, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + DashFont.Points, err = opentype.NewFace(fnt, &opentype.FaceOptions{ + Size: 15, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + p2tt, err := opentype.Parse(fonts.PressStart2P_ttf) + if err != nil { + log.Fatal(err) + } + + DashFont.Celebration, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ + Size: 72, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + DashFont.TeamBackgroundName, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ + Size: 40, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + DashFont.TeamBarName, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ + Size: 25, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + + DashFont.Debug, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ + Size: 15, + DPI: dpi, + Hinting: font.HintingVertical, + }) + if err != nil { + log.Fatal(err) + } + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90b3eed --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module pobounty + +go 1.21.0 + +require ( + github.com/andygrunwald/go-jira v1.16.0 + github.com/hajimehoshi/ebiten/v2 v2.5.6 + golang.org/x/image v0.6.0 + golang.org/x/term v0.5.0 +) + +require ( + github.com/ebitengine/purego v0.4.0 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect + github.com/golang-jwt/jwt/v4 v4.4.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/jezek/xgb v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/trivago/tgo v1.0.7 // indirect + golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect + golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/text v0.8.0 // indirect +) diff --git a/gradient.go b/gradient.go new file mode 100644 index 0000000..409f6fa --- /dev/null +++ b/gradient.go @@ -0,0 +1,43 @@ +package main + +import ( + "image" + "image/color" +) + +func HexToRGBA(hexcolor int) color.RGBA { + return color.RGBA{ + R: uint8(hexcolor >> 0x10 & 0xff), + G: uint8(hexcolor >> 0x08 & 0xff), + B: uint8(hexcolor >> 0x00 & 0xff), + A: 0xff, + } +} + +// performs linear gradient fill on target image for the background from top to bottom colors +func FillGradient(target *image.RGBA, width int, height int, top, bottom color.RGBA) { + + for y := 0; y < height; y++ { + + //the percent of our transition informs our blending + percentIn := float64(y) / float64(height) + + //compute top and bottom blend values as factor of percentage + pTopR := float64(top.R) * (1 - percentIn) + pTopG := float64(top.G) * (1 - percentIn) + pTopB := float64(top.B) * (1 - percentIn) + + pBottomR := float64(bottom.R) * (percentIn) + pBottomG := float64(bottom.G) * (percentIn) + pBottomB := float64(bottom.B) * (percentIn) + + //perform blending on per pixel row basis + for x := 0; x < width; x++ { + pixelIndex := 4 * (width*y + x) + target.Pix[pixelIndex] = uint8(pTopR + pBottomR) + target.Pix[pixelIndex+1] = uint8(pTopG + pBottomG) + target.Pix[pixelIndex+2] = uint8(pTopB + pBottomB) + target.Pix[pixelIndex+3] = 0xff + } + } +} diff --git a/issuereader.go b/issuereader.go new file mode 100644 index 0000000..fcb6acf --- /dev/null +++ b/issuereader.go @@ -0,0 +1,163 @@ +package main + +import ( + "log" + "regexp" + "strconv" + "strings" + + "github.com/andygrunwald/go-jira" +) + +const ( + JIRA_URL = "https://jirawms.mda.ca" +) + +type IssueReader struct { + auth jira.BasicAuthTransport //auth data + client *jira.Client //jira client + score map[string]int //score information +} + +func NewIssueReader(user, pass string) *IssueReader { + ir := &IssueReader{ + auth: jira.BasicAuthTransport{ + Username: strings.TrimSpace(user), + Password: strings.TrimSpace(pass), + }, + score: make(map[string]int), + } + + var err error + ir.client, err = jira.NewClient(ir.auth.Client(), strings.TrimSpace(JIRA_URL)) + if err != nil { + log.Fatal(err) + } + + return ir +} + +func (i *IssueReader) GetScores() map[string]int { + return i.score +} + +func (i *IssueReader) ZeroScores() { + for k := range i.score { + i.score[k] = 0 + } + +} + +func (i *IssueReader) RefreshScores() map[string]int { + + i.ZeroScores() + + poIssues, er := i.GetAllIssues(i.client, GetBountyJQL()) + if er != nil { + log.Fatal(er) + } + + //run through each to check if we've got bounty points to award + for _, issue := range poIssues { + teamId := i.ParseCustomTeamField(issue.Fields.Unknowns[CUSTOM_FIELD_TEAM]) + i.ExtractScoreFromComments(teamId, issue.Key) + } + + return i.GetScores() +} + +// the team field is one of type string after a non-intuitive map decomposition, assert on interface +func (i *IssueReader) ParseCustomTeamField(teamField interface{}) string { + var str string = "" + if teamField != nil { + str = teamField.([]interface{})[0].(map[string]interface{})["value"].(string) + } + return str +} + +// given a teamid and jira issue, validate potential PO bounty scores +func (i *IssueReader) ExtractScoreFromComments(teamId string, key string) { + if i.IsValidTeam(teamId) { + is, _, err := i.client.Issue.Get(key, nil) + if err != nil { + log.Fatal(err) + } + + comments := is.Fields.Comments.Comments + for _, c := range comments { + if i.CommentAuthorIsPO(c) { + bp := i.ExtractBountyFromComment(c) + i.score[teamId] = i.score[teamId] + bp + } + } + } +} + +// check if s exists in Scrum Teams list +func (i *IssueReader) IsValidTeam(s string) bool { + for _, t := range GetTeamNames() { + if strings.Compare(strings.ToLower(s), strings.ToLower(t)) == 0 { + return true + } + } + return false +} + +// given a jira.Comment, find and extract the associated PO Bounty points +func (i *IssueReader) ExtractBountyFromComment(c *jira.Comment) int { + result := 0 + re := regexp.MustCompile(`POB: (\d+)`) + if c != nil { + matches := re.FindStringSubmatch(c.Body) + + //we're expecting exactly one principle match and one submatch (2 entries total) + if len(matches) == 2 { + //submatch must be an integer, or else we simply don't update the result + a, err := strconv.Atoi(matches[1]) + if err == nil { + result = a + } + + } + } + return result +} + +// given a specific jira.Comment, find if it was authored by someone int he POList +func (i *IssueReader) CommentAuthorIsPO(c *jira.Comment) bool { + if c != nil { + for _, p := range GetPOList() { + if c.Author.Name == p { + return true + } + } + } + return false +} + +// retrieve all issues as per searchString JQL +func (i *IssueReader) GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error) { + last := 0 + var issues []jira.Issue + for { + opt := &jira.SearchOptions{ + MaxResults: 1000, // Max results can go up to 1000 + StartAt: last, + } + + chunk, resp, err := client.Issue.Search(searchString, opt) + if err != nil { + return nil, err + } + + total := resp.Total + if issues == nil { + issues = make([]jira.Issue, 0, total) + } + issues = append(issues, chunk...) + last = resp.StartAt + len(chunk) + if last >= total { + return issues, nil + } + } +} diff --git a/leaderboard.go b/leaderboard.go new file mode 100644 index 0000000..6fbe34f --- /dev/null +++ b/leaderboard.go @@ -0,0 +1,87 @@ +package main + +import ( + "math/rand" + "strings" + + "github.com/hajimehoshi/ebiten/v2" +) + +const ( + MAX_RANDOM_INCREMENT = 50 +) + +type Leaderboard struct { + Board *ebiten.Image //the team board target image + teams map[string]*AnimatedScoreBar //collection of teams + score bars +} + +func NewLeaderboard(w, h int, teamnames []string) *Leaderboard { + t := &Leaderboard{} + t.Board = ebiten.NewImage(w, h) + t.teams = make(map[string]*AnimatedScoreBar) + + for i, name := range teamnames { + cleanname := strings.ToUpper(name) + t.teams[cleanname] = NewAnimatedScoreBar(CharacterType(i%CHARACTER_NUMTYPES), strings.ToUpper(name)) + t.teams[cleanname].SetOrder(i) + } + + return t +} + +func (t *Leaderboard) SetScaleFactor(sf float64) { + for _, team := range t.teams { + team.SetScaleFactor(sf) + } +} + +func (l *Leaderboard) UpdateScoreForTeam(name string, score int) { + t := l.teams[strings.ToUpper(name)] + if t != nil { + t.SetTarget(float64(score)) + } +} + +func (l *Leaderboard) GetLeaders() []*AnimatedScoreBar { + leaders := []*AnimatedScoreBar{} + + highScore := 0 + + for _, team := range l.teams { + teamscore := int(team.GetTarget()) + if teamscore > highScore { + leaders = leaders[:0] + leaders = append(leaders, team) + highScore = teamscore + } else if teamscore == highScore { + leaders = append(leaders, team) + } + } + + return leaders +} + +func (t *Leaderboard) Animate() { + + t.Board.Clear() + for _, v := range t.teams { + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(0, float64(v.GetOrder())*(ASB_HEIGHT+ASB_BUFFER)) + t.Board.DrawImage(v.ScoreImage, op) + v.Animate() + } + +} + +func (l *Leaderboard) SetAllTargets(t float64) { + for _, board := range l.teams { + board.SetTarget(t) + } +} + +func (l *Leaderboard) RandomizeTeamTargets() { + for _, board := range l.teams { + board.SetTarget(board.GetTarget() + float64(rand.Int()%MAX_RANDOM_INCREMENT)) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c209b84 --- /dev/null +++ b/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + RunDashboard(LoadTime()) +} diff --git a/resources/fonts/arcade_n.ttf b/resources/fonts/arcade_n.ttf new file mode 100644 index 0000000..4730e44 Binary files /dev/null and b/resources/fonts/arcade_n.ttf differ diff --git a/resources/fonts/bahnschrift.ttf b/resources/fonts/bahnschrift.ttf new file mode 100644 index 0000000..2c121b3 Binary files /dev/null and b/resources/fonts/bahnschrift.ttf differ diff --git a/resources/images/barmask.png b/resources/images/barmask.png new file mode 100644 index 0000000..f81cc72 Binary files /dev/null and b/resources/images/barmask.png differ diff --git a/resources/images/char2.png b/resources/images/char2.png new file mode 100644 index 0000000..394211f Binary files /dev/null and b/resources/images/char2.png differ diff --git a/resources/images/gold.png b/resources/images/gold.png new file mode 100644 index 0000000..cebf334 Binary files /dev/null and b/resources/images/gold.png differ diff --git a/resources/images/hot.png b/resources/images/hot.png new file mode 100644 index 0000000..0fdee0a Binary files /dev/null and b/resources/images/hot.png differ diff --git a/resources/images/idle.png b/resources/images/idle.png new file mode 100644 index 0000000..b71b6cb Binary files /dev/null and b/resources/images/idle.png differ diff --git a/resources/images/shiny.png b/resources/images/shiny.png new file mode 100644 index 0000000..92d178f Binary files /dev/null and b/resources/images/shiny.png differ diff --git a/resources/images/win.png b/resources/images/win.png new file mode 100644 index 0000000..3d3a2ae Binary files /dev/null and b/resources/images/win.png differ diff --git a/resources/settings/deadline.txt b/resources/settings/deadline.txt new file mode 100644 index 0000000..b04b3a4 --- /dev/null +++ b/resources/settings/deadline.txt @@ -0,0 +1 @@ +2023-11-24T04:00:00.000Z \ No newline at end of file diff --git a/reward.go b/reward.go new file mode 100644 index 0000000..1bdfbca --- /dev/null +++ b/reward.go @@ -0,0 +1,104 @@ +package main + +import ( + "bytes" + _ "embed" + "image" + _ "image/png" + "log" + + "github.com/hajimehoshi/ebiten/v2" +) + +const ( + REWARD_HEIGHT = 32 + REWARD_WIDTH = 32 + + SHINY_ANIMATION_CYCLES = 9 + SHINY_ROW_INDEX = 11 + SPARKLE_ROW_INDEX = 7 +) + +var ( + //go:embed resources/images/gold.png + assetsReward_png []byte + rewardsImage *ebiten.Image + + //go:embed resources/images/shiny.png + assetsShiny_png []byte + shinyImage *ebiten.Image +) + +type Reward struct { + Sprite *ebiten.Image + scratch *ebiten.Image + cycle int +} + +func NewReward() *Reward { + r := &Reward{ + cycle: 0, + } + r.Sprite = ebiten.NewImageFromImage(rewardsImage) + r.scratch = ebiten.NewImageFromImage(rewardsImage) + return r +} + +func (r *Reward) Animate() { + + r.scratch.Clear() + r.Sprite.Clear() + + r.scratch.DrawImage(rewardsImage.SubImage(image.Rect(0, 0, REWARD_WIDTH, REWARD_HEIGHT)).(*ebiten.Image), nil) + + if r.cycle < SHINY_ANIMATION_CYCLES { + //add sheen + op := &ebiten.DrawImageOptions{} + op.Blend = ebiten.BlendSourceAtop + + sx := r.cycle * REWARD_WIDTH + sy := REWARD_HEIGHT * SHINY_ROW_INDEX + + ex := sx + REWARD_WIDTH + ey := sy + REWARD_HEIGHT + + r.scratch.DrawImage(shinyImage.SubImage(image.Rect(sx, sy, ex, ey)).(*ebiten.Image), op) + + //add sparkle + spsy := REWARD_HEIGHT * SPARKLE_ROW_INDEX + spey := spsy + REWARD_HEIGHT + op.GeoM.Translate(REWARD_WIDTH/2, REWARD_HEIGHT/2) + r.scratch.DrawImage(shinyImage.SubImage(image.Rect(sx, spsy, ex, spey)).(*ebiten.Image), nil) + } + + r.Sprite.DrawImage(r.scratch, nil) + r.CycleUpdate() +} + +func (r *Reward) CycleUpdate() { + r.cycle = (r.cycle + 1) % (SHINY_ANIMATION_CYCLES * 8) +} + +func (r *Reward) Width() int { + return REWARD_WIDTH +} + +func (r *Reward) Height() int { + return REWARD_HEIGHT +} + +func init() { + + img, _, err := image.Decode(bytes.NewReader(assetsReward_png)) + if err != nil { + log.Fatal(err) + } + rewardsImage = ebiten.NewImageFromImage(img) + + img, _, err = image.Decode(bytes.NewReader(assetsShiny_png)) + if err != nil { + log.Fatal(err) + } + shinyImage = ebiten.NewImageFromImage(img) + +} diff --git a/timer.go b/timer.go new file mode 100644 index 0000000..533a495 --- /dev/null +++ b/timer.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + "os" + "time" +) + +func LoadTime() time.Time { + + data, err := os.ReadFile(GetDeadlineResourcePath()) + if err != nil { + data = make([]byte, 1) + data[0] = 0 + } + + deadline, err := time.Parse(time.RFC3339, string(data)) + if err != nil { + log.Fatal(err) + } + + return deadline +}