Compare commits
4 Commits
4f0ac4a452
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d89ee67a72 | |||
| 1b405d716e | |||
| 8f7b16a9ae | |||
| db5da9bb48 |
70
README.md
Normal file
70
README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# GERS PO Dashboard
|
||||
|
||||
The GERS Software PO Dashboard is intended to be a reusable board for displaying remaining time in a given work interval as well as tallying scores on accepted Jira issues. Written in go. Leverages ebitengine. Assets are a combination of original work in conjunction with free assets from itch.io.
|
||||
|
||||
## Features
|
||||
* displays countdown timer (in working days, hours, minutes, seconds) until a given deadline
|
||||
* animated score bars and overall leaderboard
|
||||
* configurable teams, score tallying, score givers
|
||||
* customizable Jira criteria for score keeping
|
||||
* celebration/completion screen, with winners identified
|
||||
* 24hr-remaining mode (ominous background)
|
||||
* offline mode (for simulation)
|
||||
|
||||
## Future Improvements:
|
||||
* configurable/customizable assets (the idea being teams can create their own)
|
||||
|
||||
## Build Steps:
|
||||
|
||||
Make sure you've got go/git installed.
|
||||
```
|
||||
git clone https://gitscm.mda.ca/brm_cg/sw-prototypes/gers-dashboard.git
|
||||
```
|
||||
|
||||
```
|
||||
cd gers-dashboard
|
||||
```
|
||||
|
||||
Retrieve module dependencies.
|
||||
```
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
And lastly build.
|
||||
```
|
||||
go build <targetname>
|
||||
```
|
||||
|
||||
Alternatively you don't have to build and can run/debug in your favorite IDE. If you're using VS Code the following launch configuration may prove useful. Note the external console; required if you are attempting to connect to a live jira instance in order to enter credentials.
|
||||
|
||||
```
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}",
|
||||
"console": "externalTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If you'd like to run this without connecting to Jira (simulated behaviours by keyboard only), you can clear the url entry in the config.json file (missing field or empty string will suffice). Keyboard inputs as follows:
|
||||
|
||||
| Key | Description |
|
||||
| ----------- | ----------- |
|
||||
| Q | Quit |
|
||||
| P | Add points to all teams randomly |
|
||||
| L | Assign 200 points to all teams |
|
||||
| R | Advance deadline to next 24hrs 5s (to preview 24hr-remaining mode) |
|
||||
| E | Transitions to end-screen (expires timer immediately) |
|
||||
| . | Randomly select new background gradient (countdown only) |
|
||||
| ArrowUp | Increase animation cycle frequency (max = ebitengine ticks per second, and this project is configured to the default 60) |
|
||||
| ArrowDown | Decrease animation cycle frequency (min = 1) |
|
||||
|
||||
|
||||

