From cd6c1a78b057a51b8a50febd943a4b3f32b1b636 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Wed, 19 Nov 2025 13:13:22 -0700 Subject: [PATCH] Menu and save system --- go.mod | 5 +- go.sum | 2 + internal/game/game.go | 521 +++++++++++++++++++++-------------- internal/save/save.go | 189 +++++++++++++ internal/screens/gameplay.go | 235 ++++++++++++++++ internal/screens/settings.go | 136 +++++++++ internal/screens/splash.go | 101 +++++++ internal/screens/title.go | 209 ++++++++++++++ internal/ui/menu/menu.go | 119 +++----- 9 files changed, 1231 insertions(+), 286 deletions(-) create mode 100644 internal/save/save.go create mode 100644 internal/screens/gameplay.go create mode 100644 internal/screens/settings.go create mode 100644 internal/screens/splash.go create mode 100644 internal/screens/title.go diff --git a/go.mod b/go.mod index 7da6200..caf46cc 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/atridad/BigFeelings -go 1.24.0 - -toolchain go1.24.3 +go 1.25.4 require ( github.com/hajimehoshi/ebiten/v2 v2.9.4 @@ -10,6 +8,7 @@ require ( ) require ( + github.com/BurntSushi/toml v1.5.0 github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect github.com/ebitengine/purego v0.9.0 // indirect diff --git a/go.sum b/go.sum index cd12ee3..9d1fa28 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= diff --git a/internal/game/game.go b/internal/game/game.go index fa22a94..ca1e8a2 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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 } diff --git a/internal/save/save.go b/internal/save/save.go new file mode 100644 index 0000000..cdfee1c --- /dev/null +++ b/internal/save/save.go @@ -0,0 +1,189 @@ +package save + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" +) + +// File path for save data. +const ( + dataFile = "data.toml" +) + +// Stores all persistent game data in a single file. +type Data struct { + Settings Settings `toml:"settings"` + GameState GameState `toml:"game_state"` +} + +// Stores user preferences. +type Settings struct { + FPSMonitor bool `toml:"fps_monitor"` + FPSCap string `toml:"fps_cap"` // "60", "120", or "uncapped" +} + +// Stores the current game progress. +type GameState struct { + HasSave bool `toml:"has_save"` + SavedAt time.Time `toml:"saved_at"` + HeroX float64 `toml:"hero_x"` + HeroY float64 `toml:"hero_y"` + HeroStamina float64 `toml:"hero_stamina"` + PlayTimeMS int64 `toml:"play_time_ms"` // Total play time in milliseconds +} + +// Handles saving and loading of settings and game state. +type Manager struct { + dataPath string +} + +// Creates a new save manager. +func NewManager() (*Manager, error) { + // Get executable path + exePath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("failed to get executable path: %w", err) + } + + // Get directory containing the executable + exeDir := filepath.Dir(exePath) + + return &Manager{ + dataPath: filepath.Join(exeDir, dataFile), + }, nil +} + +// Loads all data from disk. +func (m *Manager) LoadData() (*Data, error) { + // Check if file exists + if _, err := os.Stat(m.dataPath); os.IsNotExist(err) { + // Return default data + return &Data{ + Settings: Settings{ + FPSMonitor: false, + FPSCap: "60", + }, + GameState: GameState{ + HasSave: false, + }, + }, nil + } + + var data Data + if _, err := toml.DecodeFile(m.dataPath, &data); err != nil { + return nil, fmt.Errorf("failed to parse data file: %w", err) + } + + return &data, nil +} + +// Writes all data to disk. +func (m *Manager) SaveData(data *Data) error { + file, err := os.Create(m.dataPath) + if err != nil { + return fmt.Errorf("failed to create data file: %w", err) + } + defer file.Close() + + encoder := toml.NewEncoder(file) + if err := encoder.Encode(data); err != nil { + return fmt.Errorf("failed to write data file: %w", err) + } + + return nil +} + +// Loads user settings from disk. +func (m *Manager) LoadSettings() (*Settings, error) { + data, err := m.LoadData() + if err != nil { + return nil, err + } + return &data.Settings, nil +} + +// Writes user settings to disk. +func (m *Manager) SaveSettings(settings *Settings) error { + data, err := m.LoadData() + if err != nil { + // If load fails, create new data with these settings + data = &Data{ + Settings: *settings, + GameState: GameState{ + HasSave: false, + }, + } + } else { + data.Settings = *settings + } + + return m.SaveData(data) +} + +// Loads game state from disk. +func (m *Manager) LoadGameState() (*GameState, error) { + data, err := m.LoadData() + if err != nil { + return nil, err + } + + if !data.GameState.HasSave { + return nil, nil // No save exists + } + + return &data.GameState, nil +} + +// Writes game state to disk. +func (m *Manager) SaveGameState(state *GameState) error { + data, err := m.LoadData() + if err != nil { + // If load fails, create new data with this game state + data = &Data{ + Settings: Settings{ + FPSMonitor: false, + FPSCap: "60", + }, + GameState: *state, + } + } else { + data.GameState = *state + } + + data.GameState.HasSave = true + data.GameState.SavedAt = time.Now() + + return m.SaveData(data) +} + +// Checks if a saved game exists. +func (m *Manager) HasSavedGame() bool { + data, err := m.LoadData() + if err != nil { + return false + } + return data.GameState.HasSave +} + +// Removes the saved game. +func (m *Manager) DeleteGameState() error { + data, err := m.LoadData() + if err != nil { + return err + } + + data.GameState = GameState{ + HasSave: false, + } + + return m.SaveData(data) +} + +// Returns the path to the save file. +func (m *Manager) GetSaveFilePath() string { + return m.dataPath +} diff --git a/internal/screens/gameplay.go b/internal/screens/gameplay.go new file mode 100644 index 0000000..2987a05 --- /dev/null +++ b/internal/screens/gameplay.go @@ -0,0 +1,235 @@ +package screens + +import ( + "fmt" + "image/color" + "time" + + "github.com/hajimehoshi/ebiten/v2" + + "github.com/atridad/BigFeelings/internal/hero" + "github.com/atridad/BigFeelings/internal/save" + "github.com/atridad/BigFeelings/internal/status" + "github.com/atridad/BigFeelings/internal/ui/hud" +) + +var ( + backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255} +) + +// Hero settings. +const ( + heroStartX = 960 / 2 // ScreenWidth / 2 + heroStartY = 540 / 2 // 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 = 960 - 220 // 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 + targetTPS = 60 +) + +// Player input for the gameplay screen. +type GameplayInput struct { + Left bool + Right bool + Up bool + Down bool + Sprint bool +} + +// Manages the main gameplay state including the hero, HUD, and game world. +// This is where the actual game logic and rendering happens during active play. +type GameplayScreen struct { + hero *hero.Hero + hud hud.Overlay + bounds hero.Bounds + lastTick time.Time + gameStartTime time.Time + totalPlayTime time.Duration + fpsEnabled *bool + fpsFrames int + fpsAccumulator time.Duration + fpsValue float64 +} + +// Creates a new gameplay screen instance. +func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool) *GameplayScreen { + return &GameplayScreen{ + 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: float64(screenWidth), + Height: float64(screenHeight), + }, + lastTick: time.Now(), + gameStartTime: time.Now(), + fpsEnabled: fpsEnabled, + } +} + +// Processes gameplay logic with the given input and delta time. +func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) { + dt := delta.Seconds() + + g.hero.Update(hero.Input{ + Left: input.Left, + Right: input.Right, + Up: input.Up, + Down: input.Down, + Sprint: input.Sprint, + }, dt, g.bounds) + + // Track total play time + g.totalPlayTime += delta + + g.trackFPS(delta) +} + +// Calculates the current FPS if FPS monitoring is enabled. +func (g *GameplayScreen) trackFPS(delta time.Duration) { + if g.fpsEnabled == nil || !*g.fpsEnabled { + return + } + + g.fpsAccumulator += delta + g.fpsFrames++ + + if g.fpsAccumulator >= fpsSampleWindow { + g.fpsValue = float64(g.fpsFrames) / g.fpsAccumulator.Seconds() + g.fpsAccumulator = 0 + g.fpsFrames = 0 + } +} + +// Renders the gameplay screen. +func (g *GameplayScreen) Draw(screen *ebiten.Image) { + screen.Fill(backgroundColor) + g.hero.Draw(screen) + + staminaColor := staminaNormalColor + if g.hero.Stamina < g.hero.MaxStamina*staminaLowThreshold { + staminaColor = staminaLowColor + } + + staminaMeter := status.Meter{ + Label: "Stamina", + Base: g.hero.MaxStamina, + Level: g.hero.Stamina, + Color: staminaColor, + } + + meters := []status.Meter{staminaMeter} + + if g.fpsEnabled != nil && *g.fpsEnabled { + // Color based on target FPS (60). + ratio := g.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", g.fpsValue), + Base: -1, // Negative base means text-only display. + Level: 0, + Color: fpsColor, + } + meters = append(meters, fpsMeter) + } + + g.hud.Draw(screen, meters) +} + +// Resets the gameplay screen to its initial state. +func (g *GameplayScreen) Reset() { + g.hero = hero.New(hero.Config{ + StartX: heroStartX, + StartY: heroStartY, + Radius: heroRadius, + Speed: heroSpeed, + Color: heroColor, + MaxStamina: heroMaxStamina, + StaminaDrain: heroStaminaDrain, + StaminaRegen: heroStaminaRegen, + }) + g.lastTick = time.Now() + g.gameStartTime = time.Now() + g.totalPlayTime = 0 + g.fpsFrames = 0 + g.fpsAccumulator = 0 + g.fpsValue = 0 +} + +// SaveState converts the current gameplay state to a saveable format. +func (g *GameplayScreen) SaveState() *save.GameState { + return &save.GameState{ + HeroX: g.hero.X, + HeroY: g.hero.Y, + HeroStamina: g.hero.Stamina, + PlayTimeMS: g.totalPlayTime.Milliseconds(), + } +} + +// LoadState restores gameplay state from saved data. +func (g *GameplayScreen) LoadState(state *save.GameState) { + g.hero = hero.New(hero.Config{ + StartX: state.HeroX, + StartY: state.HeroY, + Radius: heroRadius, + Speed: heroSpeed, + Color: heroColor, + MaxStamina: heroMaxStamina, + StaminaDrain: heroStaminaDrain, + StaminaRegen: heroStaminaRegen, + }) + g.hero.Stamina = state.HeroStamina + g.totalPlayTime = time.Duration(state.PlayTimeMS) * time.Millisecond + g.lastTick = time.Now() + g.gameStartTime = time.Now() + g.fpsFrames = 0 + g.fpsAccumulator = 0 + g.fpsValue = 0 +} diff --git a/internal/screens/settings.go b/internal/screens/settings.go new file mode 100644 index 0000000..cf1e5d3 --- /dev/null +++ b/internal/screens/settings.go @@ -0,0 +1,136 @@ +package screens + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text/v2" +) + +// An interface for managing FPS cap settings. +type FPSCapSetting interface { + String() string + Cycle() +} + +// Displays and manages game settings like FPS monitor and FPS cap. +// This screen can be accessed from both the title screen and pause menu. +type SettingsScreen struct { + selectedIndex int + fpsMonitorValue *bool + fpsCapValue FPSCapSetting +} + +// Creates a new settings screen instance. +func NewSettingsScreen() *SettingsScreen { + return &SettingsScreen{ + selectedIndex: 0, + } +} + +// Sets the FPS monitor toggle reference. +func (s *SettingsScreen) SetFPSMonitor(enabled *bool) { + s.fpsMonitorValue = enabled +} + +// Sets the FPS cap setting reference. +func (s *SettingsScreen) SetFPSCap(cap FPSCapSetting) { + s.fpsCapValue = cap +} + +// Processes settings screen input. +// Returns true if the user wants to go back. +func (s *SettingsScreen) Update() bool { + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + return true + } + + settingsCount := 2 + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) { + s.selectedIndex-- + if s.selectedIndex < 0 { + s.selectedIndex = 0 + } + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) { + s.selectedIndex++ + if s.selectedIndex >= settingsCount { + s.selectedIndex = settingsCount - 1 + } + } + + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) { + if s.selectedIndex == 0 && s.fpsMonitorValue != nil { + *s.fpsMonitorValue = !*s.fpsMonitorValue + } else if s.selectedIndex == 1 && s.fpsCapValue != nil { + s.fpsCapValue.Cycle() + } + } + + return false +} + +// Renders the settings screen. +func (s *SettingsScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight int, title string) { + // Draw background + screen.Fill(color.RGBA{R: 20, G: 20, B: 30, A: 255}) + + // Draw title + titleX := (screenWidth / 2) - (len(title) * 7 / 2) + titleY := screenHeight/3 - 50 + s.drawText(screen, title, color.White, titleX, titleY) + + // Draw settings options + startY := screenHeight/2 - 20 + leftMargin := screenWidth/2 - 120 + + fpsMonitorText := "FPS Monitor: " + if s.fpsMonitorValue != nil && *s.fpsMonitorValue { + fpsMonitorText += "ON" + } else { + fpsMonitorText += "OFF" + } + + if s.selectedIndex == 0 { + indicatorX := leftMargin - 20 + s.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, startY) + s.drawText(screen, fpsMonitorText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, startY) + } else { + s.drawText(screen, fpsMonitorText, color.RGBA{R: 180, G: 180, B: 200, A: 255}, leftMargin, startY) + } + + fpsCapText := "FPS Cap: " + if s.fpsCapValue != nil { + fpsCapText += s.fpsCapValue.String() + } else { + fpsCapText += "60 FPS" + } + + capY := startY + 40 + if s.selectedIndex == 1 { + indicatorX := leftMargin - 20 + s.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, capY) + s.drawText(screen, fpsCapText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, capY) + } else { + s.drawText(screen, fpsCapText, color.RGBA{R: 180, G: 180, B: 200, A: 255}, leftMargin, capY) + } + + // Draw hint text + hintText := "Enter/Space to toggle, ESC to go back" + hintX := (screenWidth / 2) - (len(hintText) * 7 / 2) + hintY := screenHeight - 50 + s.drawText(screen, hintText, color.RGBA{R: 120, G: 120, B: 150, A: 255}, hintX, hintY) +} + +func (s *SettingsScreen) drawText(screen *ebiten.Image, txt string, clr color.Color, x, y int) { + op := &text.DrawOptions{} + op.GeoM.Translate(float64(x), float64(y)-basicFaceAscent) + op.ColorScale.ScaleWithColor(clr) + text.Draw(screen, txt, basicFace, op) +} + +// Resets the settings screen to its initial state. +func (s *SettingsScreen) Reset() { + s.selectedIndex = 0 +} diff --git a/internal/screens/splash.go b/internal/screens/splash.go new file mode 100644 index 0000000..908882b --- /dev/null +++ b/internal/screens/splash.go @@ -0,0 +1,101 @@ +package screens + +import ( + "image/color" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text/v2" + "golang.org/x/image/font/basicfont" +) + +var ( + basicFace = text.NewGoXFace(basicfont.Face7x13) + basicFaceAscent = basicFace.Metrics().HAscent +) + +const ( + splashDuration = 2 * time.Second + fadeInDuration = 500 * time.Millisecond + fadeOutDuration = 500 * time.Millisecond +) + +// Displays the game title with fade in/out effects. +// This is typically the first screen shown when the game starts. +type SplashScreen struct { + startTime time.Time + fadeInEnd time.Time + fadeOutStart time.Time + endTime time.Time +} + +// Creates a new splash screen instance. +func NewSplashScreen() *SplashScreen { + now := time.Now() + return &SplashScreen{ + startTime: now, + fadeInEnd: now.Add(fadeInDuration), + fadeOutStart: now.Add(splashDuration - fadeOutDuration), + endTime: now.Add(splashDuration), + } +} + +// Processes splash screen logic. +// Returns true when the splash screen should end and transition to the next screen. +func (s *SplashScreen) Update() bool { + // Return true if splash is complete + if time.Now().After(s.endTime) { + return true + } + + // Allow skipping with any key + if inpututil.IsKeyJustPressed(ebiten.KeySpace) || + inpututil.IsKeyJustPressed(ebiten.KeyEnter) || + inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + return true + } + + return false +} + +// Renders the splash screen. +func (s *SplashScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { + screen.Fill(color.RGBA{R: 0, G: 0, B: 0, A: 255}) + + now := time.Now() + alpha := 1.0 + + // Calculate fade in/out + if now.Before(s.fadeInEnd) { + elapsed := now.Sub(s.startTime) + alpha = float64(elapsed) / float64(fadeInDuration) + } else if now.After(s.fadeOutStart) { + elapsed := now.Sub(s.fadeOutStart) + alpha = 1.0 - (float64(elapsed) / float64(fadeOutDuration)) + } + + if alpha < 0 { + alpha = 0 + } else if alpha > 1 { + alpha = 1 + } + + // Draw large game title + titleText := "BIG FEELINGS" + + // Calculate size for large text (scale up the basic font) + scale := 4.0 + charWidth := 7.0 * scale + textWidth := float64(len(titleText)) * charWidth + + x := float64(screenWidth)/2 - textWidth/2 + y := float64(screenHeight) / 2 + + op := &text.DrawOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate(x, y-basicFaceAscent*scale) + op.ColorScale.ScaleWithColor(color.White) + op.ColorScale.ScaleAlpha(float32(alpha)) + text.Draw(screen, titleText, basicFace, op) +} diff --git a/internal/screens/title.go b/internal/screens/title.go new file mode 100644 index 0000000..d361eb9 --- /dev/null +++ b/internal/screens/title.go @@ -0,0 +1,209 @@ +package screens + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// Represents the options available on the title screen. +type TitleMenuOption int + +const ( + TitleOptionContinue TitleMenuOption = iota + TitleOptionNewGame + TitleOptionSettings + TitleOptionQuit + titleOptionCount +) + +type titleScreenMode int + +const ( + titleModeMain titleScreenMode = iota + titleModeSettings +) + +// Displays the main menu with Continue, New Game, Settings, and Quit options. +// This is shown after the splash screen and when returning from the pause menu. +type TitleScreen struct { + selectedIndex int + currentMode titleScreenMode + settingsScreen *SettingsScreen + hasSaveGame bool +} + +// Creates a new title screen instance. +func NewTitleScreen() *TitleScreen { + return &TitleScreen{ + selectedIndex: 0, + currentMode: titleModeMain, + settingsScreen: NewSettingsScreen(), + hasSaveGame: false, + } +} + +// Sets the FPS monitor toggle reference for settings. +func (t *TitleScreen) SetFPSMonitor(enabled *bool) { + t.settingsScreen.SetFPSMonitor(enabled) +} + +// Sets the FPS cap setting reference for settings. +func (t *TitleScreen) SetFPSCap(cap FPSCapSetting) { + t.settingsScreen.SetFPSCap(cap) +} + +// Sets whether a saved game exists. +func (t *TitleScreen) SetHasSaveGame(hasSave bool) { + t.hasSaveGame = hasSave + // If no save game, skip Continue option + if !hasSave && t.selectedIndex == 0 { + t.selectedIndex = 1 // Move to New Game + } +} + +// Processes title screen input and returns the selected option if any. +func (t *TitleScreen) Update() *TitleMenuOption { + // Handle settings screen + if t.currentMode == titleModeSettings { + if t.settingsScreen.Update() { + t.currentMode = titleModeMain + t.selectedIndex = 0 + } + return nil + } + + // Handle main menu + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) { + t.selectedIndex-- + if t.selectedIndex < 0 { + t.selectedIndex = int(titleOptionCount) - 1 + } + // Skip Continue if no save exists + if t.selectedIndex == int(TitleOptionContinue) && !t.hasSaveGame { + t.selectedIndex-- + if t.selectedIndex < 0 { + t.selectedIndex = int(titleOptionCount) - 1 + } + } + } + + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) { + t.selectedIndex++ + if t.selectedIndex >= int(titleOptionCount) { + t.selectedIndex = 0 + } + // Skip Continue if no save exists + if t.selectedIndex == int(TitleOptionContinue) && !t.hasSaveGame { + t.selectedIndex++ + if t.selectedIndex >= int(titleOptionCount) { + t.selectedIndex = 0 + } + } + } + + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) { + selected := TitleMenuOption(t.selectedIndex) + if selected == TitleOptionSettings { + t.currentMode = titleModeSettings + t.settingsScreen.Reset() + return nil + } + return &selected + } + + return nil +} + +// Renders the title screen. +func (t *TitleScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { + // If in settings mode, draw settings screen + if t.currentMode == titleModeSettings { + t.settingsScreen.Draw(screen, screenWidth, screenHeight, "SETTINGS") + return + } + + // Draw main menu + screen.Fill(color.RGBA{R: 20, G: 20, B: 30, A: 255}) + + // Draw game title + titleText := "BIG FEELINGS" + scale := 3.0 + charWidth := 7.0 * scale + textWidth := float64(len(titleText)) * charWidth + + titleX := float64(screenWidth)/2 - textWidth/2 + titleY := float64(screenHeight) / 3 + + op := &text.DrawOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate(titleX, titleY-basicFaceAscent*scale) + op.ColorScale.ScaleWithColor(color.RGBA{R: 210, G: 220, B: 255, A: 255}) + text.Draw(screen, titleText, basicFace, op) + + // Draw menu options + options := []string{"Continue", "New Game", "Settings", "Quit"} + startY := screenHeight/2 + 10 + + for i, option := range options { + // Skip Continue option if no save exists + if i == int(TitleOptionContinue) && !t.hasSaveGame { + continue + } + optionY := startY + (i * 50) + optionX := (screenWidth / 2) - (len(option) * 7 / 2) + + // Dim Continue option if it exists but is disabled + optionColor := color.RGBA{R: 180, G: 180, B: 200, A: 255} + if i == int(TitleOptionContinue) && !t.hasSaveGame { + optionColor = color.RGBA{R: 80, G: 80, B: 100, A: 255} + } + + if i == t.selectedIndex { + // Draw selection indicator + indicatorX := optionX - 20 + t.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, optionY) + t.drawText(screen, option, color.RGBA{R: 255, G: 255, B: 100, A: 255}, optionX, optionY) + + // Draw selection box + boxPadding := float32(10.0) + boxWidth := float32(len(option)*7) + boxPadding*2 + boxHeight := float32(20) + boxX := float32(optionX) - boxPadding + boxY := float32(optionY) - float32(basicFaceAscent) - boxPadding/2 + + vector.StrokeRect(screen, boxX, boxY, boxWidth, boxHeight, 2, + color.RGBA{R: 255, G: 200, B: 0, A: 255}, false) + } else { + t.drawText(screen, option, optionColor, optionX, optionY) + } + } + + // Draw hint text + hintText := "Use Arrow Keys/WASD to navigate, Enter/Space to select" + hintX := (screenWidth / 2) - (len(hintText) * 7 / 2) + hintY := screenHeight - 50 + t.drawText(screen, hintText, color.RGBA{R: 120, G: 120, B: 150, A: 255}, hintX, hintY) +} + +func (t *TitleScreen) drawText(screen *ebiten.Image, txt string, clr color.Color, x, y int) { + op := &text.DrawOptions{} + op.GeoM.Translate(float64(x), float64(y)-basicFaceAscent) + op.ColorScale.ScaleWithColor(clr) + text.Draw(screen, txt, basicFace, op) +} + +// Resets the title screen to its initial state. +func (t *TitleScreen) Reset() { + // Start at Continue if available, otherwise New Game + if t.hasSaveGame { + t.selectedIndex = 0 + } else { + t.selectedIndex = 1 + } + t.currentMode = titleModeMain + t.settingsScreen.Reset() +} diff --git a/internal/ui/menu/menu.go b/internal/ui/menu/menu.go index 3b1c277..2831327 100644 --- a/internal/ui/menu/menu.go +++ b/internal/ui/menu/menu.go @@ -8,6 +8,8 @@ import ( "github.com/hajimehoshi/ebiten/v2/text/v2" "github.com/hajimehoshi/ebiten/v2/vector" "golang.org/x/image/font/basicfont" + + "github.com/atridad/BigFeelings/internal/screens" ) var ( @@ -37,7 +39,9 @@ type MenuOption int const ( OptionResume MenuOption = iota + OptionSave OptionSettings + OptionMainMenu OptionQuit optionCount ) @@ -50,10 +54,9 @@ const ( ) type PauseMenu struct { - selectedIndex int - currentScreen menuScreen - fpsMonitorValue *bool - fpsCapValue FPSCapSetting + selectedIndex int + currentScreen menuScreen + settingsScreen *screens.SettingsScreen } type FPSCapSetting interface { @@ -63,17 +66,18 @@ type FPSCapSetting interface { func NewPauseMenu() *PauseMenu { return &PauseMenu{ - selectedIndex: 0, - currentScreen: screenMain, + selectedIndex: 0, + currentScreen: screenMain, + settingsScreen: screens.NewSettingsScreen(), } } func (m *PauseMenu) SetFPSMonitor(enabled *bool) { - m.fpsMonitorValue = enabled + m.settingsScreen.SetFPSMonitor(enabled) } func (m *PauseMenu) SetFPSCap(cap FPSCapSetting) { - m.fpsCapValue = cap + m.settingsScreen.SetFPSCap(cap) } // Returns the selected option if one was chosen, nil otherwise @@ -112,34 +116,10 @@ func (m *PauseMenu) updateMain() *MenuOption { } func (m *PauseMenu) updateSettings() *MenuOption { - if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + if m.settingsScreen.Update() { 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 } @@ -149,7 +129,7 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { } menuWidth := 400 - menuHeight := 300 + menuHeight := 380 menuX := (screenWidth - menuWidth) / 2 menuY := (screenHeight - menuHeight) / 2 @@ -181,11 +161,11 @@ func (m *PauseMenu) drawMain(screen *ebiten.Image, menuX, menuY, menuWidth, menu titleY := menuY + 50 m.drawText(screen, titleText, color.White, titleX, titleY) - options := []string{"Resume", "Settings", "Quit"} - startY := menuY + 110 + options := []string{"Resume", "Save", "Settings", "Main Menu", "Quit"} + startY := menuY + 90 for i, option := range options { - optionY := startY + (i * 40) + optionY := startY + (i * 45) optionX := menuX + (menuWidth / 2) - (len(option) * 7 / 2) if i == m.selectedIndex { @@ -204,49 +184,35 @@ func (m *PauseMenu) drawMain(screen *ebiten.Image, menuX, menuY, menuWidth, menu } 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) + // Draw menu background and border + vector.DrawFilledRect(screen, + float32(menuX), float32(menuY), + float32(menuWidth), float32(menuHeight), + color.RGBA{R: 40, G: 40, B: 50, A: 255}, + false, + ) - startY := menuY + 110 - leftMargin := menuX + 40 + vector.StrokeRect(screen, + float32(menuX), float32(menuY), + float32(menuWidth), float32(menuHeight), + 2, + color.RGBA{R: 100, G: 100, B: 120, A: 255}, + false, + ) - fpsMonitorText := "FPS Monitor: " - if m.fpsMonitorValue != nil && *m.fpsMonitorValue { - fpsMonitorText += "ON" - } else { - fpsMonitorText += "OFF" - } + // Create a sub-image for the settings screen to draw within the menu bounds + // We'll draw to the full screen and the settings screen will handle positioning + screenWidth := menuWidth + screenHeight := menuHeight - 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) - } + // Temporarily adjust the drawing to center within the menu + subScreen := ebiten.NewImage(screenWidth, screenHeight) + m.settingsScreen.Draw(subScreen, screenWidth, screenHeight, "SETTINGS") - 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) + // Draw the settings content in the menu area + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(float64(menuX), float64(menuY)) + screen.DrawImage(subScreen, op) } func (m *PauseMenu) drawText(screen *ebiten.Image, txt string, clr color.Color, x, y int) { @@ -259,4 +225,5 @@ func (m *PauseMenu) drawText(screen *ebiten.Image, txt string, clr color.Color, func (m *PauseMenu) Reset() { m.selectedIndex = 0 m.currentScreen = screenMain + m.settingsScreen.Reset() }