Menu and save system

This commit is contained in:
2025-11-19 13:13:22 -07:00
parent a7e6f4e0bf
commit cd6c1a78b0
9 changed files with 1231 additions and 286 deletions

View File

@@ -1,20 +1,17 @@
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/save"
"github.com/atridad/BigFeelings/internal/screens"
"github.com/atridad/BigFeelings/internal/ui/menu"
)
// Game settings.
// Window and display configuration.
const (
ScreenWidth = 960
@@ -23,7 +20,29 @@ const (
WindowTitle = "Big Feelings"
)
// FPS cap options.
// Game states define the different screens and modes the game can be in.
// To add a new state:
// 1. Add a new constant to the gameState enum below
// 2. Create a new screen type in internal/screens/ (see splash.go, title.go, gameplay.go as examples)
// 3. Add the screen instance to the 'state' struct
// 4. Handle state transitions in Update() method
// 5. Handle rendering in Draw() method
//
// State Flow:
// stateSplash -> stateTitle -> statePlaying <-> statePaused
// ^____________|
type gameState int
const (
stateSplash gameState = iota // Initial splash screen with game logo
stateTitle // Main menu (Play/Quit options)
statePlaying // Active gameplay
statePaused // Game paused (overlay menu)
)
// FPS cap options for performance tuning.
type FPSCap int
const (
@@ -63,53 +82,7 @@ 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
)
// Input state for player controls.
type controls struct {
Left bool
@@ -119,62 +92,6 @@ type controls struct {
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),
@@ -185,8 +102,129 @@ func readControls() controls {
}
}
type Game struct {
state *state
}
// state holds all game state including screens, settings, and current mode.
type state struct {
// Current state
gameState gameState
lastTick time.Time
// Screens - each screen manages its own UI and logic
splashScreen *screens.SplashScreen
titleScreen *screens.TitleScreen
gameplayScreen *screens.GameplayScreen
pauseMenu *menu.PauseMenu
// Settings
fpsEnabled bool
fpsCap FPSCap
saveManager *save.Manager
// Auto-save
lastAutoSave time.Time
autoSaveInterval time.Duration
}
// New creates a new game instance.
func New() *Game {
return &Game{state: newState()}
}
// newState initializes a fresh game state.
func newState() *state {
now := time.Now()
s := &state{
gameState: stateSplash,
lastTick: now,
fpsEnabled: false,
fpsCap: FPSCap60,
lastAutoSave: now,
autoSaveInterval: 30 * time.Second, // Auto-save every 30 seconds
}
// Initialize save manager
saveManager, err := save.NewManager()
if err != nil {
// If save manager fails, continue without it (settings won't persist)
// TODO: Show error to user
saveManager = nil
}
s.saveManager = saveManager
// Load settings if available
if saveManager != nil {
if settings, err := saveManager.LoadSettings(); err == nil {
s.fpsEnabled = settings.FPSMonitor
switch settings.FPSCap {
case "60":
s.fpsCap = FPSCap60
case "120":
s.fpsCap = FPSCap120
case "uncapped":
s.fpsCap = FPSCapUncapped
default:
s.fpsCap = FPSCap60
}
}
}
// Initialize all screens
s.splashScreen = screens.NewSplashScreen()
s.titleScreen = screens.NewTitleScreen()
s.gameplayScreen = screens.NewGameplayScreen(ScreenWidth, ScreenHeight, &s.fpsEnabled)
s.pauseMenu = menu.NewPauseMenu()
// Configure settings references for title screen and pause menu
s.titleScreen.SetFPSMonitor(&s.fpsEnabled)
s.titleScreen.SetFPSCap(&s.fpsCap)
s.pauseMenu.SetFPSMonitor(&s.fpsEnabled)
s.pauseMenu.SetFPSCap(&s.fpsCap)
// Check if saved game exists
if saveManager != nil {
s.titleScreen.SetHasSaveGame(saveManager.HasSavedGame())
}
// Set initial TPS
ebiten.SetTPS(s.fpsCap.TPS())
// Save initial settings to create data.toml on first launch
if saveManager != nil {
settings := &save.Settings{
FPSMonitor: s.fpsEnabled,
FPSCap: s.fpCapToStringHelper(s.fpsCap),
}
saveManager.SaveSettings(settings)
}
return s
}
// Helper function for converting FPSCap to string (used in initialization)
func (s *state) fpCapToStringHelper(cap FPSCap) string {
switch cap {
case FPSCap60:
return "60"
case FPSCap120:
return "120"
case FPSCapUncapped:
return "uncapped"
default:
return "60"
}
}
// Update is called every frame and handles state transitions and input.
func (g *Game) Update() error {
// Update TPS if FPS cap changed.
// Track previous FPS settings to detect changes
prevFPSEnabled := g.state.fpsEnabled
prevFPSCap := g.state.fpsCap
// Update TPS if FPS cap changed
currentTPS := g.state.fpsCap.TPS()
if currentTPS < 0 {
ebiten.SetTPS(ebiten.SyncWithFPS)
@@ -194,127 +232,196 @@ func (g *Game) Update() error {
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 {
// Handle state-specific updates
var err error
switch g.state.gameState {
case stateSplash:
err = g.updateSplash()
case stateTitle:
err = g.updateTitle()
case statePlaying:
err = g.updatePlaying()
case statePaused:
err = g.updatePaused()
}
// Auto-save settings if they changed
if prevFPSEnabled != g.state.fpsEnabled || prevFPSCap != g.state.fpsCap {
g.saveSettings()
}
return err
}
// updateSplash handles the splash screen state.
func (g *Game) updateSplash() error {
if g.state.splashScreen.Update() {
g.state.gameState = stateTitle
}
return nil
}
// updateTitle handles the title screen state.
func (g *Game) updateTitle() error {
if selectedOption := g.state.titleScreen.Update(); selectedOption != nil {
switch *selectedOption {
case screens.TitleOptionContinue:
// Load saved game
if g.state.saveManager != nil {
if gameState, err := g.state.saveManager.LoadGameState(); err == nil && gameState != nil {
g.state.gameplayScreen.LoadState(gameState)
g.state.gameState = statePlaying
g.state.lastTick = time.Now()
}
}
case screens.TitleOptionNewGame:
g.state.gameState = statePlaying
g.state.gameplayScreen.Reset()
g.state.lastTick = time.Now()
// Delete old save if it exists
if g.state.saveManager != nil {
g.state.saveManager.DeleteGameState()
}
case screens.TitleOptionSettings:
// Settings are handled within the title screen itself
// No state change needed
case screens.TitleOptionQuit:
return ebiten.Termination
}
}
return nil
}
// Track FPS.
now := time.Now()
if !g.state.lastTick.IsZero() {
g.state.trackFPS(now.Sub(g.state.lastTick))
// updatePlaying handles the active gameplay state.
func (g *Game) updatePlaying() error {
// Check for pause
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
g.state.gameState = statePaused
g.state.pauseMenu.Reset()
return nil
}
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
// Calculate delta time
now := time.Now()
delta := now.Sub(g.state.lastTick)
g.state.lastTick = now
// Update gameplay
input := readControls()
g.state.gameplayScreen.Update(screens.GameplayInput{
Left: input.Left,
Right: input.Right,
Up: input.Up,
Down: input.Down,
Sprint: input.Sprint,
}, delta)
// Periodic auto-save
if now.Sub(g.state.lastAutoSave) >= g.state.autoSaveInterval {
g.saveGame()
g.state.lastAutoSave = now
}
return nil
}
// updatePaused handles the pause menu state.
func (g *Game) updatePaused() error {
// Allow ESC to resume
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
g.state.gameState = statePlaying
g.state.lastTick = time.Now()
return nil
}
// Handle pause menu selection
if selectedOption := g.state.pauseMenu.Update(); selectedOption != nil {
switch *selectedOption {
case menu.OptionResume:
g.state.gameState = statePlaying
g.state.lastTick = time.Now()
case menu.OptionSave:
// Save game immediately
g.saveGame()
case menu.OptionMainMenu:
// Save game before returning to main menu
g.saveGame()
g.state.gameState = stateTitle
if g.state.saveManager != nil {
g.state.titleScreen.SetHasSaveGame(g.state.saveManager.HasSavedGame())
}
g.state.titleScreen.Reset()
case menu.OptionQuit:
// Save game before quitting
g.saveGame()
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 {
// saveGame saves the current game state and settings.
func (g *Game) saveGame() {
if g.state.saveManager == nil {
return
}
s.fpsAccumulator += delta
s.fpsFrames++
// Save game state if in playing mode
if g.state.gameState == statePlaying || g.state.gameState == statePaused {
gameState := g.state.gameplayScreen.SaveState()
g.state.saveManager.SaveGameState(gameState)
}
if s.fpsAccumulator >= fpsSampleWindow {
s.fpsValue = float64(s.fpsFrames) / s.fpsAccumulator.Seconds()
s.fpsAccumulator = 0
s.fpsFrames = 0
// Save settings
g.saveSettings()
}
// saveSettings saves only the settings.
func (g *Game) saveSettings() {
if g.state.saveManager == nil {
return
}
settings := &save.Settings{
FPSMonitor: g.state.fpsEnabled,
FPSCap: g.fpCapToString(g.state.fpsCap),
}
g.state.saveManager.SaveSettings(settings)
}
// fpCapToString converts FPSCap to string for saving.
func (g *Game) fpCapToString(cap FPSCap) string {
switch cap {
case FPSCap60:
return "60"
case FPSCap120:
return "120"
case FPSCapUncapped:
return "uncapped"
default:
return "60"
}
}
// Draw renders the current game state to the screen.
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)
switch g.state.gameState {
case stateSplash:
g.state.splashScreen.Draw(screen, ScreenWidth, ScreenHeight)
case stateTitle:
g.state.titleScreen.Draw(screen, ScreenWidth, ScreenHeight)
case statePlaying:
g.state.gameplayScreen.Draw(screen)
case statePaused:
// Draw gameplay in background, then overlay pause menu
g.state.gameplayScreen.Draw(screen)
g.state.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight)
}
}
func clampFloat(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
// Layout returns the game's logical screen size.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return ScreenWidth, ScreenHeight
}