FPS counter in settings

This commit is contained in:
2025-11-19 09:57:16 -07:00
parent 1098e383ce
commit a7e6f4e0bf
6 changed files with 279 additions and 98 deletions

View File

@@ -11,7 +11,7 @@ import (
func main() { func main() {
ebiten.SetWindowSize(game.ScreenWidth, game.ScreenHeight) ebiten.SetWindowSize(game.ScreenWidth, game.ScreenHeight)
ebiten.SetWindowTitle(game.WindowTitle) ebiten.SetWindowTitle(game.WindowTitle)
ebiten.SetTPS(game.TargetTPS) ebiten.SetVsyncEnabled(false)
if err := ebiten.RunGame(game.New()); err != nil { if err := ebiten.RunGame(game.New()); err != nil {
log.Fatal(err) log.Fatal(err)

View File

@@ -1,6 +1,7 @@
package game package game
import ( import (
"fmt"
"image/color" "image/color"
"time" "time"
@@ -13,10 +14,7 @@ import (
"github.com/atridad/BigFeelings/internal/ui/menu" "github.com/atridad/BigFeelings/internal/ui/menu"
) )
// ============================================================ // Game settings.
// CONFIGURATION
// Tweak these values to change game settings
// ============================================================
const ( const (
ScreenWidth = 960 ScreenWidth = 960
@@ -25,11 +23,51 @@ const (
WindowTitle = "Big Feelings" 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 ( var (
backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255} backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255}
) )
// Hero configuration // Hero settings.
const ( const (
heroStartX = ScreenWidth / 2 heroStartX = ScreenWidth / 2
heroStartY = ScreenHeight / 2 heroStartY = ScreenHeight / 2
@@ -44,26 +82,28 @@ var (
heroColor = color.NRGBA{R: 210, G: 220, B: 255, A: 255} heroColor = color.NRGBA{R: 210, G: 220, B: 255, A: 255}
) )
// HUD configuration // HUD settings.
const ( const (
hudX = ScreenWidth - 220 hudX = ScreenWidth - 220
hudY = 20 hudY = 20
) )
// Stamina bar colors // HUD colors.
var ( var (
staminaNormalColor = color.NRGBA{R: 0, G: 255, B: 180, A: 255} staminaNormalColor = color.NRGBA{R: 0, G: 255, B: 180, A: 255}
staminaLowColor = color.NRGBA{R: 255, G: 60, B: 60, 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 ( const (
staminaLowThreshold = 0.2 staminaLowThreshold = 0.2
fpsWarnThreshold = 0.85
fpsPoorThreshold = 0.6
fpsSampleWindow = time.Second
) )
// ============================================================
// TYPES
// ============================================================
type gameState int type gameState int
const ( const (
@@ -90,19 +130,20 @@ type state struct {
lastTick time.Time lastTick time.Time
pauseMenu *menu.PauseMenu pauseMenu *menu.PauseMenu
gameState gameState gameState gameState
fpsEnabled bool
fpsFrames int
fpsAccumulator time.Duration
fpsValue float64
fpsCap FPSCap
} }
// ============================================================
// INITIALIZATION
// ============================================================
func New() *Game { func New() *Game {
return &Game{state: newState()} return &Game{state: newState()}
} }
func newState() *state { func newState() *state {
now := time.Now() now := time.Now()
return &state{ s := &state{
hero: hero.New(hero.Config{ hero: hero.New(hero.Config{
StartX: heroStartX, StartX: heroStartX,
StartY: heroStartY, StartY: heroStartY,
@@ -125,13 +166,15 @@ func newState() *state {
lastTick: now, lastTick: now,
pauseMenu: menu.NewPauseMenu(), pauseMenu: menu.NewPauseMenu(),
gameState: statePlaying, 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 { func readControls() controls {
return controls{ return controls{
Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA), Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA),
@@ -142,11 +185,15 @@ func readControls() controls {
} }
} }
// ============================================================
// UPDATE
// ============================================================
func (g *Game) Update() error { 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 inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
if g.state.gameState == statePlaying { if g.state.gameState == statePlaying {
g.state.gameState = statePaused 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 { if g.state.gameState == statePlaying {
g.state.update(readControls()) g.state.update(readControls())
} else if g.state.gameState == statePaused { } else if g.state.gameState == statePaused {
@@ -188,9 +241,20 @@ func (s *state) update(input controls) {
}, dt, s.bounds) }, dt, s.bounds)
} }
// ============================================================ func (s *state) trackFPS(delta time.Duration) {
// RENDERING 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) { func (g *Game) Draw(screen *ebiten.Image) {
g.state.draw(screen) g.state.draw(screen)
@@ -211,13 +275,46 @@ func (s *state) draw(screen *ebiten.Image) {
Level: s.hero.Stamina, Level: s.hero.Stamina,
Color: staminaColor, 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 { if s.gameState == statePaused {
s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight) 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) { func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return ScreenWidth, ScreenHeight return ScreenWidth, ScreenHeight
} }

View File

@@ -7,10 +7,7 @@ import (
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
) )
// ============================================================ // Default values and gameplay constants.
// CONFIGURATION
// Tweak these values to change gameplay behavior
// ============================================================
const ( const (
// Default values if not specified in config // Default values if not specified in config
@@ -32,10 +29,6 @@ const (
exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina
) )
// ============================================================
// TYPES
// ============================================================
type Input struct { type Input struct {
Left bool Left bool
Right bool Right bool
@@ -104,10 +97,6 @@ type Config struct {
StaminaRegen float64 StaminaRegen float64
} }
// ============================================================
// INITIALIZATION
// ============================================================
func New(cfg Config) *Hero { func New(cfg Config) *Hero {
if cfg.Radius <= 0 { if cfg.Radius <= 0 {
cfg.Radius = defaultRadius cfg.Radius = defaultRadius
@@ -146,10 +135,6 @@ func New(cfg Config) *Hero {
} }
} }
// ============================================================
// UPDATE
// ============================================================
func (h *Hero) Update(input Input, dt float64, bounds Bounds) { func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
h.updateMovement(input, dt, bounds) h.updateMovement(input, dt, bounds)
h.updateStamina(input, dt) h.updateStamina(input, dt)
@@ -242,10 +227,6 @@ func (h *Hero) updateAnimation(input Input, dt float64) {
} }
} }
// ============================================================
// STATE
// ============================================================
func (h *Hero) getVisualState() VisualState { func (h *Hero) getVisualState() VisualState {
if h.Stamina < h.MaxStamina*exhaustedThreshold { if h.Stamina < h.MaxStamina*exhaustedThreshold {
return StateExhausted return StateExhausted
@@ -258,10 +239,6 @@ func (h *Hero) getVisualState() VisualState {
return StateIdle return StateIdle
} }
// ============================================================
// RENDERING
// ============================================================
func (h *Hero) Draw(screen *ebiten.Image) { func (h *Hero) Draw(screen *ebiten.Image) {
sprite := h.getCurrentSprite() sprite := h.getCurrentSprite()
@@ -319,10 +296,6 @@ func (h *Hero) getCurrentSprite() *ebiten.Image {
return sprite return sprite
} }
// ============================================================
// UTILITIES
// ============================================================
func clamp(value, min, max float64) float64 { func clamp(value, min, max float64) float64 {
if value < min { if value < min {
return min return min

View File

@@ -78,8 +78,15 @@ func (m MeterLabel) Draw(screen *ebiten.Image, x, y int) (int, int) {
if m.Color == nil { if m.Color == nil {
m.Color = color.White m.Color = color.White
} }
txt := fmt.Sprintf("%s: %3.0f%%", m.Meter.Label, m.Meter.Level) var txt string
drawHUDText(screen, txt, m.Color, x, y) 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 return len(txt) * 7, 13
} }

View File

@@ -34,6 +34,13 @@ func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) {
// Meter column // Meter column
meterElements := make([]Element, 0, len(meters)) meterElements := make([]Element, 0, len(meters))
for _, meter := range meters { for _, meter := range meters {
if meter.Base < 0 {
// Text-only display (no bar).
meterElements = append(meterElements,
MeterLabel{Meter: meter, Color: o.Color},
)
} else {
// Full meter with bar.
meterElements = append(meterElements, Column{ meterElements = append(meterElements, Column{
Elements: []Element{ Elements: []Element{
MeterLabel{Meter: meter, Color: o.Color}, MeterLabel{Meter: meter, Color: o.Color},
@@ -42,6 +49,7 @@ func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) {
Spacing: 2, Spacing: 2,
}) })
} }
}
meterPanel := Column{ meterPanel := Column{
Elements: meterElements, Elements: meterElements,

View File

@@ -42,41 +42,67 @@ const (
optionCount optionCount
) )
type menuScreen int
const (
screenMain menuScreen = iota
screenSettings
)
type PauseMenu struct { type PauseMenu struct {
selectedIndex int selectedIndex int
showWIP bool currentScreen menuScreen
fpsMonitorValue *bool
fpsCapValue FPSCapSetting
}
type FPSCapSetting interface {
String() string
Cycle()
} }
func NewPauseMenu() *PauseMenu { func NewPauseMenu() *PauseMenu {
return &PauseMenu{ return &PauseMenu{
selectedIndex: 0, 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 // Returns the selected option if one was chosen, nil otherwise
func (m *PauseMenu) Update() *MenuOption { 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) { if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) {
m.selectedIndex-- m.selectedIndex--
if m.selectedIndex < 0 { if m.selectedIndex < 0 {
m.selectedIndex = int(optionCount) - 1 m.selectedIndex = int(optionCount) - 1
} }
m.showWIP = false
} }
if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) { if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) {
m.selectedIndex++ m.selectedIndex++
if m.selectedIndex >= int(optionCount) { if m.selectedIndex >= int(optionCount) {
m.selectedIndex = 0 m.selectedIndex = 0
} }
m.showWIP = false
} }
// Handle selection
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) { if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) {
selected := MenuOption(m.selectedIndex) selected := MenuOption(m.selectedIndex)
if selected == OptionSettings { if selected == OptionSettings {
m.showWIP = true m.currentScreen = screenSettings
m.selectedIndex = 0
return nil return nil
} }
return &selected return &selected
@@ -85,19 +111,48 @@ func (m *PauseMenu) Update() *MenuOption {
return nil 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) { func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) {
// Draw semi-transparent overlay
if overlay := getOverlayImage(screenWidth, screenHeight); overlay != nil { if overlay := getOverlayImage(screenWidth, screenHeight); overlay != nil {
screen.DrawImage(overlay, nil) screen.DrawImage(overlay, nil)
} }
// Menu dimensions
menuWidth := 400 menuWidth := 400
menuHeight := 300 menuHeight := 300
menuX := (screenWidth - menuWidth) / 2 menuX := (screenWidth - menuWidth) / 2
menuY := (screenHeight - menuHeight) / 2 menuY := (screenHeight - menuHeight) / 2
// Draw menu background
vector.DrawFilledRect(screen, vector.DrawFilledRect(screen,
float32(menuX), float32(menuY), float32(menuX), float32(menuY),
float32(menuWidth), float32(menuHeight), float32(menuWidth), float32(menuHeight),
@@ -105,7 +160,6 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) {
false, false,
) )
// Draw menu border
vector.StrokeRect(screen, vector.StrokeRect(screen,
float32(menuX), float32(menuY), float32(menuX), float32(menuY),
float32(menuWidth), float32(menuHeight), float32(menuWidth), float32(menuHeight),
@@ -114,13 +168,19 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) {
false, 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" titleText := "PAUSED"
titleX := menuX + (menuWidth / 2) - (len(titleText) * 7 / 2) titleX := menuX + (menuWidth / 2) - (len(titleText) * 7 / 2)
titleY := menuY + 50 titleY := menuY + 50
m.drawText(screen, titleText, color.White, titleX, titleY) m.drawText(screen, titleText, color.White, titleX, titleY)
// Draw menu options
options := []string{"Resume", "Settings", "Quit"} options := []string{"Resume", "Settings", "Quit"}
startY := menuY + 110 startY := menuY + 110
@@ -128,7 +188,6 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) {
optionY := startY + (i * 40) optionY := startY + (i * 40)
optionX := menuX + (menuWidth / 2) - (len(option) * 7 / 2) optionX := menuX + (menuWidth / 2) - (len(option) * 7 / 2)
// Draw selection indicator
if i == m.selectedIndex { if i == m.selectedIndex {
indicatorX := optionX - 20 indicatorX := optionX - 20
m.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, optionY) 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 hintText := "Use Arrow Keys/WASD to navigate, Enter/Space to select"
if m.showWIP { hintX := menuX + (menuWidth / 2) - (len(hintText) * 7 / 2)
wipY := startY + 140 hintY := menuY + menuHeight - 30
wipText := "Work In Progress" m.drawText(screen, hintText, color.RGBA{R: 150, G: 150, B: 150, A: 255}, hintX, hintY)
wipX := menuX + (menuWidth / 2) - (len(wipText) * 7 / 2) }
m.drawText(screen, wipText, color.RGBA{R: 255, G: 150, B: 0, A: 255}, wipX, wipY)
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 if m.selectedIndex == 0 {
hintText := "Use Arrow Keys/WASD to navigate, Enter/Space to select" 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) hintX := menuX + (menuWidth / 2) - (len(hintText) * 7 / 2)
hintY := menuY + menuHeight - 30 hintY := menuY + menuHeight - 30
m.drawText(screen, hintText, color.RGBA{R: 150, G: 150, B: 150, A: 255}, hintX, hintY) 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() { func (m *PauseMenu) Reset() {
m.selectedIndex = 0 m.selectedIndex = 0
m.showWIP = false m.currentScreen = screenMain
} }