Files
LilGuy/internal/hero/hero.go
2025-11-24 12:18:32 -07:00

310 lines
5.9 KiB
Go

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
}