package hero import ( "image/color" "github.com/hajimehoshi/ebiten/v2" "github.com/atridad/LilGuy/internal/projectile" ) const ( defaultRadius = 24.0 defaultSpeed = 200.0 defaultMaxStamina = 100.0 defaultStaminaDrain = 50.0 defaultStaminaRegen = 30.0 sprintSpeedMultiplier = 1.8 sprintRecoveryThreshold = 0.2 normalAnimSpeed = 0.15 idleAnimSpeed = 0.3 sprintAnimSpeed = 0.08 exhaustedThreshold = 0.2 animFrameWrap = 4096 heroSpriteScale = 0.175 fixedSpriteHeight = 329.0 fixedSpriteWidth = 315.0 gravity = 1200.0 jumpStrength = -450.0 maxFallSpeed = 800.0 groundFriction = 0.85 airFriction = 0.95 ) 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 ) type Hero struct { X float64 Y float64 Radius float64 VelocityX float64 VelocityY float64 Speed float64 Color color.NRGBA 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, 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) { h.updateMovement(input, dt, bounds) h.updateStamina(input, dt) h.updateAnimation(dt) } func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) { h.VelocityY += gravity * dt if h.VelocityY > maxFallSpeed { h.VelocityY = maxFallSpeed } h.Y += h.VelocityY * dt footPosition := h.Y if footPosition >= bounds.Ground { h.Y = bounds.Ground h.VelocityY = 0 h.isGrounded = true } else { h.isGrounded = false } if input.Jump && h.isGrounded { h.VelocityY = 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 *= sprintSpeedMultiplier } friction := groundFriction if !h.isGrounded { friction = airFriction } h.VelocityX = h.VelocityX*friction + targetVelocityX*(1-friction) h.X += h.VelocityX * dt if h.X < h.Radius { h.X = h.Radius h.VelocityX = 0 } if h.X > bounds.Width-h.Radius { h.X = bounds.Width - h.Radius h.VelocityX = 0 } } func (h *Hero) updateStamina(input Input, dt float64) { if !input.Sprint { h.wasSprintHeld = false if h.Stamina >= h.MaxStamina*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 } } } func (h *Hero) updateAnimation(dt float64) { isMoving := h.isMoving key := animationKey{direction: h.direction, state: animIdle} if isMoving { key.state = animMove } if key != h.lastAnimKey { h.animFrame = 0 h.animTimer = 0 h.lastAnimKey = key } if isMoving { animSpeed := normalAnimSpeed * 0.5 if h.isSprinting { animSpeed = sprintAnimSpeed * 0.5 } h.animTimer += dt frameAdvance := int(h.animTimer / animSpeed) if frameAdvance > 0 { h.animTimer -= animSpeed * float64(frameAdvance) h.animFrame = (h.animFrame + frameAdvance) % animFrameWrap } } } func (h *Hero) getVisualState() VisualState { if h.Stamina < h.MaxStamina*exhaustedThreshold { return StateExhausted } if h.isSprinting { return StateSprinting } return StateIdle } 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{} op.GeoM.Translate(-actualWidth/2, -actualHeight) op.GeoM.Scale(heroSpriteScale, heroSpriteScale) op.GeoM.Translate(h.X, h.Y) state := h.getVisualState() switch state { case StateExhausted: op.ColorScale.ScaleWithColor(color.RGBA{R: 255, G: 100, B: 100, A: 255}) case StateSprinting: case StateIdle: } screen.DrawImage(sprite, op) } } func (h *Hero) getCurrentSprite() *ebiten.Image { return getKnightSprite(h.direction, h.isMoving, h.animFrame) } func (h *Hero) GetDirection() Direction { return h.direction }