310 lines
5.9 KiB
Go
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
|
|
}
|