Menu and save system
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user