Files
LilGuy/internal/game/game.go
2025-11-24 12:18:32 -07:00

432 lines
10 KiB
Go

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
}