package hero import ( "image/color" "math" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/vector" ) // Direction flags from the controls. type Input struct { Left bool Right bool Up bool Down bool Sprint bool } // Playfield limits for movement. type Bounds struct { Width float64 Height float64 } // Visual states for the hero. type VisualState int const ( StateIdle VisualState = iota StateSprinting StateExhausted ) // Player avatar data. type Hero struct { X float64 Y float64 Radius float64 Speed float64 Color color.NRGBA Stamina float64 MaxStamina float64 StaminaDrain float64 StaminaRegen float64 canSprint bool wasSprintHeld bool isSprinting bool } // Spawn settings for the avatar. type Config struct { StartX float64 StartY float64 Radius float64 Speed float64 Color color.NRGBA MaxStamina float64 StaminaDrain float64 StaminaRegen float64 } // Builds an avatar from the config with fallbacks. func New(cfg Config) *Hero { if cfg.Radius <= 0 { cfg.Radius = 24 } if cfg.Speed <= 0 { cfg.Speed = 180 } if cfg.Color.A == 0 { cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255} } if cfg.MaxStamina <= 0 { cfg.MaxStamina = 100 } if cfg.StaminaDrain <= 0 { cfg.StaminaDrain = 50 } if cfg.StaminaRegen <= 0 { cfg.StaminaRegen = 30 } 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, } } // Applies movement input and clamps to the playfield. func (h *Hero) Update(input Input, dt float64, bounds Bounds) { dx, dy := 0.0, 0.0 if input.Left { dx -= 1 } if input.Right { dx += 1 } if input.Up { dy -= 1 } if input.Down { dy += 1 } isMoving := dx != 0 || dy != 0 if isMoving { length := math.Hypot(dx, dy) dx /= length dy /= length } speed := h.Speed if !input.Sprint { h.wasSprintHeld = false if h.Stamina >= h.MaxStamina*0.2 { h.canSprint = true } } h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && isMoving if h.isSprinting { speed *= 2.0 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 } } h.X += dx * speed * dt h.Y += dy * speed * dt maxX := math.Max(h.Radius, bounds.Width-h.Radius) maxY := math.Max(h.Radius, bounds.Height-h.Radius) h.X = clamp(h.X, h.Radius, maxX) h.Y = clamp(h.Y, h.Radius, maxY) } // Returns the current visual state based on hero state. func (h *Hero) getVisualState() VisualState { if h.Stamina < h.MaxStamina*0.2 { return StateExhausted } if h.isSprinting { return StateSprinting } return StateIdle } // Renders the avatar. func (h *Hero) Draw(screen *ebiten.Image) { state := h.getVisualState() vector.FillCircle( screen, float32(h.X), float32(h.Y), float32(h.Radius), h.Color, false, ) eyeOffsetX := h.Radius * 0.3 eyeOffsetY := h.Radius * 0.25 switch state { case StateExhausted: drawExhaustedFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY) case StateSprinting: drawSprintingFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY) case StateIdle: drawIdleFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY) } } func drawIdleFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) { eyeRadius := radius * 0.15 vector.FillCircle( screen, float32(x-eyeOffsetX), float32(y-eyeOffsetY), float32(eyeRadius), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) vector.FillCircle( screen, float32(x+eyeOffsetX), float32(y-eyeOffsetY), float32(eyeRadius), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) smileRadius := radius * 0.5 smileY := y + radius*0.15 for angle := 0.3; angle <= 2.84; angle += 0.15 { smileX := x + smileRadius*math.Cos(angle) smileYPos := smileY + smileRadius*0.3*math.Sin(angle) vector.FillCircle( screen, float32(smileX), float32(smileYPos), float32(radius*0.08), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) } } func drawSprintingFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) { eyeWidth := radius * 0.2 eyeHeight := radius * 0.12 for ex := -eyeWidth; ex <= eyeWidth; ex += radius * 0.08 { for ey := -eyeHeight; ey <= eyeHeight; ey += radius * 0.08 { if ex*ex/(eyeWidth*eyeWidth)+ey*ey/(eyeHeight*eyeHeight) <= 1 { vector.FillCircle( screen, float32(x-eyeOffsetX+ex), float32(y-eyeOffsetY+ey), float32(radius*0.05), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) } } } for ex := -eyeWidth; ex <= eyeWidth; ex += radius * 0.08 { for ey := -eyeHeight; ey <= eyeHeight; ey += radius * 0.08 { if ex*ex/(eyeWidth*eyeWidth)+ey*ey/(eyeHeight*eyeHeight) <= 1 { vector.FillCircle( screen, float32(x+eyeOffsetX+ex), float32(y-eyeOffsetY+ey), float32(radius*0.05), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) } } } mouthY := y + radius*0.3 mouthWidth := radius * 0.5 for mx := -mouthWidth; mx <= mouthWidth; mx += radius * 0.08 { vector.FillCircle( screen, float32(x+mx), float32(mouthY), float32(radius*0.06), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) } } func drawExhaustedFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) { eyeSize := radius * 0.15 for i := -eyeSize; i <= eyeSize; i += radius * 0.08 { vector.FillCircle( screen, float32(x-eyeOffsetX+i), float32(y-eyeOffsetY+i), float32(radius*0.05), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) vector.FillCircle( screen, float32(x-eyeOffsetX+i), float32(y-eyeOffsetY-i), float32(radius*0.05), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) } for i := -eyeSize; i <= eyeSize; i += radius * 0.08 { vector.FillCircle( screen, float32(x+eyeOffsetX+i), float32(y-eyeOffsetY+i), float32(radius*0.05), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) vector.FillCircle( screen, float32(x+eyeOffsetX+i), float32(y-eyeOffsetY-i), float32(radius*0.05), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) } mouthY := y + radius*0.35 mouthWidth := radius * 0.2 mouthHeight := radius * 0.25 for angle := 0.0; angle < 2*math.Pi; angle += 0.3 { mx := x + mouthWidth*math.Cos(angle) my := mouthY + mouthHeight*math.Sin(angle) vector.FillCircle( screen, float32(mx), float32(my), float32(radius*0.06), color.NRGBA{R: 0, G: 0, B: 0, A: 255}, false, ) } } func clamp(value, min, max float64) float64 { if value < min { return min } if value > max { return max } return value }