|
||||
115
asb.go
115
asb.go
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
@@ -20,24 +19,30 @@ const (
|
||||
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
|
||||
|
||||
//scales and offsets
|
||||
ASB_GRAD_TOP = 0x457cf1 //0xff887c //0x9933cc
|
||||
ASB_GRAD_BOTTOM = 0x2051bf //0xb6325f //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
|
||||
|
||||
//scoreboard ticker related
|
||||
ASB_TICK_PIXEL_SCALE = 2 //how many pixels per cycle for ticker animation
|
||||
ASB_TICK_MAX_ADJUST = 2 * ASB_HEIGHT
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed resources/images/barmask.png
|
||||
barmaskAsset_png []byte
|
||||
assetsBarmask *ebiten.Image
|
||||
|
||||
//go:embed resources/images/crown.png
|
||||
crownAsset_png []byte
|
||||
assetsCrown *ebiten.Image
|
||||
)
|
||||
|
||||
type AnimatedScoreBar struct {
|
||||
@@ -45,12 +50,16 @@ type AnimatedScoreBar struct {
|
||||
name string
|
||||
target float64
|
||||
value float64
|
||||
gradient *image.RGBA
|
||||
gradient *Gradient
|
||||
barcolors []color.RGBA
|
||||
character *Character
|
||||
flames *Flame
|
||||
barbuffer *ebiten.Image
|
||||
order int //ranking
|
||||
sf float64 //scaling factor
|
||||
crown *ebiten.Image
|
||||
leader bool
|
||||
cycle int
|
||||
}
|
||||
|
||||
func NewAnimatedScoreBar(t CharacterType, s string) *AnimatedScoreBar {
|
||||
@@ -61,17 +70,22 @@ func NewAnimatedScoreBar(t CharacterType, s string) *AnimatedScoreBar {
|
||||
sf: 1,
|
||||
ScoreImage: ebiten.NewImage(ASB_WIDTH, ASB_HEIGHT),
|
||||
barbuffer: ebiten.NewImage(ASB_WIDTH, ASB_HEIGHT),
|
||||
gradient: NewGradient(ASB_WIDTH, ASB_HEIGHT),
|
||||
character: NewCharacter(t),
|
||||
flames: NewFlame(),
|
||||
crown: ebiten.NewImageFromImage(assetsCrown),
|
||||
order: 0,
|
||||
leader: false,
|
||||
barcolors: []color.RGBA{HexToRGBA(ASB_GRAD_TOP), HexToRGBA(ASB_GRAD_BOTTOM)},
|
||||
}
|
||||
|
||||
a.character.RandomizeCycleStart()
|
||||
a.SetBarColors(a.barcolors[GRAD_IDX_TOP], a.barcolors[GRAD_IDX_BOTTOM])
|
||||
return a
|
||||
}
|
||||
|
||||
//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)
|
||||
func (a *AnimatedScoreBar) RefreshBar() {
|
||||
a.barbuffer.DrawImage(a.gradient.Scene, nil)
|
||||
|
||||
//mask start of the bar for smooth edges
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
@@ -80,7 +94,6 @@ func NewAnimatedScoreBar(t CharacterType, s string) *AnimatedScoreBar {
|
||||
|
||||
//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 {
|
||||
@@ -103,22 +116,43 @@ func (a *AnimatedScoreBar) GetValue() float64 {
|
||||
return a.value
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) ResetLead() {
|
||||
a.leader = false
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) SetLead() {
|
||||
a.leader = true
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) Animate() {
|
||||
|
||||
a.ScoreImage.Clear()
|
||||
|
||||
a.AddFadedTeamName()
|
||||
//a.AddFadedTeamName() //peeps complained, so we cut this
|
||||
a.AddScoreBar()
|
||||
a.AddCharacter()
|
||||
a.AddTextScore()
|
||||
a.AdjustValue()
|
||||
a.AddLeadIndicator()
|
||||
|
||||
a.CycleUpdate()
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) CycleUpdate() {
|
||||
a.cycle++
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) AddLeadIndicator() {
|
||||
if a.leader {
|
||||
a.ScoreImage.DrawImage(a.crown, nil)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
text.Draw(a.ScoreImage, a.name, DashFont.TeamBackgroundName, 1000, ASB_STRFADE_YOFFSET, c)
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) SetOrder(order int) {
|
||||
@@ -171,10 +205,34 @@ func (a *AnimatedScoreBar) AddCharacter() {
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) AddTextScore() {
|
||||
var ypos int
|
||||
M := ASB_HEIGHT
|
||||
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)
|
||||
|
||||
//animate ticker movement for the team name
|
||||
adjust := ASB_TICK_MAX_ADJUST //max adjustment
|
||||
if a.cycle%adjust < M/ASB_TICK_PIXEL_SCALE {
|
||||
ypos = 2*M - ASB_TICK_PIXEL_SCALE*a.cycle%adjust
|
||||
} else if a.cycle%adjust > 2*M/ASB_TICK_PIXEL_SCALE {
|
||||
ypos = M - (ASB_TICK_PIXEL_SCALE*(a.cycle-2*M/ASB_TICK_PIXEL_SCALE))%adjust
|
||||
} else {
|
||||
ypos = M
|
||||
}
|
||||
text.Draw(a.ScoreImage, a.name, DashFont.Title, tx, ypos, color.White)
|
||||
|
||||
//now do the same for the points, offset by the height of the ticker
|
||||
bcycle := a.cycle - M/ASB_TICK_PIXEL_SCALE
|
||||
adjust = 2 * M
|
||||
if bcycle%adjust < 2*M/ASB_TICK_PIXEL_SCALE {
|
||||
ypos = 3*M - ASB_TICK_PIXEL_SCALE*bcycle%adjust
|
||||
} else if bcycle%adjust > 3*M/ASB_TICK_PIXEL_SCALE {
|
||||
ypos = M - (ASB_TICK_PIXEL_SCALE*(bcycle-3*M/ASB_TICK_PIXEL_SCALE))%adjust //M - (pixel_scale*(bcycle-2*M/pixel_scale))%ma
|
||||
} else {
|
||||
ypos = M
|
||||
}
|
||||
text.Draw(a.ScoreImage, fmt.Sprintf("%0.f", a.value), DashFont.Title, tx, ypos, color.White)
|
||||
text.Draw(a.ScoreImage, "POINTS", DashFont.Title, tx+ASB_BUFFER*7, ypos, color.White)
|
||||
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) AdjustValue() {
|
||||
@@ -183,6 +241,7 @@ func (a *AnimatedScoreBar) AdjustValue() {
|
||||
}
|
||||
}
|
||||
|
||||
// set our point scale factor (scalefactor = pixels per score point)
|
||||
func (a *AnimatedScoreBar) SetScaleFactor(sf float64) {
|
||||
a.sf = sf
|
||||
}
|
||||
@@ -194,7 +253,19 @@ func (a *AnimatedScoreBar) Reset() {
|
||||
func init() {
|
||||
img, _, err := image.Decode(bytes.NewReader(barmaskAsset_png))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
assetsBarmask = ebiten.NewImageFromImage(img)
|
||||
|
||||
img, _, err = image.Decode(bytes.NewReader(crownAsset_png))
|
||||
if err != nil {
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
assetsCrown = ebiten.NewImageFromImage(img)
|
||||
}
|
||||
|
||||
func (a *AnimatedScoreBar) SetBarColors(top, bottom color.RGBA) {
|
||||
//fill bar gradient
|
||||
a.gradient.SetColors(top, bottom)
|
||||
a.RefreshBar()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
_ "embed"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"math/rand"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
@@ -136,13 +135,13 @@ func (c *Character) GetType() CharacterType {
|
||||
func init() {
|
||||
img, _, err := image.Decode(bytes.NewReader(assets_png))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
assetsImage = ebiten.NewImageFromImage(img)
|
||||
|
||||
img, _, err = image.Decode(bytes.NewReader(winnerAssets_png))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
assetsWinner = ebiten.NewImageFromImage(img)
|
||||
}
|
||||
|
||||
28
constants.go
28
constants.go
@@ -1,31 +1,11 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
CUSTOM_FIELD_TEAM = "customfield_14580"
|
||||
)
|
||||
|
||||
func GetDeadlineResourcePath() string {
|
||||
return "resources/settings/deadline.txt"
|
||||
func GetConfigPath() string {
|
||||
return "resources/settings/config.json"
|
||||
}
|
||||
|
||||
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 GetLogPath() string {
|
||||
return "dashboard.log"
|
||||
}
|
||||
|
||||
func GetTimerFormatString() string {
|
||||
|
||||
288
dashboard.go
288
dashboard.go
@@ -4,10 +4,9 @@ import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -19,32 +18,35 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DIMWIDTH = 1920
|
||||
DIMHEIGHT = 1080
|
||||
DASH_WIDTH = 1920
|
||||
DASH_HEIGHT = 1080
|
||||
|
||||
COLOR_GRAD_TOP = 0xff887c
|
||||
COLOR_GRAD_BOTTOM = 0xb6325f
|
||||
COLOR_GRAD_ETOP = 0xffcc33
|
||||
COLOR_GRAD_EBOTTOM = 0xcc9933
|
||||
//color codes
|
||||
DASH_COLOR_GRAD_TOP = 0x9601ce //0xff887c
|
||||
DASH_COLOR_GRAD_BOTTOM = 0x4b007a //0xb6325f
|
||||
DASH_COLOR_GRAD_ETOP = 0xffcc33
|
||||
DASH_COLOR_GRAD_EBOTTOM = 0xcc9933
|
||||
DASH_COLOR_GRAD_24TOP = 0xd42518
|
||||
DASH_COLOR_GRAD_24BOT = 0x911416
|
||||
|
||||
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
|
||||
//intervals and rates
|
||||
DASH_SCORE_UPDATE_INTERVAL = 60 * 60 * 5 //in seconds, 60 updates per second, 60 seconds per minute, 5 minutes
|
||||
DASH_TIMER_UPDATE_RATE = 3 //updates per frame
|
||||
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
|
||||
//offsets and scalers
|
||||
DASH_TIMESTR_XOFFSET = 50
|
||||
DASH_TIMESTR_YOFFSET = 40
|
||||
DASH_WINSTR_XOFFSET = 80
|
||||
DASH_WINSTR_YOFFSET = 55
|
||||
DASH_SCORE_VERTICAL_OFFSET = 40 //pixels
|
||||
DASH_REWARD_SCALE = 5
|
||||
DASH_WINNER_CHARACTER_SPACING = 150
|
||||
DASH_TIMER_XOFFSET_DIST = 500
|
||||
DASH_POOMOJI_SIN_SCALER = 10
|
||||
DASH_SCALE_FACTOR = 3 //pixels per team-points on results bar
|
||||
DASH_DEBUG_OFFSET = 50
|
||||
DASH_MAX_DEBUG_TARGET = 200
|
||||
)
|
||||
|
||||
type TeamData struct {
|
||||
@@ -60,6 +62,7 @@ type Dashboard struct {
|
||||
WindowTitle string
|
||||
Width int
|
||||
Height int
|
||||
settings *DashSettings
|
||||
|
||||
//timer and animation related
|
||||
tick int
|
||||
@@ -68,9 +71,14 @@ type Dashboard struct {
|
||||
expired bool
|
||||
aps int //animation-cycles per second
|
||||
endcondition bool
|
||||
final24 bool
|
||||
finalready bool
|
||||
|
||||
//dashboard elements and backgrounds
|
||||
gradientImage *image.RGBA
|
||||
gradient *Gradient
|
||||
|
||||
//gradientImage *image.RGBA
|
||||
|
||||
gradColorBottom color.RGBA
|
||||
gradColorTop color.RGBA
|
||||
reward *Reward
|
||||
@@ -91,13 +99,35 @@ func (d *Dashboard) Update() error {
|
||||
|
||||
//perform animation rate update check
|
||||
d.UpdateAnimationRate()
|
||||
d.UpdateTimer()
|
||||
d.UpdateScoreboardAsync()
|
||||
d.HandleInputs()
|
||||
|
||||
d.tick++
|
||||
return nil
|
||||
}
|
||||
|
||||
// instantaneous inputs, bypassing timers
|
||||
func (d *Dashboard) HandleInputs() {
|
||||
if d.expired {
|
||||
return
|
||||
}
|
||||
|
||||
/********** instantaneous score updates, bypassing timer bypass timer *****************/
|
||||
//END IMMEDIATELY
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyE) {
|
||||
d.deadline = time.Now()
|
||||
}
|
||||
|
||||
//24 HOURS REMAINING MODE
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyR) {
|
||||
//find the time.Time just under 24 hours from now, then use this to compute delta
|
||||
//between original deadline and new pre-24hour time, finally subtracting this duration
|
||||
//from the original deadline to arrive at our new, just shy of 24hour deadline
|
||||
timePlus24 := time.Now().Add(24*time.Hour + 5*time.Second)
|
||||
newoffset := d.deadline.Sub(timePlus24)
|
||||
d.deadline = d.deadline.Add(-newoffset)
|
||||
}
|
||||
|
||||
//GENERATE RANDOM SCORES
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyP) {
|
||||
d.leaderboard.RandomizeTeamTargets()
|
||||
@@ -106,49 +136,71 @@ func (d *Dashboard) Update() error {
|
||||
|
||||
//SET ALL TEAMS TO PREDETERMINED VALUE
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyL) {
|
||||
d.leaderboard.SetAllTargets(MAX_DEBUG_TARGET)
|
||||
d.leaderboard.SetAllTargets(DASH_MAX_DEBUG_TARGET)
|
||||
d.leaders = d.leaderboard.GetLeaders()
|
||||
}
|
||||
/**************************************************************************************/
|
||||
|
||||
//perform timer calculation
|
||||
if d.tick%TIMER_UPDATE_RATE == 0 {
|
||||
d.timeleft = time.Until(d.deadline)
|
||||
//CHOOSE AND SET NEW RANDOM GRADIENT COLORS FOR BG
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyPeriod) {
|
||||
d.gradient.SetColors(HexToRGBA(int(rand.Uint32())), HexToRGBA(int(rand.Uint32())))
|
||||
}
|
||||
|
||||
//reload scores in new thread
|
||||
if d.tick%SCORE_UPDATE_INTERVAL == 0 {
|
||||
//TRY AND RECONNECT TO JIRA
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyJ) {
|
||||
d.issuereader.Reconnect()
|
||||
if d.issuereader.IsAvailable() {
|
||||
go d.UpdateScoreboardAsync()
|
||||
}
|
||||
}
|
||||
|
||||
//FORCE DROP JIRA CONNECTION
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyD) {
|
||||
d.issuereader.DropClient()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (d *Dashboard) UpdateTimer() {
|
||||
//perform timer calculation if we're at the correct update interval
|
||||
if d.tick%DASH_TIMER_UPDATE_RATE == 0 {
|
||||
//except don't do it on weekends (since we're using working days weekends don't count)
|
||||
isweekend := (time.Now().Weekday() == time.Sunday || time.Now().Weekday() == time.Saturday)
|
||||
if !isweekend {
|
||||
d.timeleft = time.Until(d.deadline)
|
||||
d.expired = d.timeleft.Seconds() <= 0 //set expiry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// use thread to reload scores
|
||||
func (d *Dashboard) UpdateScoreboardAsync() {
|
||||
if d.tick%DASH_SCORE_UPDATE_INTERVAL == 0 && !d.endcondition {
|
||||
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 {
|
||||
if !d.issuereader.IsAvailable() {
|
||||
return
|
||||
}
|
||||
|
||||
if !d.expired {
|
||||
if !d.endcondition {
|
||||
scores := d.issuereader.RefreshScores()
|
||||
for k, v := range scores {
|
||||
d.leaderboard.UpdateScoreForTeam(k, v)
|
||||
//refresh could take a while, we may have actually ended in between
|
||||
if !d.endcondition {
|
||||
for k, v := range scores {
|
||||
d.leaderboard.UpdateScoreForTeam(k, v)
|
||||
}
|
||||
d.leaders = d.leaderboard.GetLeaders()
|
||||
}
|
||||
|
||||
d.leaders = d.leaderboard.GetLeaders()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dashboard) Draw(screen *ebiten.Image) {
|
||||
screen.WritePixels(d.gradientImage.Pix)
|
||||
screen.DrawImage(d.gradient.Scene, nil)
|
||||
d.DrawUpdate(screen)
|
||||
|
||||
//d.DrawDebug(screen)
|
||||
}
|
||||
|
||||
@@ -160,6 +212,22 @@ func (d *Dashboard) DrawUpdate(screen *ebiten.Image) {
|
||||
d.RenderTimer(screen)
|
||||
d.DrawLeaderboard(screen)
|
||||
}
|
||||
|
||||
d.DrawWarnings(screen)
|
||||
}
|
||||
|
||||
func (d *Dashboard) DrawWarnings(screen *ebiten.Image) {
|
||||
if d.tick%90 < 45 {
|
||||
var str string
|
||||
|
||||
if !d.issuereader.IsAvailable() {
|
||||
str = "OFFLINE MODE"
|
||||
}
|
||||
|
||||
if str != "" {
|
||||
text.Draw(screen, str, DashFont.Debug, DASH_TIMESTR_YOFFSET, DASH_TIMESTR_YOFFSET, color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dashboard) DrawLeaderboard(screen *ebiten.Image) {
|
||||
@@ -183,50 +251,55 @@ 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
|
||||
xpos := rpos.X - float64(d.reward.Width())/2*DASH_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
|
||||
ypos = rpos.Y + math.Sin((float64(d.tick)-rpos.Y)/(math.Pi*4))*DASH_POOMOJI_SIN_SCALER
|
||||
}
|
||||
|
||||
//adjust y offset to center
|
||||
ypos = ypos - float64(d.reward.Width())/2*REWARD_SCALE
|
||||
ypos = ypos - float64(d.reward.Width())/2*DASH_REWARD_SCALE
|
||||
|
||||
//apply transformation
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
op.GeoM.Scale(REWARD_SCALE, REWARD_SCALE)
|
||||
op.GeoM.Scale(DASH_REWARD_SCALE, DASH_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()
|
||||
numleaders := len(d.leaders)
|
||||
|
||||
mascot := d.winnerCharacters[i]
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
if numleaders > 0 {
|
||||
text.Draw(screen, GetWinnerText(numleaders), 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()
|
||||
|
||||
totallen := len(d.leaders) * WINNER_CHARACTER_SPACING
|
||||
mascot := d.winnerCharacters[i]
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
|
||||
xpos := d.Width/2 - totallen/2 + i*WINNER_CHARACTER_SPACING
|
||||
ypos := d.Height * 2 / 3
|
||||
totallen := numleaders * DASH_WINNER_CHARACTER_SPACING
|
||||
|
||||
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)
|
||||
xpos := d.Width/2 - totallen/2 + i*DASH_WINNER_CHARACTER_SPACING
|
||||
ypos := d.Height * 2 / 3
|
||||
|
||||
str := fmt.Sprintf("%0.fPTS", team.GetTarget())
|
||||
text.Draw(screen, str, DashFont.Points, xpos, ypos+150+20, color.Black)
|
||||
i++
|
||||
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++
|
||||
}
|
||||
} else {
|
||||
text.Draw(screen, "EVERYONE LOSES", DashFont.Debug, d.Width/2-DASH_WINSTR_XOFFSET*3/2, d.Height*2/3-DASH_WINSTR_YOFFSET, color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +316,7 @@ func (d *Dashboard) AnimateRewards() {
|
||||
// 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.gradient.SetColors(HexToRGBA(DASH_COLOR_GRAD_ETOP), HexToRGBA(DASH_COLOR_GRAD_EBOTTOM))
|
||||
d.endcondition = true
|
||||
d.tick = 0
|
||||
|
||||
@@ -268,22 +341,36 @@ func (d *Dashboard) DrawDebug(screen *ebiten.Image) {
|
||||
func (d *Dashboard) RenderTimer(screen *ebiten.Image) {
|
||||
|
||||
hoursRaw := d.timeleft.Hours()
|
||||
days := math.Floor(float64(hoursRaw) / 24)
|
||||
|
||||
//ok this bit is messy due to our lame workweek tracking, if we've got less than 5 days left, use the raw day count
|
||||
//otherwise take into account business day tracking
|
||||
days := math.Floor(float64(hoursRaw) / 24) //total number of days, including weekends
|
||||
if days > 5 {
|
||||
days = float64(GetWeekDaysUntil(d.deadline)) //weekdays only (not including holidays)
|
||||
}
|
||||
|
||||
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
|
||||
d.final24 = hoursRaw < 24
|
||||
if d.final24 && !d.finalready {
|
||||
d.leaderboard.Pinkify()
|
||||
d.gradient.SetColors(HexToRGBA(DASH_COLOR_GRAD_24TOP), HexToRGBA(DASH_COLOR_GRAD_24BOT))
|
||||
d.finalready = true
|
||||
}
|
||||
|
||||
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)
|
||||
formattedTime := fmt.Sprintf("%.f:%02d:%02d:%02d", days, hours, minutes, seconds)
|
||||
ypos := d.Height / 3
|
||||
xpos := d.Width/2 - DASH_TIMER_XOFFSET_DIST
|
||||
|
||||
text.Draw(screen, formattedTime, DashFont.PrincipleTimer, xpos, ypos, color.White)
|
||||
text.Draw(screen, GetTimerFormatString(), DashFont.Normal, d.Width/2-DASH_TIMESTR_XOFFSET, ypos+DASH_TIMESTR_YOFFSET, color.White)
|
||||
}
|
||||
|
||||
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)
|
||||
text.Draw(screen, d.settings.Labels.Completion, DashFont.Celebration, d.Width/2-600, d.Height/3, color.Black)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,13 +385,12 @@ func (d *Dashboard) SetDimensions(w, h int) {
|
||||
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)
|
||||
d.gradient.SetColors(d.gradColorTop, d.gradColorBottom)
|
||||
}
|
||||
|
||||
func (d *Dashboard) SetTime(t time.Time) {
|
||||
d.deadline = t
|
||||
d.timeleft = time.Until(d.deadline)
|
||||
}
|
||||
|
||||
// adjust animation rate based on keyboard input (up/down arrow keys)
|
||||
@@ -327,45 +413,55 @@ func (d *Dashboard) UpdateAnimationRate() {
|
||||
|
||||
// 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)
|
||||
d.issuereader = NewIssueReader(username, password, d.settings)
|
||||
}
|
||||
|
||||
func NewDashboard() *Dashboard {
|
||||
func NewDashboard(s *DashSettings) *Dashboard {
|
||||
d := &Dashboard{
|
||||
tick: 0,
|
||||
WindowTitle: GetDashboardTitle(),
|
||||
gradColorTop: HexToRGBA(COLOR_GRAD_TOP),
|
||||
gradColorBottom: HexToRGBA(COLOR_GRAD_BOTTOM),
|
||||
WindowTitle: s.Labels.Title,
|
||||
gradColorTop: HexToRGBA(DASH_COLOR_GRAD_TOP),
|
||||
gradColorBottom: HexToRGBA(DASH_COLOR_GRAD_BOTTOM),
|
||||
aps: DASH_ANIMATIONS_PER_CYCLE,
|
||||
reward: NewReward(),
|
||||
endcondition: false,
|
||||
winnerCharacters: []*Character{},
|
||||
gradient: NewGradient(DASH_WIDTH, DASH_HEIGHT),
|
||||
final24: false,
|
||||
finalready: false,
|
||||
settings: s,
|
||||
}
|
||||
|
||||
//d.AddIssueReader()
|
||||
d.AddIssueReader()
|
||||
|
||||
teamnames := GetTeamNames()
|
||||
d.leaderboard = NewLeaderboard(ASB_WIDTH, (len(teamnames)+1)*ASB_HEIGHT+ASB_BUFFER, teamnames)
|
||||
d.SetTime(d.settings.Expiry)
|
||||
d.leaderboard = NewLeaderboard(ASB_WIDTH, (len(d.settings.Teams)+1)*ASB_HEIGHT+ASB_BUFFER, d.settings.Teams)
|
||||
d.leaderboard.SetScaleFactor(DASH_SCALE_FACTOR)
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func RunDashboard(t time.Time) {
|
||||
dash := NewDashboard()
|
||||
ebiten.SetWindowSize(ebiten.ScreenSizeInFullscreen())
|
||||
func RunDashboard(s *DashSettings) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dash := NewDashboard(s)
|
||||
dash.SetDimensions(DASH_WIDTH, DASH_HEIGHT)
|
||||
|
||||
ebiten.SetWindowSize(ebiten.ScreenSizeInFullscreen())
|
||||
ebiten.SetWindowTitle(s.Labels.Title)
|
||||
|
||||
ebiten.SetWindowTitle(dash.WindowTitle)
|
||||
dash.SetDimensions(DIMWIDTH, DIMHEIGHT)
|
||||
ebiten.SetFullscreen(true)
|
||||
dash.SetTime(t)
|
||||
if err := ebiten.RunGame(dash); err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
3
flame.go
3
flame.go
@@ -5,7 +5,6 @@ import (
|
||||
_ "embed"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"log"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
)
|
||||
@@ -63,7 +62,7 @@ func (f *Flame) GetHeight() int {
|
||||
func init() {
|
||||
img, _, err := image.Decode(bytes.NewReader(flameAssets_png))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
assetsFlame = ebiten.NewImageFromImage(img)
|
||||
}
|
||||
|
||||
23
fonts.go
23
fonts.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2/examples/resources/fonts"
|
||||
"golang.org/x/image/font"
|
||||
@@ -34,7 +33,7 @@ func init() {
|
||||
DashFont = &FontBase{}
|
||||
tt, err := opentype.ParseCollection(banschrift_ttf)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
//fmt.Printf("%d\n", tt.NumFonts())
|
||||
@@ -46,7 +45,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.Expiry, err = opentype.NewFace(fnt, &opentype.FaceOptions{
|
||||
@@ -55,7 +54,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.Title, err = opentype.NewFace(fnt, &opentype.FaceOptions{
|
||||
@@ -64,7 +63,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.Normal, err = opentype.NewFace(fnt, &opentype.FaceOptions{
|
||||
@@ -73,7 +72,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.Points, err = opentype.NewFace(fnt, &opentype.FaceOptions{
|
||||
@@ -82,12 +81,12 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
p2tt, err := opentype.Parse(fonts.PressStart2P_ttf)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.Celebration, err = opentype.NewFace(p2tt, &opentype.FaceOptions{
|
||||
@@ -96,7 +95,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.TeamBackgroundName, err = opentype.NewFace(p2tt, &opentype.FaceOptions{
|
||||
@@ -105,7 +104,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.TeamBarName, err = opentype.NewFace(p2tt, &opentype.FaceOptions{
|
||||
@@ -114,7 +113,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
DashFont.Debug, err = opentype.NewFace(p2tt, &opentype.FaceOptions{
|
||||
@@ -123,7 +122,7 @@ func init() {
|
||||
Hinting: font.HintingVertical,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
56
gradient.go
56
gradient.go
@@ -3,6 +3,13 @@ package main
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
GRAD_IDX_TOP = 0
|
||||
GRAD_IDX_BOTTOM = 1
|
||||
)
|
||||
|
||||
func HexToRGBA(hexcolor int) color.RGBA {
|
||||
@@ -14,13 +21,36 @@ func HexToRGBA(hexcolor int) color.RGBA {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
type Gradient struct {
|
||||
Scene *ebiten.Image //our resultant image
|
||||
target *image.RGBA //raw pixel array
|
||||
width int
|
||||
height int
|
||||
colors []color.RGBA //gradient boundary colors
|
||||
}
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
func NewGradient(width, height int) *Gradient {
|
||||
return &Gradient{
|
||||
Scene: ebiten.NewImage(width, height),
|
||||
target: image.NewRGBA(image.Rect(0, 0, width, height)),
|
||||
width: width,
|
||||
height: height,
|
||||
colors: []color.RGBA{HexToRGBA(0xFFFFFF), HexToRGBA(0x000000)},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gradient) SetColors(top, bottom color.RGBA) {
|
||||
g.colors[GRAD_IDX_TOP] = top
|
||||
g.colors[GRAD_IDX_BOTTOM] = bottom
|
||||
g.fillCurrent()
|
||||
}
|
||||
|
||||
// performs linear fill on the currently set gradient values, top to bottom (0 to 1)
|
||||
func (g *Gradient) fill(top, bottom color.RGBA) {
|
||||
for y := 0; y < g.height; y++ {
|
||||
|
||||
//the percent of our transition informs our blending
|
||||
percentIn := float64(y) / float64(height)
|
||||
percentIn := float64(y) / float64(g.height)
|
||||
|
||||
//compute top and bottom blend values as factor of percentage
|
||||
pTopR := float64(top.R) * (1 - percentIn)
|
||||
@@ -32,12 +62,18 @@ func FillGradient(target *image.RGBA, width int, height int, top, bottom color.R
|
||||
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
|
||||
for x := 0; x < g.width; x++ {
|
||||
pixelIndex := 4 * (g.width*y + x)
|
||||
g.target.Pix[pixelIndex] = uint8(pTopR + pBottomR)
|
||||
g.target.Pix[pixelIndex+1] = uint8(pTopG + pBottomG)
|
||||
g.target.Pix[pixelIndex+2] = uint8(pTopB + pBottomB)
|
||||
g.target.Pix[pixelIndex+3] = 0xff
|
||||
}
|
||||
}
|
||||
g.Scene.WritePixels(g.target.Pix)
|
||||
}
|
||||
|
||||
// gradient on currently set colors
|
||||
func (g *Gradient) fillCurrent() {
|
||||
g.fill(g.colors[GRAD_IDX_TOP], g.colors[GRAD_IDX_BOTTOM])
|
||||
}
|
||||
|
||||
106
issuereader.go
106
issuereader.go
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -9,34 +9,53 @@ import (
|
||||
"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
|
||||
auth jira.BasicAuthTransport //auth data
|
||||
client *jira.Client //jira client
|
||||
score map[string]int //score information
|
||||
settings *DashSettings
|
||||
available bool //flag letting us know if the client is currentl available
|
||||
}
|
||||
|
||||
func NewIssueReader(user, pass string) *IssueReader {
|
||||
func NewIssueReader(user, pass string, s *DashSettings) *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)
|
||||
score: make(map[string]int),
|
||||
settings: s,
|
||||
available: false,
|
||||
}
|
||||
|
||||
ir.Reconnect()
|
||||
return ir
|
||||
}
|
||||
|
||||
func (i *IssueReader) Reconnect() {
|
||||
i.client = nil
|
||||
if i.settings.Jira.Url != "" {
|
||||
var err error
|
||||
i.client, err = jira.NewClient(i.auth.Client(), strings.TrimSpace(i.settings.Jira.Url))
|
||||
if err != nil {
|
||||
DashLogger.Log(fmt.Sprintf("%v", err))
|
||||
i.available = false
|
||||
} else {
|
||||
i.available = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forcibly drops the client (recoverable only through manual reconnection)
|
||||
func (i *IssueReader) DropClient() {
|
||||
i.client = nil
|
||||
i.available = false
|
||||
}
|
||||
|
||||
func (i *IssueReader) IsAvailable() bool {
|
||||
return i.available
|
||||
}
|
||||
|
||||
func (i *IssueReader) GetScores() map[string]int {
|
||||
return i.score
|
||||
}
|
||||
@@ -48,19 +67,34 @@ func (i *IssueReader) ZeroScores() {
|
||||
|
||||
}
|
||||
|
||||
func (i *IssueReader) BuildBountyJQL() string {
|
||||
var epiclink string
|
||||
|
||||
epiclink = "("
|
||||
for k, epicid := range i.settings.Jira.Epics {
|
||||
epiclink = epiclink + `"Epic Link" = ` + epicid
|
||||
if k != len(i.settings.Jira.Epics)-1 {
|
||||
epiclink = epiclink + " OR "
|
||||
}
|
||||
}
|
||||
epiclink = epiclink + ")"
|
||||
|
||||
query := fmt.Sprintf(`project=%s AND %s AND Status = Accepted`, i.settings.Jira.Project, epiclink)
|
||||
return query
|
||||
}
|
||||
|
||||
func (i *IssueReader) RefreshScores() map[string]int {
|
||||
|
||||
i.ZeroScores()
|
||||
|
||||
poIssues, er := i.GetAllIssues(i.client, GetBountyJQL())
|
||||
poIssues, er := i.GetAllIssues(i.client, i.BuildBountyJQL())
|
||||
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)
|
||||
DashLogger.Log(fmt.Sprintf("%v\n", er))
|
||||
} else {
|
||||
i.ZeroScores()
|
||||
//run through each to check if we've got bounty points to award
|
||||
for _, issue := range poIssues {
|
||||
teamId := i.ParseCustomTeamField(issue.Fields.Unknowns[i.settings.Jira.Teamfield])
|
||||
i.ExtractScoreFromComments(teamId, issue.Key)
|
||||
}
|
||||
}
|
||||
|
||||
return i.GetScores()
|
||||
@@ -80,7 +114,7 @@ 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)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
comments := is.Fields.Comments.Comments
|
||||
@@ -95,7 +129,7 @@ func (i *IssueReader) ExtractScoreFromComments(teamId string, key string) {
|
||||
|
||||
// check if s exists in Scrum Teams list
|
||||
func (i *IssueReader) IsValidTeam(s string) bool {
|
||||
for _, t := range GetTeamNames() {
|
||||
for _, t := range i.settings.Teams {
|
||||
if strings.Compare(strings.ToLower(s), strings.ToLower(t)) == 0 {
|
||||
return true
|
||||
}
|
||||
@@ -106,14 +140,14 @@ func (i *IssueReader) IsValidTeam(s string) bool {
|
||||
// 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+)`)
|
||||
re := regexp.MustCompile(i.settings.Criteria.Pattern)
|
||||
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 {
|
||||
//our returned matches must be larger than the specified match index
|
||||
if len(matches) > i.settings.Criteria.MatchId {
|
||||
//submatch must be an integer, or else we simply don't update the result
|
||||
a, err := strconv.Atoi(matches[1])
|
||||
a, err := strconv.Atoi(matches[i.settings.Criteria.MatchId])
|
||||
if err == nil {
|
||||
result = a
|
||||
}
|
||||
@@ -126,7 +160,7 @@ func (i *IssueReader) ExtractBountyFromComment(c *jira.Comment) int {
|
||||
// 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() {
|
||||
for _, p := range i.settings.ProductOwners {
|
||||
if c.Author.Name == p {
|
||||
return true
|
||||
}
|
||||
@@ -137,6 +171,12 @@ func (i *IssueReader) CommentAuthorIsPO(c *jira.Comment) bool {
|
||||
|
||||
// retrieve all issues as per searchString JQL
|
||||
func (i *IssueReader) GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error) {
|
||||
|
||||
if client == nil {
|
||||
i.available = false
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
last := 0
|
||||
var issues []jira.Issue
|
||||
for {
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
MAX_RANDOM_INCREMENT = 50
|
||||
LDRBRD_MAX_RANDOM_INCREMENT = 50
|
||||
LDRBRD_BAR_CLR_GRD_24TOP = 0xeb40d4
|
||||
LDRBRD_BAR_CLR_GRD_24BOT = 0xc625af
|
||||
)
|
||||
|
||||
type Leaderboard struct {
|
||||
@@ -43,12 +45,14 @@ func (l *Leaderboard) UpdateScoreForTeam(name string, score int) {
|
||||
}
|
||||
}
|
||||
|
||||
// return current list of leaders, and while we're at it, update our current lead indicators
|
||||
func (l *Leaderboard) GetLeaders() []*AnimatedScoreBar {
|
||||
leaders := []*AnimatedScoreBar{}
|
||||
|
||||
highScore := 0
|
||||
|
||||
for _, team := range l.teams {
|
||||
team.ResetLead()
|
||||
teamscore := int(team.GetTarget())
|
||||
if teamscore > highScore {
|
||||
leaders = leaders[:0]
|
||||
@@ -59,6 +63,11 @@ func (l *Leaderboard) GetLeaders() []*AnimatedScoreBar {
|
||||
}
|
||||
}
|
||||
|
||||
//for new set of leaders, set appropriate lead indicators
|
||||
for _, bar := range leaders {
|
||||
bar.SetLead()
|
||||
}
|
||||
|
||||
return leaders
|
||||
}
|
||||
|
||||
@@ -82,6 +91,12 @@ func (l *Leaderboard) SetAllTargets(t float64) {
|
||||
|
||||
func (l *Leaderboard) RandomizeTeamTargets() {
|
||||
for _, board := range l.teams {
|
||||
board.SetTarget(board.GetTarget() + float64(rand.Int()%MAX_RANDOM_INCREMENT))
|
||||
board.SetTarget(board.GetTarget() + float64(rand.Int()%LDRBRD_MAX_RANDOM_INCREMENT))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Leaderboard) Pinkify() {
|
||||
for _, board := range l.teams {
|
||||
board.SetBarColors(HexToRGBA(LDRBRD_BAR_CLR_GRD_24TOP), HexToRGBA(LDRBRD_BAR_CLR_GRD_24BOT))
|
||||
}
|
||||
}
|
||||
|
||||
46
logger.go
Normal file
46
logger.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
logpath string
|
||||
logfile []byte
|
||||
}
|
||||
|
||||
// write to our file, if it exists
|
||||
func (l *Logger) Log(str string) {
|
||||
|
||||
CheckLogPath(l.logpath)
|
||||
|
||||
var msg string
|
||||
msg = str
|
||||
os.WriteFile(l.logpath, []byte(msg), os.ModeAppend)
|
||||
}
|
||||
|
||||
// dump the err into a string, write to our log file, then pass off the error to the log package
|
||||
func (l *Logger) Fatal(err error) {
|
||||
l.Log(fmt.Sprintf("%v\n", err))
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
func NewLogger(logpath string) *Logger {
|
||||
CheckLogPath(logpath)
|
||||
|
||||
return &Logger{
|
||||
logpath: logpath,
|
||||
}
|
||||
}
|
||||
|
||||
// checks for existence of given logpath file, creates if it doesn't
|
||||
func CheckLogPath(logpath string) {
|
||||
_, err := os.Stat(logpath)
|
||||
if os.IsNotExist(err) {
|
||||
os.Create(logpath)
|
||||
} else if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
11
main.go
11
main.go
@@ -1,5 +1,14 @@
|
||||
package main
|
||||
|
||||
var DashLogger *Logger
|
||||
|
||||
func main() {
|
||||
RunDashboard(LoadTime())
|
||||
DashLogger = NewLogger(GetLogPath())
|
||||
|
||||
settings, err := LoadSettings(GetConfigPath())
|
||||
if err != nil {
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
|
||||
RunDashboard(settings)
|
||||
}
|
||||
|
||||
71
readme.md
71
readme.md
@@ -1,3 +1,70 @@
|
||||
# POBounty Dashboard
|
||||
# GERS PO Dashboard
|
||||
|
||||
Simple two scener game app using ebiten. Displays countdown until a given deadline specified in RFC3339 (/resources/settings/deadline.txt). Attempts to read scores for specific teams from Jira if such a connection is available. On timer expiry, transitions to end scene, displaying winners and associated points.
|
||||
The GERS Software PO Dashboard is intended to be a reusable board for displaying remaining time in a given work interval as well as tallying scores on accepted Jira issues. Written in go. Leverages ebitengine. Assets are a combination of original work in conjunction with free assets from itch.io.
|
||||
|
||||
## Features
|
||||
* displays countdown timer (in working days, hours, minutes, seconds) until a given deadline
|
||||
* animated score bars and overall leaderboard
|
||||
* configurable teams, score tallying, score givers
|
||||
* customizable Jira criteria for score keeping
|
||||
* celebration/completion screen, with winners identified
|
||||
* 24hr-remaining mode (ominous background)
|
||||
* offline mode (for simulation)
|
||||
|
||||
## Future Improvements:
|
||||
* configurable/customizable assets (the idea being teams can create their own)
|
||||
|
||||
## Build Steps:
|
||||
|
||||
Make sure you've got go/git installed.
|
||||
```
|
||||
git clone https://gitscm.mda.ca/brm_cg/sw-prototypes/gers-dashboard.git
|
||||
```
|
||||
|
||||
```
|
||||
cd gers-dashboard
|
||||
```
|
||||
|
||||
Retrieve module dependencies.
|
||||
```
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
And lastly build.
|
||||
```
|
||||
go build <targetname>
|
||||
```
|
||||
|
||||
Alternatively you don't have to build and can run/debug in your favorite IDE. If you're using VS Code the following launch configuration may prove useful. Note the external console; required if you are attempting to connect to a live jira instance in order to enter credentials.
|
||||
|
||||
```
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}",
|
||||
"console": "externalTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If you'd like to run this without connecting to Jira (simulated behaviours by keyboard only), you can clear the url entry in the config.json file (missing field or empty string will suffice). Keyboard inputs as follows:
|
||||
|
||||
| Key | Description |
|
||||
| ----------- | ----------- |
|
||||
| Q | Quit |
|
||||
| P | Add points to all teams randomly |
|
||||
| L | Assign 200 points to all teams |
|
||||
| R | Advance deadline to next 24hrs 5s (to preview 24hr-remaining mode) |
|
||||
| E | Transitions to end-screen (expires timer immediately) |
|
||||
| . | Randomly select new background gradient (countdown only) |
|
||||
| ArrowUp | Increase animation cycle frequency (max = ebitengine ticks per second, and this project is configured to the default 60) |
|
||||
| ArrowDown | Decrease animation cycle frequency (min = 1) |
|
||||
|
||||
|
||||

|
||||
|
||||
BIN
resources/images/crown.png
Normal file
BIN
resources/images/crown.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
resources/images/ebiten.png
Normal file
BIN
resources/images/ebiten.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
19
resources/settings/config.json
Normal file
19
resources/settings/config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"labels": {
|
||||
"title": "PI-8 Dashboard",
|
||||
"completion": "PHASE B COMPLETE"
|
||||
},
|
||||
"expiry": "2023-11-24T04:00:00.000Z",
|
||||
"jira": {
|
||||
"url": "https://jirawms.mda.ca",
|
||||
"teamfield": "customfield_14580",
|
||||
"project": "GERSSW",
|
||||
"epics": ["GERSSW-4971", "GERSSW-3759"]
|
||||
},
|
||||
"teams": ["devops","gaia", "igaluk", "orion", "pulsar", "solaris", "supernova"],
|
||||
"productowners": ["dbenavid", "el076320", "em031838", "ktsui", "ma031420"],
|
||||
"criteria": {
|
||||
"pattern": "POB: (\\d+)",
|
||||
"matchid": 1
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
2023-11-24T04:00:00.000Z
|
||||
22
reward.go
22
reward.go
@@ -5,7 +5,6 @@ import (
|
||||
_ "embed"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"log"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
)
|
||||
@@ -14,9 +13,9 @@ const (
|
||||
REWARD_HEIGHT = 32
|
||||
REWARD_WIDTH = 32
|
||||
|
||||
SHINY_ANIMATION_CYCLES = 9
|
||||
SHINY_ROW_INDEX = 11
|
||||
SPARKLE_ROW_INDEX = 7
|
||||
REWARD_FX_CYCLES = 9 //number of animation cycles for effects
|
||||
REWARD_FX_SHEEN_IDX = 11 //row offset from png, sheen effect
|
||||
REWARD_FX_SPRKL_IDX = 7 //row offset from png, sparkle effect
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,21 +50,22 @@ func (r *Reward) Animate() {
|
||||
|
||||
r.scratch.DrawImage(rewardsImage.SubImage(image.Rect(0, 0, REWARD_WIDTH, REWARD_HEIGHT)).(*ebiten.Image), nil)
|
||||
|
||||
if r.cycle < SHINY_ANIMATION_CYCLES {
|
||||
//animate the effects
|
||||
if r.cycle < REWARD_FX_CYCLES {
|
||||
//add sheen
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
op.Blend = ebiten.BlendSourceAtop
|
||||
|
||||
sx := r.cycle * REWARD_WIDTH
|
||||
sy := REWARD_HEIGHT * SHINY_ROW_INDEX
|
||||
sy := REWARD_HEIGHT * REWARD_FX_SHEEN_IDX
|
||||
|
||||
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
|
||||
//add sparkle: x postions are identical to sheen (same png sheet), just need to compute new y offsets
|
||||
spsy := REWARD_HEIGHT * REWARD_FX_SPRKL_IDX
|
||||
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)
|
||||
@@ -76,7 +76,7 @@ func (r *Reward) Animate() {
|
||||
}
|
||||
|
||||
func (r *Reward) CycleUpdate() {
|
||||
r.cycle = (r.cycle + 1) % (SHINY_ANIMATION_CYCLES * 8)
|
||||
r.cycle = (r.cycle + 1) % (REWARD_FX_CYCLES * 8)
|
||||
}
|
||||
|
||||
func (r *Reward) Width() int {
|
||||
@@ -91,13 +91,13 @@ func init() {
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(assetsReward_png))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
rewardsImage = ebiten.NewImageFromImage(img)
|
||||
|
||||
img, _, err = image.Decode(bytes.NewReader(assetsShiny_png))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
DashLogger.Fatal(err)
|
||||
}
|
||||
shinyImage = ebiten.NewImageFromImage(img)
|
||||
|
||||
|
||||
49
settings.go
Normal file
49
settings.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DashSettingsJira struct {
|
||||
Url string `json:"url"`
|
||||
Teamfield string `json:"teamfield"`
|
||||
Project string `json:"project"`
|
||||
Epics []string `json:"epics"`
|
||||
}
|
||||
|
||||
type DashSettingsSearch struct {
|
||||
Pattern string `json:"pattern"`
|
||||
MatchId int `json:"matchid"`
|
||||
}
|
||||
|
||||
type DashSettingsLabels struct {
|
||||
Title string `json:"title"`
|
||||
Completion string `json:"completion"`
|
||||
}
|
||||
|
||||
type DashSettings struct {
|
||||
Labels DashSettingsLabels `json:"labels"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
Jira DashSettingsJira `json:"jira"`
|
||||
Teams []string `json:"teams"`
|
||||
ProductOwners []string `json:"productowners"`
|
||||
Criteria DashSettingsSearch `json:"criteria"`
|
||||
}
|
||||
|
||||
func LoadSettings(path string) (*DashSettings, error) {
|
||||
var err error
|
||||
var settings *DashSettings = &DashSettings{}
|
||||
|
||||
//check file exists, load, deserialize
|
||||
_, err = os.Stat(path)
|
||||
if err == nil {
|
||||
rawbytes, err := os.ReadFile(path)
|
||||
|
||||
if err == nil {
|
||||
err = json.Unmarshal(rawbytes, settings)
|
||||
}
|
||||
}
|
||||
return settings, err
|
||||
}
|
||||
28
timer.go
28
timer.go
@@ -1,23 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
func LoadTime() time.Time {
|
||||
/*
|
||||
totally punked from stackoverflow
|
||||
https://stackoverflow.com/questions/31327124/how-to-calculate-number-of-business-days-in-golang/51607001#51607001
|
||||
*/
|
||||
func GetWeekDaysUntil(target time.Time) int {
|
||||
start := time.Now()
|
||||
offset := -int(start.Weekday())
|
||||
start.AddDate(0, 0, offset)
|
||||
|
||||
data, err := os.ReadFile(GetDeadlineResourcePath())
|
||||
if err != nil {
|
||||
data = make([]byte, 1)
|
||||
data[0] = 0
|
||||
offset += int(target.Weekday())
|
||||
if target.Weekday() == time.Sunday {
|
||||
offset++
|
||||
}
|
||||
|
||||
deadline, err := time.Parse(time.RFC3339, string(data))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return deadline
|
||||
diff := target.Sub(start).Truncate(time.Hour * 24)
|
||||
weeks := float64(diff.Hours()/24) / 7
|
||||
return int(math.Round(weeks)*5) + offset - 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user