package game import ( "fmt" "image/color" "time" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/atridad/BigFeelings/internal/hero" "github.com/atridad/BigFeelings/internal/status" "github.com/atridad/BigFeelings/internal/ui/hud" "github.com/atridad/BigFeelings/internal/ui/menu" ) // Game settings. const ( ScreenWidth = 960 ScreenHeight = 540 TargetTPS = 60 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 settings. const ( heroStartX = ScreenWidth / 2 heroStartY = ScreenHeight / 2 heroRadius = 28.0 heroSpeed = 180.0 heroMaxStamina = 100.0 heroStaminaDrain = 50.0 heroStaminaRegen = 30.0 ) var ( heroColor = color.NRGBA{R: 210, G: 220, B: 255, A: 255} ) // HUD settings. const ( hudX = ScreenWidth - 220 hudY = 20 ) // 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 ) type gameState int const ( statePlaying gameState = iota statePaused ) type controls struct { Left bool Right bool Up bool Down bool Sprint bool } type Game struct { state *state } type state struct { 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 } func New() *Game { return &Game{state: newState()} } func newState() *state { now := time.Now() s := &state{ hero: hero.New(hero.Config{ StartX: heroStartX, StartY: heroStartY, Radius: heroRadius, Speed: heroSpeed, Color: heroColor, MaxStamina: heroMaxStamina, StaminaDrain: heroStaminaDrain, StaminaRegen: heroStaminaRegen, }), hud: hud.Overlay{ X: hudX, Y: hudY, Color: color.White, }, bounds: hero.Bounds{ Width: ScreenWidth, Height: ScreenHeight, }, 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 } func readControls() controls { return controls{ Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA), Right: ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD), Up: ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW), Down: ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS), Sprint: ebiten.IsKeyPressed(ebiten.KeyShift), } } 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 g.state.pauseMenu.Reset() } else if g.state.gameState == statePaused { g.state.gameState = statePlaying g.state.lastTick = time.Now() } } // 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 { if selectedOption := g.state.pauseMenu.Update(); selectedOption != nil { switch *selectedOption { case menu.OptionResume: g.state.gameState = statePlaying g.state.lastTick = time.Now() case menu.OptionQuit: return ebiten.Termination } } } return nil } func (s *state) update(input controls) { now := time.Now() dt := now.Sub(s.lastTick).Seconds() s.lastTick = now s.hero.Update(hero.Input{ Left: input.Left, Right: input.Right, Up: input.Up, Down: input.Down, Sprint: input.Sprint, }, dt, s.bounds) } 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) } func (s *state) draw(screen *ebiten.Image) { screen.Fill(backgroundColor) s.hero.Draw(screen) staminaColor := staminaNormalColor if s.hero.Stamina < s.hero.MaxStamina*staminaLowThreshold { staminaColor = staminaLowColor } staminaMeter := status.Meter{ Label: "Stamina", Base: s.hero.MaxStamina, Level: s.hero.Stamina, Color: staminaColor, } 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 }