Files
LilGuy/internal/hero/hero.go

321 lines
6.0 KiB
Go

package hero
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/atridad/LilGuy/internal/projectile"
)
// Hero defaults
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
)
// 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
)
// Hero state
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)
}
// Movement and physics
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
}
}
}
// Animation
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
}
// 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{}
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
}