package game import ( "time" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/atridad/LilGuy/internal/save" "github.com/atridad/LilGuy/internal/screens" "github.com/atridad/LilGuy/internal/ui/menu" ) // Window and display configuration. const ( ScreenWidth = 960 ScreenHeight = 540 TargetTPS = 60 WindowTitle = "Lil Guy" ) // 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 ( 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 } // Input state for player controls. type controls struct { Left bool Right bool Jump bool Sprint bool Shoot bool } func readControls() controls { return controls{ Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA), Right: ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD), Jump: ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW) || ebiten.IsKeyPressed(ebiten.KeySpace), Sprint: ebiten.IsKeyPressed(ebiten.KeyShift), Shoot: inpututil.IsKeyJustPressed(ebiten.KeyK), } } 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 { // 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) } else { ebiten.SetTPS(currentTPS) } // 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 } // 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 } // 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, Jump: input.Jump, Sprint: input.Sprint, Shoot: input.Shoot, }, delta) // Periodic auto-save if now.Sub(g.state.lastAutoSave) >= g.state.autoSaveInterval { g.saveGame() g.state.gameplayScreen.ShowSaveNotification() 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() g.state.gameplayScreen.ShowSaveNotification() g.state.gameState = statePlaying g.state.lastTick = time.Now() 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 } // saveGame saves the current game state and settings. func (g *Game) saveGame() { if g.state.saveManager == nil { return } // 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) } // 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) { 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) } } // Layout returns the game's logical screen size. func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenWidth, ScreenHeight }