Menu and save system
This commit is contained in:
5
go.mod
5
go.mod
@@ -1,8 +1,6 @@
|
|||||||
module github.com/atridad/BigFeelings
|
module github.com/atridad/BigFeelings
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.4
|
||||||
|
|
||||||
toolchain go1.24.3
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/hajimehoshi/ebiten/v2 v2.9.4
|
github.com/hajimehoshi/ebiten/v2 v2.9.4
|
||||||
@@ -10,6 +8,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect
|
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect
|
||||||
github.com/ebitengine/hideconsole v1.0.0 // indirect
|
github.com/ebitengine/hideconsole v1.0.0 // indirect
|
||||||
github.com/ebitengine/purego v0.9.0 // indirect
|
github.com/ebitengine/purego v0.9.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
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 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0=
|
||||||
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI=
|
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=
|
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"image/color"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||||
|
|
||||||
"github.com/atridad/BigFeelings/internal/hero"
|
"github.com/atridad/BigFeelings/internal/save"
|
||||||
"github.com/atridad/BigFeelings/internal/status"
|
"github.com/atridad/BigFeelings/internal/screens"
|
||||||
"github.com/atridad/BigFeelings/internal/ui/hud"
|
|
||||||
"github.com/atridad/BigFeelings/internal/ui/menu"
|
"github.com/atridad/BigFeelings/internal/ui/menu"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Game settings.
|
// Window and display configuration.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ScreenWidth = 960
|
ScreenWidth = 960
|
||||||
@@ -23,7 +20,29 @@ const (
|
|||||||
WindowTitle = "Big Feelings"
|
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
|
type FPSCap int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -63,53 +82,7 @@ func (f *FPSCap) Cycle() {
|
|||||||
*f = (*f + 1) % fpsCapCount
|
*f = (*f + 1) % fpsCapCount
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// Input state for player controls.
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
type controls struct {
|
type controls struct {
|
||||||
Left bool
|
Left bool
|
||||||
@@ -119,62 +92,6 @@ type controls struct {
|
|||||||
Sprint bool
|
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 {
|
func readControls() controls {
|
||||||
return controls{
|
return controls{
|
||||||
Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA),
|
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 {
|
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()
|
currentTPS := g.state.fpsCap.TPS()
|
||||||
if currentTPS < 0 {
|
if currentTPS < 0 {
|
||||||
ebiten.SetTPS(ebiten.SyncWithFPS)
|
ebiten.SetTPS(ebiten.SyncWithFPS)
|
||||||
@@ -194,127 +232,196 @@ func (g *Game) Update() error {
|
|||||||
ebiten.SetTPS(currentTPS)
|
ebiten.SetTPS(currentTPS)
|
||||||
}
|
}
|
||||||
|
|
||||||
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
// Handle state-specific updates
|
||||||
if g.state.gameState == statePlaying {
|
var err error
|
||||||
g.state.gameState = statePaused
|
switch g.state.gameState {
|
||||||
g.state.pauseMenu.Reset()
|
case stateSplash:
|
||||||
} else if g.state.gameState == statePaused {
|
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.gameState = statePlaying
|
||||||
g.state.lastTick = time.Now()
|
g.state.lastTick = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case screens.TitleOptionNewGame:
|
||||||
// Track FPS.
|
g.state.gameState = statePlaying
|
||||||
now := time.Now()
|
g.state.gameplayScreen.Reset()
|
||||||
if !g.state.lastTick.IsZero() {
|
g.state.lastTick = time.Now()
|
||||||
g.state.trackFPS(now.Sub(g.state.lastTick))
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.state.gameState == statePlaying {
|
// updatePlaying handles the active gameplay state.
|
||||||
g.state.update(readControls())
|
func (g *Game) updatePlaying() error {
|
||||||
} else if g.state.gameState == statePaused {
|
// 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,
|
||||||
|
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 {
|
if selectedOption := g.state.pauseMenu.Update(); selectedOption != nil {
|
||||||
switch *selectedOption {
|
switch *selectedOption {
|
||||||
case menu.OptionResume:
|
case menu.OptionResume:
|
||||||
g.state.gameState = statePlaying
|
g.state.gameState = statePlaying
|
||||||
g.state.lastTick = time.Now()
|
g.state.lastTick = time.Now()
|
||||||
case menu.OptionQuit:
|
case menu.OptionSave:
|
||||||
return ebiten.Termination
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *state) update(input controls) {
|
// saveGame saves the current game state and settings.
|
||||||
now := time.Now()
|
func (g *Game) saveGame() {
|
||||||
dt := now.Sub(s.lastTick).Seconds()
|
if g.state.saveManager == nil {
|
||||||
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 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.fpsAccumulator += delta
|
// Save game state if in playing mode
|
||||||
s.fpsFrames++
|
if g.state.gameState == statePlaying || g.state.gameState == statePaused {
|
||||||
|
gameState := g.state.gameplayScreen.SaveState()
|
||||||
|
g.state.saveManager.SaveGameState(gameState)
|
||||||
|
}
|
||||||
|
|
||||||
if s.fpsAccumulator >= fpsSampleWindow {
|
// Save settings
|
||||||
s.fpsValue = float64(s.fpsFrames) / s.fpsAccumulator.Seconds()
|
g.saveSettings()
|
||||||
s.fpsAccumulator = 0
|
}
|
||||||
s.fpsFrames = 0
|
|
||||||
|
// 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) {
|
func (g *Game) Draw(screen *ebiten.Image) {
|
||||||
g.state.draw(screen)
|
switch g.state.gameState {
|
||||||
}
|
case stateSplash:
|
||||||
|
g.state.splashScreen.Draw(screen, ScreenWidth, ScreenHeight)
|
||||||
func (s *state) draw(screen *ebiten.Image) {
|
case stateTitle:
|
||||||
screen.Fill(backgroundColor)
|
g.state.titleScreen.Draw(screen, ScreenWidth, ScreenHeight)
|
||||||
s.hero.Draw(screen)
|
case statePlaying:
|
||||||
|
g.state.gameplayScreen.Draw(screen)
|
||||||
staminaColor := staminaNormalColor
|
case statePaused:
|
||||||
if s.hero.Stamina < s.hero.MaxStamina*staminaLowThreshold {
|
// Draw gameplay in background, then overlay pause menu
|
||||||
staminaColor = staminaLowColor
|
g.state.gameplayScreen.Draw(screen)
|
||||||
}
|
g.state.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clampFloat(value, min, max float64) float64 {
|
// Layout returns the game's logical screen size.
|
||||||
if value < min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
if value > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
||||||
return ScreenWidth, ScreenHeight
|
return ScreenWidth, ScreenHeight
|
||||||
}
|
}
|
||||||
|
|||||||
189
internal/save/save.go
Normal file
189
internal/save/save.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
235
internal/screens/gameplay.go
Normal file
235
internal/screens/gameplay.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
136
internal/screens/settings.go
Normal file
136
internal/screens/settings.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
101
internal/screens/splash.go
Normal file
101
internal/screens/splash.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
209
internal/screens/title.go
Normal file
209
internal/screens/title.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"github.com/hajimehoshi/ebiten/v2/text/v2"
|
"github.com/hajimehoshi/ebiten/v2/text/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
"golang.org/x/image/font/basicfont"
|
"golang.org/x/image/font/basicfont"
|
||||||
|
|
||||||
|
"github.com/atridad/BigFeelings/internal/screens"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -37,7 +39,9 @@ type MenuOption int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
OptionResume MenuOption = iota
|
OptionResume MenuOption = iota
|
||||||
|
OptionSave
|
||||||
OptionSettings
|
OptionSettings
|
||||||
|
OptionMainMenu
|
||||||
OptionQuit
|
OptionQuit
|
||||||
optionCount
|
optionCount
|
||||||
)
|
)
|
||||||
@@ -52,8 +56,7 @@ const (
|
|||||||
type PauseMenu struct {
|
type PauseMenu struct {
|
||||||
selectedIndex int
|
selectedIndex int
|
||||||
currentScreen menuScreen
|
currentScreen menuScreen
|
||||||
fpsMonitorValue *bool
|
settingsScreen *screens.SettingsScreen
|
||||||
fpsCapValue FPSCapSetting
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FPSCapSetting interface {
|
type FPSCapSetting interface {
|
||||||
@@ -65,15 +68,16 @@ func NewPauseMenu() *PauseMenu {
|
|||||||
return &PauseMenu{
|
return &PauseMenu{
|
||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
currentScreen: screenMain,
|
currentScreen: screenMain,
|
||||||
|
settingsScreen: screens.NewSettingsScreen(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *PauseMenu) SetFPSMonitor(enabled *bool) {
|
func (m *PauseMenu) SetFPSMonitor(enabled *bool) {
|
||||||
m.fpsMonitorValue = enabled
|
m.settingsScreen.SetFPSMonitor(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *PauseMenu) SetFPSCap(cap FPSCapSetting) {
|
func (m *PauseMenu) SetFPSCap(cap FPSCapSetting) {
|
||||||
m.fpsCapValue = cap
|
m.settingsScreen.SetFPSCap(cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the selected option if one was chosen, nil otherwise
|
// Returns the selected option if one was chosen, nil otherwise
|
||||||
@@ -112,34 +116,10 @@ func (m *PauseMenu) updateMain() *MenuOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *PauseMenu) updateSettings() *MenuOption {
|
func (m *PauseMenu) updateSettings() *MenuOption {
|
||||||
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
if m.settingsScreen.Update() {
|
||||||
m.currentScreen = screenMain
|
m.currentScreen = screenMain
|
||||||
m.selectedIndex = 0
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +129,7 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
menuWidth := 400
|
menuWidth := 400
|
||||||
menuHeight := 300
|
menuHeight := 380
|
||||||
menuX := (screenWidth - menuWidth) / 2
|
menuX := (screenWidth - menuWidth) / 2
|
||||||
menuY := (screenHeight - menuHeight) / 2
|
menuY := (screenHeight - menuHeight) / 2
|
||||||
|
|
||||||
@@ -181,11 +161,11 @@ func (m *PauseMenu) drawMain(screen *ebiten.Image, menuX, menuY, menuWidth, menu
|
|||||||
titleY := menuY + 50
|
titleY := menuY + 50
|
||||||
m.drawText(screen, titleText, color.White, titleX, titleY)
|
m.drawText(screen, titleText, color.White, titleX, titleY)
|
||||||
|
|
||||||
options := []string{"Resume", "Settings", "Quit"}
|
options := []string{"Resume", "Save", "Settings", "Main Menu", "Quit"}
|
||||||
startY := menuY + 110
|
startY := menuY + 90
|
||||||
|
|
||||||
for i, option := range options {
|
for i, option := range options {
|
||||||
optionY := startY + (i * 40)
|
optionY := startY + (i * 45)
|
||||||
optionX := menuX + (menuWidth / 2) - (len(option) * 7 / 2)
|
optionX := menuX + (menuWidth / 2) - (len(option) * 7 / 2)
|
||||||
|
|
||||||
if i == m.selectedIndex {
|
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) {
|
func (m *PauseMenu) drawSettings(screen *ebiten.Image, menuX, menuY, menuWidth, menuHeight int) {
|
||||||
titleText := "SETTINGS"
|
// Draw menu background and border
|
||||||
titleX := menuX + (menuWidth / 2) - (len(titleText) * 7 / 2)
|
vector.DrawFilledRect(screen,
|
||||||
titleY := menuY + 50
|
float32(menuX), float32(menuY),
|
||||||
m.drawText(screen, titleText, color.White, titleX, titleY)
|
float32(menuWidth), float32(menuHeight),
|
||||||
|
color.RGBA{R: 40, G: 40, B: 50, A: 255},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
startY := menuY + 110
|
vector.StrokeRect(screen,
|
||||||
leftMargin := menuX + 40
|
float32(menuX), float32(menuY),
|
||||||
|
float32(menuWidth), float32(menuHeight),
|
||||||
|
2,
|
||||||
|
color.RGBA{R: 100, G: 100, B: 120, A: 255},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
fpsMonitorText := "FPS Monitor: "
|
// Create a sub-image for the settings screen to draw within the menu bounds
|
||||||
if m.fpsMonitorValue != nil && *m.fpsMonitorValue {
|
// We'll draw to the full screen and the settings screen will handle positioning
|
||||||
fpsMonitorText += "ON"
|
screenWidth := menuWidth
|
||||||
} else {
|
screenHeight := menuHeight
|
||||||
fpsMonitorText += "OFF"
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.selectedIndex == 0 {
|
// Temporarily adjust the drawing to center within the menu
|
||||||
indicatorX := leftMargin - 20
|
subScreen := ebiten.NewImage(screenWidth, screenHeight)
|
||||||
m.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, startY)
|
m.settingsScreen.Draw(subScreen, screenWidth, screenHeight, "SETTINGS")
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fpsCapText := "FPS Cap: "
|
// Draw the settings content in the menu area
|
||||||
if m.fpsCapValue != nil {
|
op := &ebiten.DrawImageOptions{}
|
||||||
fpsCapText += m.fpsCapValue.String()
|
op.GeoM.Translate(float64(menuX), float64(menuY))
|
||||||
} else {
|
screen.DrawImage(subScreen, op)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *PauseMenu) drawText(screen *ebiten.Image, txt string, clr color.Color, x, y int) {
|
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() {
|
func (m *PauseMenu) Reset() {
|
||||||
m.selectedIndex = 0
|
m.selectedIndex = 0
|
||||||
m.currentScreen = screenMain
|
m.currentScreen = screenMain
|
||||||
|
m.settingsScreen.Reset()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user