package screens import ( "fmt" "image/color" "math" "time" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/vector" "github.com/atridad/LilGuy/internal/config" "github.com/atridad/LilGuy/internal/hero" "github.com/atridad/LilGuy/internal/maps" "github.com/atridad/LilGuy/internal/portal" "github.com/atridad/LilGuy/internal/projectile" "github.com/atridad/LilGuy/internal/raycast" "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" ) type GameplayInput struct { Left bool Right bool Jump bool Sprint bool Shoot bool } type GameplayScreen struct { hero *hero.Hero hud hud.Overlay world *world.World projectiles *projectile.Manager portals *portal.Manager mapManager *maps.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 portalVisibility *bool // Raycasting system raycastSystem *raycast.System raycastEnabled *bool raycastDebugMode *bool } func NewGameplayScreen(screenWidth, screenHeight int, mapManager *maps.Manager, fpsEnabled *bool, portalVisibility *bool, raycastEnabled *bool, raycastDebugMode *bool) *GameplayScreen { cfg := config.Default() // Ensure we have a current map if mapManager.CurrentMap() == nil { // Fallback or error later } currentMap := mapManager.CurrentMap() portalMgr := portal.NewManager() for _, p := range currentMap.Portals { portalMgr.AddPortal(p) } if portalVisibility != nil && *portalVisibility { for _, p := range portalMgr.Portals { p.Visible = true } } gs := &GameplayScreen{ hero: hero.New(hero.Config{ StartX: cfg.Hero.StartX, StartY: cfg.Hero.StartY, Radius: cfg.Hero.Radius, Speed: cfg.Hero.Speed, Color: cfg.Hero.Color, MaxStamina: cfg.Hero.MaxStamina, StaminaDrain: cfg.Hero.StaminaDrain, StaminaRegen: cfg.Hero.StaminaRegen, }), hud: hud.Overlay{ X: cfg.HUD.X, Y: cfg.HUD.Y, Color: color.White, ScreenName: currentMap.DisplayName, }, world: currentMap.World, projectiles: projectile.NewManager(), portals: portalMgr, mapManager: mapManager, bounds: hero.Bounds{ Width: float64(screenWidth), Height: float64(screenHeight), Ground: float64(screenHeight) - config.GroundHeight, }, lastTick: time.Now(), gameStartTime: time.Now(), fpsEnabled: fpsEnabled, portalVisibility: portalVisibility, raycastSystem: raycast.NewSystem(screenWidth, screenHeight), raycastEnabled: raycastEnabled, raycastDebugMode: raycastDebugMode, } gs.portals.OnTransition = gs.handlePortalTransition return gs } func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) { if delta > 100*time.Millisecond { delta = 100 * time.Millisecond } dt := delta.Seconds() g.hero.Update(hero.Input{ Left: input.Left, Right: input.Right, Jump: input.Jump, Sprint: input.Sprint, }, dt, g.bounds, g.world) 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.portals.Update(dt) // Update raycasting lights based on current map g.updateRaycastLights() // check for portal collisions g.checkPortalCollision() g.totalPlayTime += delta g.trackFPS(delta) // update save notification timer if g.showSaveNotification { g.saveNotificationTimer -= delta if g.saveNotificationTimer <= 0 { g.showSaveNotification = false } } } func (g *GameplayScreen) updateRaycastLights() { g.raycastSystem.ClearLights() currentMap := g.mapManager.CurrentMap() if currentMap == nil { return } for _, lightSrc := range currentMap.Lighting.Sources { g.raycastSystem.SetShadowIntensity(lightSrc.ShadowDarkness) g.raycastSystem.AddLight(&raycast.LightSource{ X: lightSrc.X, Y: lightSrc.Y, Radius: lightSrc.Radius, Color: lightSrc.Color, Intensity: lightSrc.Intensity, }) } } // portal collision detection func (g *GameplayScreen) checkPortalCollision() { heroRadius := g.hero.Radius heroX := g.hero.X - heroRadius heroY := g.hero.Y - heroRadius heroWidth := heroRadius * 2 heroHeight := heroRadius * 2 if p := g.portals.CheckCollision(heroX, heroY, heroWidth, heroHeight); p != nil { g.portals.TriggerTransition(p, g.hero.X, g.hero.Y) } } func (g *GameplayScreen) handlePortalTransition(event portal.TransitionEvent) { destMap, destPortal := g.mapManager.ResolveTransition(event) if destMap == nil { return } // Switch to destination map g.mapManager.SetCurrentMap(destMap.ID) g.world = destMap.World // Clear and reload portals for new map g.portals.Clear() for _, p := range destMap.Portals { g.portals.AddPortal(p) } // set portal visibility if g.portalVisibility != nil { visible := *g.portalVisibility for _, p := range g.portals.Portals { p.Visible = visible } } // Find destination portal and position hero if destPortal != nil { // Position hero based on which side they're entering from switch destPortal.Side { case portal.SideLeft: g.hero.X = destPortal.X + destPortal.Width + g.hero.Radius + 10 g.hero.Y = event.HeroY case portal.SideRight: g.hero.X = destPortal.X - g.hero.Radius - 10 g.hero.Y = event.HeroY case portal.SideTop: g.hero.X = event.HeroX g.hero.Y = destPortal.Y + destPortal.Height + g.hero.Radius + 10 case portal.SideBottom: g.hero.X = event.HeroX g.hero.Y = destPortal.Y - g.hero.Radius - 10 } } } // FPS tracking func (g *GameplayScreen) trackFPS(delta time.Duration) { if g.fpsEnabled == nil || !*g.fpsEnabled { return } g.fpsAccumulator += delta g.fpsFrames++ if g.fpsAccumulator >= config.FPSSampleWindow { g.fpsValue = float64(g.fpsFrames) / g.fpsAccumulator.Seconds() g.fpsAccumulator = 0 g.fpsFrames = 0 } } // Rendering func (g *GameplayScreen) Draw(screen *ebiten.Image) { cfg := config.Default() currentMap := g.mapManager.CurrentMap() if currentMap != nil { currentMap.Draw(screen) g.hud.ScreenName = currentMap.DisplayName g.drawAmbientOverlay(screen, currentMap) // Draw raycasting shadows/lighting if g.raycastEnabled != nil && *g.raycastEnabled { g.raycastSystem.Draw(screen, g.world) if g.raycastDebugMode != nil && *g.raycastDebugMode { g.raycastSystem.DrawDebug(screen, g.world) } } // Draw sun or moon circle in top-right g.drawCelestialBody(screen, currentMap) // Update hero lighting state g.updateHeroLighting(currentMap) } else { screen.Fill(cfg.Visual.BackgroundColor) } g.projectiles.Draw(screen) g.hero.Draw(screen) staminaColor := cfg.Visual.StaminaNormalColor if g.hero.Stamina < g.hero.MaxStamina*config.StaminaLowThreshold { staminaColor = cfg.Visual.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 { ratio := g.fpsValue / float64(config.TargetTPS) fpsColor := cfg.Visual.FPSGoodColor switch { case ratio < config.FPSPoorThreshold: fpsColor = cfg.Visual.FPSPoorColor case ratio < config.FPSWarnThreshold: fpsColor = cfg.Visual.FPSWarnColor } fpsMeter := status.Meter{ Label: fmt.Sprintf("Framerate: %3.0f FPS", g.fpsValue), Base: -1, 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) cfg := config.Default() vector.FillRect(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, cfg.Visual.SaveNotificationColor, false) msg := "Game Saved!" textX := centerX - 45 textY := centerY - 5 ebitenutil.DebugPrintAt(screen, msg, int(textX), int(textY)) } func (g *GameplayScreen) updateHeroLighting(currentMap *maps.Map) { if g.raycastEnabled == nil || !*g.raycastEnabled { g.hero.Brightness = 1.0 return } ambient := currentMap.Lighting.AmbientBrightness maxDirectLight := 0.0 for _, light := range currentMap.Lighting.Sources { inShadow := g.raycastSystem.IsPointInShadow(g.hero.X, g.hero.Y, g.world) if !inShadow { maxDirectLight = math.Max(maxDirectLight, light.Intensity) } else { indirect := light.Intensity * (1.0 - light.ShadowDarkness) maxDirectLight = math.Max(maxDirectLight, indirect) } } g.hero.Brightness = math.Min(1.0, ambient+maxDirectLight) } func (g *GameplayScreen) drawAmbientOverlay(screen *ebiten.Image, currentMap *maps.Map) { if currentMap.Lighting.AmbientBrightness >= 1.0 { return } darkness := 1.0 - currentMap.Lighting.AmbientBrightness alpha := uint8(darkness * 200) overlay := ebiten.NewImage(int(g.bounds.Width), int(g.bounds.Height)) overlay.Fill(color.RGBA{R: 0, G: 0, B: 0, A: alpha}) screen.DrawImage(overlay, &ebiten.DrawImageOptions{}) } func (g *GameplayScreen) drawCelestialBody(screen *ebiten.Image, currentMap *maps.Map) { if currentMap == nil { return } for _, lightSrc := range currentMap.Lighting.Sources { cx := float32(lightSrc.X) cy := float32(lightSrc.Y) radius := float32(25) vector.DrawFilledCircle(screen, cx, cy, radius, lightSrc.Color, true) glowColor := lightSrc.Color glowColor.A = 80 vector.DrawFilledCircle(screen, cx, cy, radius+3, glowColor, true) } } func (g *GameplayScreen) ShowSaveNotification() { g.showSaveNotification = true g.saveNotificationTimer = config.SaveNotificationDuration } // State management func (g *GameplayScreen) Reset() { cfg := config.Default() screenWidth := int(g.bounds.Width) screenHeight := int(g.bounds.Height) g.mapManager.Reset() g.mapManager.SetCurrentMap("plains") currentMap := g.mapManager.CurrentMap() g.hero = hero.New(hero.Config{ StartX: cfg.Hero.StartX, StartY: cfg.Hero.StartY, Radius: cfg.Hero.Radius, Speed: cfg.Hero.Speed, Color: cfg.Hero.Color, MaxStamina: cfg.Hero.MaxStamina, StaminaDrain: cfg.Hero.StaminaDrain, StaminaRegen: cfg.Hero.StaminaRegen, }) g.world = currentMap.World g.projectiles = projectile.NewManager() g.portals = portal.NewManager() for _, p := range currentMap.Portals { g.portals.AddPortal(p) } g.portals.OnTransition = g.handlePortalTransition // set portal visibility if g.portalVisibility != nil { visible := *g.portalVisibility for _, p := range g.portals.Portals { p.Visible = visible } } g.bounds = hero.Bounds{ Width: float64(screenWidth), Height: float64(screenHeight), Ground: float64(screenHeight) - config.GroundHeight, } g.lastTick = time.Now() g.gameStartTime = time.Now() g.totalPlayTime = 0 g.fpsFrames = 0 g.fpsAccumulator = 0 g.fpsValue = 0 } func (g *GameplayScreen) SaveState() *save.GameState { currentMapID := "plains" if g.mapManager.CurrentMap() != nil { currentMapID = g.mapManager.CurrentMap().ID } return &save.GameState{ CurrentMap: currentMapID, HeroX: g.hero.X, HeroY: g.hero.Y, HeroStamina: g.hero.Stamina, PlayTimeMS: g.totalPlayTime.Milliseconds(), } } func (g *GameplayScreen) LoadState(state *save.GameState) { cfg := config.Default() screenWidth := int(g.bounds.Width) screenHeight := int(g.bounds.Height) g.mapManager.Reset() mapID := state.CurrentMap if mapID == "" { mapID = "plains" } if err := g.mapManager.SetCurrentMap(mapID); err != nil { g.mapManager.SetCurrentMap("plains") } currentMap := g.mapManager.CurrentMap() g.hero = hero.New(hero.Config{ StartX: state.HeroX, StartY: state.HeroY, Radius: cfg.Hero.Radius, Speed: cfg.Hero.Speed, Color: cfg.Hero.Color, MaxStamina: cfg.Hero.MaxStamina, StaminaDrain: cfg.Hero.StaminaDrain, StaminaRegen: cfg.Hero.StaminaRegen, }) g.hero.Stamina = state.HeroStamina g.world = currentMap.World g.projectiles = projectile.NewManager() g.portals = portal.NewManager() for _, p := range currentMap.Portals { g.portals.AddPortal(p) } g.portals.OnTransition = g.handlePortalTransition // set portal visibility if g.portalVisibility != nil { visible := *g.portalVisibility for _, p := range g.portals.Portals { p.Visible = visible } } g.bounds = hero.Bounds{ Width: float64(screenWidth), Height: float64(screenHeight), Ground: float64(screenHeight) - config.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 } // ToggleRaycast toggles the raycasting lighting system on/off func (g *GameplayScreen) ToggleRaycast() { if g.raycastEnabled != nil { *g.raycastEnabled = !*g.raycastEnabled } } // ToggleRaycastDebug toggles the raycasting debug visualization func (g *GameplayScreen) ToggleRaycastDebug() { if g.raycastDebugMode != nil { *g.raycastDebugMode = !*g.raycastDebugMode } } // IsRaycastEnabled returns whether raycasting is enabled func (g *GameplayScreen) IsRaycastEnabled() bool { if g.raycastEnabled == nil { return false } return *g.raycastEnabled } // IsRaycastDebugMode returns whether raycasting debug mode is enabled func (g *GameplayScreen) IsRaycastDebugMode() bool { if g.raycastDebugMode == nil { return false } return *g.raycastDebugMode }