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 Brightness float64 } 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}, }, Brightness: 1.0, } } 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 } if h.Brightness < 1.0 { op.ColorScale.Scale(float32(h.Brightness), float32(h.Brightness), float32(h.Brightness), 1.0) } 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 }