package hero import ( "image/color" "github.com/hajimehoshi/ebiten/v2" "github.com/atridad/LilGuy/internal/config" "github.com/atridad/LilGuy/internal/projectile" "github.com/atridad/LilGuy/internal/world" ) const ( defaultRadius = 24.0 defaultSpeed = 200.0 defaultMaxStamina = 100.0 defaultStaminaDrain = 50.0 defaultStaminaRegen = 30.0 heroSpriteScale = 0.175 ) // Input and bounds type Input struct { Left bool Right bool Jump bool Sprint bool } type Bounds struct { Width float64 Height float64 Ground float64 } type VisualState int const ( StateIdle VisualState = iota StateSprinting StateExhausted ) type Direction int const ( DirLeft Direction = iota DirRight ) // Tags type Tag int const ( TagGravity Tag = iota ) // Hero state type Hero struct { X float64 Y float64 Radius float64 VelocityX float64 VelocityY float64 Speed float64 Color color.NRGBA Tags map[Tag]bool Stamina float64 MaxStamina float64 StaminaDrain float64 StaminaRegen float64 canSprint bool wasSprintHeld bool isSprinting bool isMoving bool isGrounded bool direction Direction animFrame int animTimer float64 lastAnimKey animationKey ProjectileConfig projectile.ProjectileConfig } type Config struct { StartX float64 StartY float64 Radius float64 Speed float64 Color color.NRGBA MaxStamina float64 StaminaDrain float64 StaminaRegen float64 } func New(cfg Config) *Hero { if cfg.Radius <= 0 { cfg.Radius = defaultRadius } if cfg.Speed <= 0 { cfg.Speed = defaultSpeed } if cfg.Color.A == 0 { cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255} } if cfg.MaxStamina <= 0 { cfg.MaxStamina = defaultMaxStamina } if cfg.StaminaDrain <= 0 { cfg.StaminaDrain = defaultStaminaDrain } if cfg.StaminaRegen <= 0 { cfg.StaminaRegen = defaultStaminaRegen } return &Hero{ X: cfg.StartX, Y: cfg.StartY, Radius: cfg.Radius, Speed: cfg.Speed, Color: cfg.Color, Tags: map[Tag]bool{TagGravity: true}, Stamina: cfg.MaxStamina, MaxStamina: cfg.MaxStamina, StaminaDrain: cfg.StaminaDrain, StaminaRegen: cfg.StaminaRegen, canSprint: true, wasSprintHeld: false, direction: DirRight, animFrame: 0, animTimer: 0, lastAnimKey: animationKey{direction: DirRight, state: animIdle}, ProjectileConfig: projectile.ProjectileConfig{ Speed: 500.0, Radius: 4.0, Color: color.NRGBA{R: 255, G: 0, B: 0, A: 255}, }, } } func (h *Hero) Update(input Input, dt float64, bounds Bounds, w *world.World) { if dt > 0.1 { dt = 0.1 } h.updateMovement(input, dt, bounds, w) h.updateStamina(input, dt) h.updateAnimation(dt) } // Movement and physics func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds, w *world.World) { if h.HasTag(TagGravity) { h.VelocityY += config.Gravity * dt } if h.VelocityY > config.MaxFallSpeed { h.VelocityY = config.MaxFallSpeed } newY := h.Y + h.VelocityY*dt h.isGrounded = false if w != nil && h.VelocityY >= 0 { feetX := h.X var landedSurface *world.Surface for _, s := range w.Surfaces { if !s.IsWalkable() { continue } if feetX >= s.X && feetX <= s.X+s.Width { if h.Y <= s.Y && newY >= s.Y { if landedSurface == nil || s.Y < landedSurface.Y { landedSurface = s } } } } if landedSurface != nil { newY = landedSurface.Y h.VelocityY = 0 h.isGrounded = true } } if !h.isGrounded { if newY >= bounds.Ground { newY = bounds.Ground h.VelocityY = 0 h.isGrounded = true } } h.Y = newY if input.Jump && h.isGrounded { h.VelocityY = config.JumpStrength h.isGrounded = false } targetVelocityX := 0.0 if input.Left { targetVelocityX -= h.Speed h.direction = DirLeft } if input.Right { targetVelocityX += h.Speed h.direction = DirRight } h.isMoving = targetVelocityX != 0 h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && h.isMoving if h.isSprinting { targetVelocityX *= config.SprintSpeedMultiplier } friction := config.GroundFriction if !h.isGrounded { friction = config.AirFriction } frictionFactor := 1.0 - (1.0-friction)*dt*60.0 if frictionFactor < 0 { frictionFactor = 0 } h.VelocityX = h.VelocityX*frictionFactor + targetVelocityX*(1.0-frictionFactor) newX := h.X + h.VelocityX*dt if newX < h.Radius { h.X = h.Radius h.VelocityX = 0 } else if newX > bounds.Width-h.Radius { h.X = bounds.Width - h.Radius h.VelocityX = 0 } else { h.X = newX } } func (h *Hero) updateStamina(input Input, dt float64) { if !input.Sprint { h.wasSprintHeld = false if h.Stamina >= h.MaxStamina*config.SprintRecoveryThreshold { h.canSprint = true } } if h.isSprinting { h.wasSprintHeld = true h.Stamina -= h.StaminaDrain * dt if h.Stamina <= 0 { h.Stamina = 0 h.canSprint = false } } else { h.Stamina += h.StaminaRegen * dt if h.Stamina > h.MaxStamina { h.Stamina = h.MaxStamina } } } // Animation func (h *Hero) updateAnimation(dt float64) { isMoving := h.isMoving key := animationKey{direction: h.direction, state: animIdle} if isMoving { key.state = animMove } // reset animation on state change if key != h.lastAnimKey { h.animFrame = 0 h.animTimer = 0 h.lastAnimKey = key } // advance animation only when moving if isMoving { animSpeed := config.NormalAnimSpeed * 0.5 if h.isSprinting { animSpeed = config.SprintAnimSpeed * 0.5 } if !h.isGrounded { animSpeed = config.JumpingAnimSpeed * 0.5 } h.animTimer += dt // precise frame advancement if h.animTimer >= animSpeed { frameAdvance := int(h.animTimer / animSpeed) h.animTimer -= animSpeed * float64(frameAdvance) h.animFrame = (h.animFrame + frameAdvance) % config.AnimFrameWrap } } else { h.animTimer = 0 } } func (h *Hero) getVisualState() VisualState { if h.Stamina < h.MaxStamina*config.ExhaustedThreshold { return StateExhausted } if h.isSprinting { return StateSprinting } return StateIdle } // Rendering func (h *Hero) Draw(screen *ebiten.Image) { sprite := h.getCurrentSprite() if sprite != nil { bounds := sprite.Bounds() actualHeight := float64(bounds.Dy()) actualWidth := float64(bounds.Dx()) op := &ebiten.DrawImageOptions{} // center sprite horizontally, align bottom to feet op.GeoM.Translate(-actualWidth/2, -actualHeight) op.GeoM.Scale(config.HeroSpriteScale, config.HeroSpriteScale) // round position to nearest pixel for crisp rendering drawX := float64(int(h.X + 0.5)) drawY := float64(int(h.Y + 0.5)) op.GeoM.Translate(drawX, drawY) // apply visual state coloring state := h.getVisualState() switch state { case StateExhausted: op.ColorScale.ScaleWithColor(color.RGBA{R: 255, G: 100, B: 100, A: 255}) case StateSprinting: // no color modification for sprinting case StateIdle: // no color modification for idle } op.Filter = ebiten.FilterNearest screen.DrawImage(sprite, op) } } func (h *Hero) getCurrentSprite() *ebiten.Image { return getKnightSprite(h.direction, h.isMoving, h.animFrame) } func (h *Hero) AddTag(tag Tag) { if h.Tags == nil { h.Tags = make(map[Tag]bool) } h.Tags[tag] = true } func (h *Hero) RemoveTag(tag Tag) { if h.Tags == nil { return } delete(h.Tags, tag) } func (h *Hero) HasTag(tag Tag) bool { if h.Tags == nil { return false } return h.Tags[tag] } func (h *Hero) GetDirection() Direction { return h.direction }