package screens import ( "fmt" "image/color" "time" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/vector" "github.com/atridad/LilGuy/internal/hero" "github.com/atridad/LilGuy/internal/projectile" "github.com/atridad/LilGuy/internal/save" "github.com/atridad/LilGuy/internal/status" "github.com/atridad/LilGuy/internal/ui/hud" "github.com/atridad/LilGuy/internal/world" ) var ( backgroundColor = color.NRGBA{R: 135, G: 206, B: 235, A: 255} saveNotificationColor = color.NRGBA{R: 50, G: 200, B: 50, A: 255} ) const ( heroStartX = 960 / 2 heroStartY = 540 / 2 heroRadius = 28.0 heroSpeed = 180.0 heroMaxStamina = 100.0 heroStaminaDrain = 50.0 heroStaminaRegen = 30.0 saveNotificationDuration = 2 * time.Second ) 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 Jump bool Sprint bool Shoot 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 world *world.World projectiles *projectile.Manager bounds hero.Bounds lastTick time.Time gameStartTime time.Time totalPlayTime time.Duration fpsEnabled *bool fpsFrames int fpsAccumulator time.Duration fpsValue float64 saveNotificationTimer time.Duration showSaveNotification bool } // Creates a new gameplay screen instance. func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool) *GameplayScreen { w := world.NewWorld() groundHeight := 16.0 w.AddSurface(&world.Surface{ X: 0, Y: float64(screenHeight) - groundHeight, Width: float64(screenWidth), Height: groundHeight, Tag: world.TagGround, Color: color.NRGBA{R: 34, G: 139, B: 34, A: 255}, }) 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, }, world: w, projectiles: projectile.NewManager(), bounds: hero.Bounds{ Width: float64(screenWidth), Height: float64(screenHeight), Ground: float64(screenHeight) - groundHeight, }, 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, Jump: input.Jump, Sprint: input.Sprint, }, dt, g.bounds) if input.Shoot { direction := 1.0 if g.hero.GetDirection() == hero.DirLeft { direction = -1.0 } g.projectiles.Shoot(g.hero.X, g.hero.Y-20, direction, g.hero.ProjectileConfig) } g.projectiles.Update(dt, g.bounds.Width, g.bounds.Height) g.totalPlayTime += delta g.trackFPS(delta) if g.showSaveNotification { g.saveNotificationTimer -= delta if g.saveNotificationTimer <= 0 { g.showSaveNotification = false } } } // 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.world.Draw(screen) g.projectiles.Draw(screen) 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) if g.showSaveNotification { g.drawSaveNotification(screen) } } func (g *GameplayScreen) drawSaveNotification(screen *ebiten.Image) { centerX := float32(g.bounds.Width / 2) centerY := float32(30) boxWidth := float32(140) boxHeight := float32(40) vector.DrawFilledRect(screen, centerX-boxWidth/2, centerY-boxHeight/2, boxWidth, boxHeight, color.NRGBA{R: 0, G: 0, B: 0, A: 180}, false) vector.StrokeRect(screen, centerX-boxWidth/2, centerY-boxHeight/2, boxWidth, boxHeight, 2, saveNotificationColor, false) msg := "Game Saved!" textX := centerX - 45 textY := centerY - 5 ebitenutil.DebugPrintAt(screen, msg, int(textX), int(textY)) } func (g *GameplayScreen) ShowSaveNotification() { g.showSaveNotification = true g.saveNotificationTimer = saveNotificationDuration } // Resets the gameplay screen to its initial state. func (g *GameplayScreen) Reset() { screenWidth := int(g.bounds.Width) screenHeight := int(g.bounds.Height) groundHeight := 16.0 w := world.NewWorld() w.AddSurface(&world.Surface{ X: 0, Y: float64(screenHeight) - groundHeight, Width: float64(screenWidth), Height: groundHeight, Tag: world.TagGround, Color: color.NRGBA{R: 34, G: 139, B: 34, A: 255}, }) g.hero = hero.New(hero.Config{ StartX: heroStartX, StartY: heroStartY, Radius: heroRadius, Speed: heroSpeed, Color: heroColor, MaxStamina: heroMaxStamina, StaminaDrain: heroStaminaDrain, StaminaRegen: heroStaminaRegen, }) g.world = w g.projectiles = projectile.NewManager() g.bounds = hero.Bounds{ Width: float64(screenWidth), Height: float64(screenHeight), Ground: float64(screenHeight) - groundHeight, } 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) { screenWidth := int(g.bounds.Width) screenHeight := int(g.bounds.Height) groundHeight := 16.0 w := world.NewWorld() w.AddSurface(&world.Surface{ X: 0, Y: float64(screenHeight) - groundHeight, Width: float64(screenWidth), Height: groundHeight, Tag: world.TagGround, Color: color.NRGBA{R: 34, G: 139, B: 34, A: 255}, }) 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.world = w g.projectiles = projectile.NewManager() g.bounds = hero.Bounds{ Width: float64(screenWidth), Height: float64(screenHeight), Ground: float64(screenHeight) - groundHeight, } 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 }