package main import ( "bufio" _ "embed" "fmt" "image/color" "math" "math/rand" "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 ( DASH_WIDTH = 1920 DASH_HEIGHT = 1080 //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 //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 //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 { TeamName string Points int } type AllTeams struct { Teams []TeamData } type Dashboard struct { WindowTitle string Width int Height int settings *DashSettings //timer and animation related tick int deadline time.Time timeleft time.Duration expired bool aps int //animation-cycles per second endcondition bool final24 bool finalready bool //dashboard elements and backgrounds gradient *Gradient //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() d.UpdateTimer() d.UpdateScoreboardAsync() d.HandleInputs() d.tick++ return nil } // instantaneous inputs, bypassing timers func (d *Dashboard) HandleInputs() { if d.expired { return } //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() d.leaders = d.leaderboard.GetLeaders() } //SET ALL TEAMS TO PREDETERMINED VALUE if inpututil.IsKeyJustPressed(ebiten.KeyL) { d.leaderboard.SetAllTargets(DASH_MAX_DEBUG_TARGET) d.leaders = d.leaderboard.GetLeaders() } //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()))) } //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() } } // update score from jira actuals func (d *Dashboard) UpdateScoreboard() { if !d.issuereader.IsAvailable() { return } if !d.endcondition { scores := d.issuereader.RefreshScores() //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() } } } func (d *Dashboard) Draw(screen *ebiten.Image) { screen.DrawImage(d.gradient.Scene, nil) 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) } 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) { 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*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))*DASH_POOMOJI_SIN_SCALER } //adjust y offset to center ypos = ypos - float64(d.reward.Width())/2*DASH_REWARD_SCALE //apply transformation op := &ebiten.DrawImageOptions{} 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) { numleaders := len(d.leaders) 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() mascot := d.winnerCharacters[i] op := &ebiten.DrawImageOptions{} totallen := numleaders * DASH_WINNER_CHARACTER_SPACING xpos := d.Width/2 - totallen/2 + i*DASH_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++ } } 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) } } // 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 { d.gradient.SetColors(HexToRGBA(DASH_COLOR_GRAD_ETOP), HexToRGBA(DASH_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() //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 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 } 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, d.settings.Labels.Completion, 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.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) 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, d.settings) } func NewDashboard(s *DashSettings) *Dashboard { d := &Dashboard{ tick: 0, 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.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(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.SetFullscreen(true) if err := ebiten.RunGame(dash); err != nil { DashLogger.Fatal(err) } }