From a7e6f4e0bf7397880db34b83005b1cca14554df1 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Wed, 19 Nov 2025 09:57:16 -0700 Subject: [PATCH] FPS counter in settings --- cmd/bigfeelings/main.go | 2 +- internal/game/game.go | 171 ++++++++++++++++++++++++++++-------- internal/hero/hero.go | 29 +----- internal/ui/hud/elements.go | 11 ++- internal/ui/hud/hud.go | 20 +++-- internal/ui/menu/menu.go | 144 +++++++++++++++++++++++++----- 6 files changed, 279 insertions(+), 98 deletions(-) diff --git a/cmd/bigfeelings/main.go b/cmd/bigfeelings/main.go index 0bb38e9..d52fbe8 100644 --- a/cmd/bigfeelings/main.go +++ b/cmd/bigfeelings/main.go @@ -11,7 +11,7 @@ import ( func main() { ebiten.SetWindowSize(game.ScreenWidth, game.ScreenHeight) ebiten.SetWindowTitle(game.WindowTitle) - ebiten.SetTPS(game.TargetTPS) + ebiten.SetVsyncEnabled(false) if err := ebiten.RunGame(game.New()); err != nil { log.Fatal(err) diff --git a/internal/game/game.go b/internal/game/game.go index 745ce56..fa22a94 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -1,6 +1,7 @@ package game import ( + "fmt" "image/color" "time" @@ -13,10 +14,7 @@ import ( "github.com/atridad/BigFeelings/internal/ui/menu" ) -// ============================================================ -// CONFIGURATION -// Tweak these values to change game settings -// ============================================================ +// Game settings. const ( ScreenWidth = 960 @@ -25,11 +23,51 @@ const ( WindowTitle = "Big Feelings" ) +// FPS cap options. +type FPSCap int + +const ( + FPSCap60 FPSCap = iota + FPSCap120 + FPSCapUncapped + fpsCapCount +) + +func (f FPSCap) TPS() int { + switch f { + case FPSCap60: + return 60 + case FPSCap120: + return 120 + case FPSCapUncapped: + return -1 + default: + return 60 + } +} + +func (f FPSCap) String() string { + switch f { + case FPSCap60: + return "60 FPS" + case FPSCap120: + return "120 FPS" + case FPSCapUncapped: + return "Uncapped" + default: + return "60 FPS" + } +} + +func (f *FPSCap) Cycle() { + *f = (*f + 1) % fpsCapCount +} + var ( backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255} ) -// Hero configuration +// Hero settings. const ( heroStartX = ScreenWidth / 2 heroStartY = ScreenHeight / 2 @@ -44,26 +82,28 @@ var ( heroColor = color.NRGBA{R: 210, G: 220, B: 255, A: 255} ) -// HUD configuration +// HUD settings. const ( hudX = ScreenWidth - 220 hudY = 20 ) -// Stamina bar colors +// HUD colors. var ( staminaNormalColor = color.NRGBA{R: 0, G: 255, B: 180, A: 255} staminaLowColor = color.NRGBA{R: 255, G: 60, B: 60, A: 255} + fpsGoodColor = color.NRGBA{R: 120, G: 255, B: 120, A: 255} + fpsWarnColor = color.NRGBA{R: 255, G: 210, B: 100, A: 255} + fpsPoorColor = color.NRGBA{R: 255, G: 120, B: 120, A: 255} ) const ( staminaLowThreshold = 0.2 + fpsWarnThreshold = 0.85 + fpsPoorThreshold = 0.6 + fpsSampleWindow = time.Second ) -// ============================================================ -// TYPES -// ============================================================ - type gameState int const ( @@ -84,25 +124,26 @@ type Game struct { } type state struct { - hero *hero.Hero - hud hud.Overlay - bounds hero.Bounds - lastTick time.Time - pauseMenu *menu.PauseMenu - gameState gameState + hero *hero.Hero + hud hud.Overlay + bounds hero.Bounds + lastTick time.Time + pauseMenu *menu.PauseMenu + gameState gameState + fpsEnabled bool + fpsFrames int + fpsAccumulator time.Duration + fpsValue float64 + fpsCap FPSCap } -// ============================================================ -// INITIALIZATION -// ============================================================ - func New() *Game { return &Game{state: newState()} } func newState() *state { now := time.Now() - return &state{ + s := &state{ hero: hero.New(hero.Config{ StartX: heroStartX, StartY: heroStartY, @@ -122,16 +163,18 @@ func newState() *state { Width: ScreenWidth, Height: ScreenHeight, }, - lastTick: now, - pauseMenu: menu.NewPauseMenu(), - gameState: statePlaying, + lastTick: now, + pauseMenu: menu.NewPauseMenu(), + gameState: statePlaying, + fpsEnabled: false, + fpsCap: FPSCap60, } + s.pauseMenu.SetFPSMonitor(&s.fpsEnabled) + s.pauseMenu.SetFPSCap(&s.fpsCap) + ebiten.SetTPS(s.fpsCap.TPS()) + return s } -// ============================================================ -// INPUT -// ============================================================ - func readControls() controls { return controls{ Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA), @@ -142,11 +185,15 @@ func readControls() controls { } } -// ============================================================ -// UPDATE -// ============================================================ - func (g *Game) Update() error { + // Update TPS if FPS cap changed. + currentTPS := g.state.fpsCap.TPS() + if currentTPS < 0 { + ebiten.SetTPS(ebiten.SyncWithFPS) + } else { + ebiten.SetTPS(currentTPS) + } + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { if g.state.gameState == statePlaying { g.state.gameState = statePaused @@ -157,6 +204,12 @@ func (g *Game) Update() error { } } + // Track FPS. + now := time.Now() + if !g.state.lastTick.IsZero() { + g.state.trackFPS(now.Sub(g.state.lastTick)) + } + if g.state.gameState == statePlaying { g.state.update(readControls()) } else if g.state.gameState == statePaused { @@ -188,9 +241,20 @@ func (s *state) update(input controls) { }, dt, s.bounds) } -// ============================================================ -// RENDERING -// ============================================================ +func (s *state) trackFPS(delta time.Duration) { + if !s.fpsEnabled { + return + } + + s.fpsAccumulator += delta + s.fpsFrames++ + + if s.fpsAccumulator >= fpsSampleWindow { + s.fpsValue = float64(s.fpsFrames) / s.fpsAccumulator.Seconds() + s.fpsAccumulator = 0 + s.fpsFrames = 0 + } +} func (g *Game) Draw(screen *ebiten.Image) { g.state.draw(screen) @@ -211,13 +275,46 @@ func (s *state) draw(screen *ebiten.Image) { Level: s.hero.Stamina, Color: staminaColor, } - s.hud.Draw(screen, []status.Meter{staminaMeter}) + + meters := []status.Meter{staminaMeter} + + if s.fpsEnabled { + // Color based on target FPS (60). + ratio := s.fpsValue / float64(TargetTPS) + fpsColor := fpsGoodColor + switch { + case ratio < fpsPoorThreshold: + fpsColor = fpsPoorColor + case ratio < fpsWarnThreshold: + fpsColor = fpsWarnColor + } + + fpsMeter := status.Meter{ + Label: fmt.Sprintf("Framerate: %3.0f FPS", s.fpsValue), + Base: -1, // Negative base means text-only display. + Level: 0, + Color: fpsColor, + } + meters = append(meters, fpsMeter) + } + + s.hud.Draw(screen, meters) if s.gameState == statePaused { s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight) } } +func clampFloat(value, min, max float64) float64 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenWidth, ScreenHeight } diff --git a/internal/hero/hero.go b/internal/hero/hero.go index dda8190..1230c8c 100644 --- a/internal/hero/hero.go +++ b/internal/hero/hero.go @@ -7,10 +7,7 @@ import ( "github.com/hajimehoshi/ebiten/v2" ) -// ============================================================ -// CONFIGURATION -// Tweak these values to change gameplay behavior -// ============================================================ +// Default values and gameplay constants. const ( // Default values if not specified in config @@ -32,10 +29,6 @@ const ( exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina ) -// ============================================================ -// TYPES -// ============================================================ - type Input struct { Left bool Right bool @@ -104,10 +97,6 @@ type Config struct { StaminaRegen float64 } -// ============================================================ -// INITIALIZATION -// ============================================================ - func New(cfg Config) *Hero { if cfg.Radius <= 0 { cfg.Radius = defaultRadius @@ -146,10 +135,6 @@ func New(cfg Config) *Hero { } } -// ============================================================ -// UPDATE -// ============================================================ - func (h *Hero) Update(input Input, dt float64, bounds Bounds) { h.updateMovement(input, dt, bounds) h.updateStamina(input, dt) @@ -242,10 +227,6 @@ func (h *Hero) updateAnimation(input Input, dt float64) { } } -// ============================================================ -// STATE -// ============================================================ - func (h *Hero) getVisualState() VisualState { if h.Stamina < h.MaxStamina*exhaustedThreshold { return StateExhausted @@ -258,10 +239,6 @@ func (h *Hero) getVisualState() VisualState { return StateIdle } -// ============================================================ -// RENDERING -// ============================================================ - func (h *Hero) Draw(screen *ebiten.Image) { sprite := h.getCurrentSprite() @@ -319,10 +296,6 @@ func (h *Hero) getCurrentSprite() *ebiten.Image { return sprite } -// ============================================================ -// UTILITIES -// ============================================================ - func clamp(value, min, max float64) float64 { if value < min { return min diff --git a/internal/ui/hud/elements.go b/internal/ui/hud/elements.go index c7795d5..a370692 100644 --- a/internal/ui/hud/elements.go +++ b/internal/ui/hud/elements.go @@ -78,8 +78,15 @@ func (m MeterLabel) Draw(screen *ebiten.Image, x, y int) (int, int) { if m.Color == nil { m.Color = color.White } - txt := fmt.Sprintf("%s: %3.0f%%", m.Meter.Label, m.Meter.Level) - drawHUDText(screen, txt, m.Color, x, y) + var txt string + if m.Meter.Base < 0 { + // Text-only display without percentage. + txt = m.Meter.Label + } else { + // Standard meter with percentage. + txt = fmt.Sprintf("%s: %3.0f%%", m.Meter.Label, m.Meter.Level) + } + drawHUDText(screen, txt, m.Meter.Color, x, y) return len(txt) * 7, 13 } diff --git a/internal/ui/hud/hud.go b/internal/ui/hud/hud.go index b7628d0..967b9ad 100644 --- a/internal/ui/hud/hud.go +++ b/internal/ui/hud/hud.go @@ -34,13 +34,21 @@ func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) { // Meter column meterElements := make([]Element, 0, len(meters)) for _, meter := range meters { - meterElements = append(meterElements, Column{ - Elements: []Element{ + if meter.Base < 0 { + // Text-only display (no bar). + meterElements = append(meterElements, MeterLabel{Meter: meter, Color: o.Color}, - Bar{Meter: meter, MaxWidth: 180, Height: 8, ShowBorder: false}, - }, - Spacing: 2, - }) + ) + } else { + // Full meter with bar. + meterElements = append(meterElements, Column{ + Elements: []Element{ + MeterLabel{Meter: meter, Color: o.Color}, + Bar{Meter: meter, MaxWidth: 180, Height: 8, ShowBorder: false}, + }, + Spacing: 2, + }) + } } meterPanel := Column{ diff --git a/internal/ui/menu/menu.go b/internal/ui/menu/menu.go index 3f0eb4d..3b1c277 100644 --- a/internal/ui/menu/menu.go +++ b/internal/ui/menu/menu.go @@ -42,41 +42,67 @@ const ( optionCount ) +type menuScreen int + +const ( + screenMain menuScreen = iota + screenSettings +) + type PauseMenu struct { - selectedIndex int - showWIP bool + selectedIndex int + currentScreen menuScreen + fpsMonitorValue *bool + fpsCapValue FPSCapSetting +} + +type FPSCapSetting interface { + String() string + Cycle() } func NewPauseMenu() *PauseMenu { return &PauseMenu{ selectedIndex: 0, - showWIP: false, + currentScreen: screenMain, } } +func (m *PauseMenu) SetFPSMonitor(enabled *bool) { + m.fpsMonitorValue = enabled +} + +func (m *PauseMenu) SetFPSCap(cap FPSCapSetting) { + m.fpsCapValue = cap +} + // Returns the selected option if one was chosen, nil otherwise func (m *PauseMenu) Update() *MenuOption { - // Handle up/down navigation + if m.currentScreen == screenSettings { + return m.updateSettings() + } + return m.updateMain() +} + +func (m *PauseMenu) updateMain() *MenuOption { if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) { m.selectedIndex-- if m.selectedIndex < 0 { m.selectedIndex = int(optionCount) - 1 } - m.showWIP = false } if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) { m.selectedIndex++ if m.selectedIndex >= int(optionCount) { m.selectedIndex = 0 } - m.showWIP = false } - // Handle selection if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) { selected := MenuOption(m.selectedIndex) if selected == OptionSettings { - m.showWIP = true + m.currentScreen = screenSettings + m.selectedIndex = 0 return nil } return &selected @@ -85,19 +111,48 @@ func (m *PauseMenu) Update() *MenuOption { return nil } +func (m *PauseMenu) updateSettings() *MenuOption { + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + m.currentScreen = screenMain + m.selectedIndex = 0 + return nil + } + + settingsCount := 2 + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) { + m.selectedIndex-- + if m.selectedIndex < 0 { + m.selectedIndex = 0 + } + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) { + m.selectedIndex++ + if m.selectedIndex >= settingsCount { + m.selectedIndex = settingsCount - 1 + } + } + + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) { + if m.selectedIndex == 0 && m.fpsMonitorValue != nil { + *m.fpsMonitorValue = !*m.fpsMonitorValue + } else if m.selectedIndex == 1 && m.fpsCapValue != nil { + m.fpsCapValue.Cycle() + } + } + + return nil +} + func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { - // Draw semi-transparent overlay if overlay := getOverlayImage(screenWidth, screenHeight); overlay != nil { screen.DrawImage(overlay, nil) } - // Menu dimensions menuWidth := 400 menuHeight := 300 menuX := (screenWidth - menuWidth) / 2 menuY := (screenHeight - menuHeight) / 2 - // Draw menu background vector.DrawFilledRect(screen, float32(menuX), float32(menuY), float32(menuWidth), float32(menuHeight), @@ -105,7 +160,6 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { false, ) - // Draw menu border vector.StrokeRect(screen, float32(menuX), float32(menuY), float32(menuWidth), float32(menuHeight), @@ -114,13 +168,19 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { false, ) - // Draw title + if m.currentScreen == screenSettings { + m.drawSettings(screen, menuX, menuY, menuWidth, menuHeight) + } else { + m.drawMain(screen, menuX, menuY, menuWidth, menuHeight) + } +} + +func (m *PauseMenu) drawMain(screen *ebiten.Image, menuX, menuY, menuWidth, menuHeight int) { titleText := "PAUSED" titleX := menuX + (menuWidth / 2) - (len(titleText) * 7 / 2) titleY := menuY + 50 m.drawText(screen, titleText, color.White, titleX, titleY) - // Draw menu options options := []string{"Resume", "Settings", "Quit"} startY := menuY + 110 @@ -128,7 +188,6 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { optionY := startY + (i * 40) optionX := menuX + (menuWidth / 2) - (len(option) * 7 / 2) - // Draw selection indicator if i == m.selectedIndex { indicatorX := optionX - 20 m.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, optionY) @@ -138,16 +197,53 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { } } - // Draw WIP message if settings was selected - if m.showWIP { - wipY := startY + 140 - wipText := "Work In Progress" - wipX := menuX + (menuWidth / 2) - (len(wipText) * 7 / 2) - m.drawText(screen, wipText, color.RGBA{R: 255, G: 150, B: 0, A: 255}, wipX, wipY) + hintText := "Use Arrow Keys/WASD to navigate, Enter/Space to select" + hintX := menuX + (menuWidth / 2) - (len(hintText) * 7 / 2) + hintY := menuY + menuHeight - 30 + m.drawText(screen, hintText, color.RGBA{R: 150, G: 150, B: 150, A: 255}, hintX, hintY) +} + +func (m *PauseMenu) drawSettings(screen *ebiten.Image, menuX, menuY, menuWidth, menuHeight int) { + titleText := "SETTINGS" + titleX := menuX + (menuWidth / 2) - (len(titleText) * 7 / 2) + titleY := menuY + 50 + m.drawText(screen, titleText, color.White, titleX, titleY) + + startY := menuY + 110 + leftMargin := menuX + 40 + + fpsMonitorText := "FPS Monitor: " + if m.fpsMonitorValue != nil && *m.fpsMonitorValue { + fpsMonitorText += "ON" + } else { + fpsMonitorText += "OFF" } - // Draw controls hint at bottom - hintText := "Use Arrow Keys/WASD to navigate, Enter/Space to select" + if m.selectedIndex == 0 { + indicatorX := leftMargin - 20 + m.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, startY) + m.drawText(screen, fpsMonitorText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, startY) + } else { + m.drawText(screen, fpsMonitorText, color.RGBA{R: 180, G: 180, B: 180, A: 255}, leftMargin, startY) + } + + fpsCapText := "FPS Cap: " + if m.fpsCapValue != nil { + fpsCapText += m.fpsCapValue.String() + } else { + fpsCapText += "60 FPS" + } + + capY := startY + 30 + if m.selectedIndex == 1 { + indicatorX := leftMargin - 20 + m.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, capY) + m.drawText(screen, fpsCapText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, capY) + } else { + m.drawText(screen, fpsCapText, color.RGBA{R: 180, G: 180, B: 180, A: 255}, leftMargin, capY) + } + + hintText := "Enter/Space to toggle, ESC to go back" hintX := menuX + (menuWidth / 2) - (len(hintText) * 7 / 2) hintY := menuY + menuHeight - 30 m.drawText(screen, hintText, color.RGBA{R: 150, G: 150, B: 150, A: 255}, hintX, hintY) @@ -162,5 +258,5 @@ func (m *PauseMenu) drawText(screen *ebiten.Image, txt string, clr color.Color, func (m *PauseMenu) Reset() { m.selectedIndex = 0 - m.showWIP = false + m.currentScreen = screenMain }