First commit >:]

This commit is contained in:
2023-09-17 13:34:03 -04:00
commit b7c28cbf0d
23 changed files with 1410 additions and 0 deletions

200
asb.go Normal file
View File

@@ -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)
}

148
characters.go Normal file
View File

@@ -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)
}

41
constants.go Normal file
View File

@@ -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
}

371
dashboard.go Normal file
View File

@@ -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)
}
}

69
flame.go Normal file
View File

@@ -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)
}

129
fonts.go Normal file
View File

@@ -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)
}
}

26
go.mod Normal file
View File

@@ -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
)

43
gradient.go Normal file
View File

@@ -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
}
}
}

163
issuereader.go Normal file
View File

@@ -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
}
}
}

87
leaderboard.go Normal file
View File

@@ -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))
}
}

5
main.go Normal file
View File

@@ -0,0 +1,5 @@
package main
func main() {
RunDashboard(LoadTime())
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
resources/images/char2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
resources/images/gold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
resources/images/hot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
resources/images/idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
resources/images/shiny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
resources/images/win.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1 @@
2023-11-24T04:00:00.000Z

104
reward.go Normal file
View File

@@ -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)
}

23
timer.go Normal file
View File

@@ -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
}