Files
LilGuy/internal/hero/hero.go

394 lines
7.4 KiB
Go

package hero
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/atridad/LilGuy/internal/config"
"github.com/atridad/LilGuy/internal/projectile"
"github.com/atridad/LilGuy/internal/world"
)
const (
defaultRadius = 24.0
defaultSpeed = 200.0
defaultMaxStamina = 100.0
defaultStaminaDrain = 50.0
defaultStaminaRegen = 30.0
heroSpriteScale = 0.175
)
// 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
)
// Tags
type Tag int
const (
TagGravity Tag = iota
)
// Hero state
type Hero struct {
X float64
Y float64
Radius float64
VelocityX float64
VelocityY float64
Speed float64
Color color.NRGBA
Tags map[Tag]bool
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,
Tags: map[Tag]bool{TagGravity: true},
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, w *world.World) {
if dt > 0.1 {
dt = 0.1
}
h.updateMovement(input, dt, bounds, w)
h.updateStamina(input, dt)
h.updateAnimation(dt)
}
// Movement and physics
func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds, w *world.World) {
if h.HasTag(TagGravity) {
h.VelocityY += config.Gravity * dt
}
if h.VelocityY > config.MaxFallSpeed {
h.VelocityY = config.MaxFallSpeed
}
newY := h.Y + h.VelocityY*dt
h.isGrounded = false
if w != nil && h.VelocityY >= 0 {
feetX := h.X
var landedSurface *world.Surface
for _, s := range w.Surfaces {
if !s.IsWalkable() {
continue
}
if feetX >= s.X && feetX <= s.X+s.Width {
if h.Y <= s.Y && newY >= s.Y {
if landedSurface == nil || s.Y < landedSurface.Y {
landedSurface = s
}
}
}
}
if landedSurface != nil {
newY = landedSurface.Y
h.VelocityY = 0
h.isGrounded = true
}
}
if !h.isGrounded {
if newY >= bounds.Ground {
newY = bounds.Ground
h.VelocityY = 0
h.isGrounded = true
}
}
h.Y = newY
if input.Jump && h.isGrounded {
h.VelocityY = config.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 *= config.SprintSpeedMultiplier
}
friction := config.GroundFriction
if !h.isGrounded {
friction = config.AirFriction
}
frictionFactor := 1.0 - (1.0-friction)*dt*60.0
if frictionFactor < 0 {
frictionFactor = 0
}
h.VelocityX = h.VelocityX*frictionFactor + targetVelocityX*(1.0-frictionFactor)
newX := h.X + h.VelocityX*dt
if newX < h.Radius {
h.X = h.Radius
h.VelocityX = 0
} else if newX > bounds.Width-h.Radius {
h.X = bounds.Width - h.Radius
h.VelocityX = 0
} else {
h.X = newX
}
}
func (h *Hero) updateStamina(input Input, dt float64) {
if !input.Sprint {
h.wasSprintHeld = false
if h.Stamina >= h.MaxStamina*config.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
}
// reset animation on state change
if key != h.lastAnimKey {
h.animFrame = 0
h.animTimer = 0
h.lastAnimKey = key
}
// advance animation only when moving
if isMoving {
animSpeed := config.NormalAnimSpeed * 0.5
if h.isSprinting {
animSpeed = config.SprintAnimSpeed * 0.5
}
if !h.isGrounded {
animSpeed = config.JumpingAnimSpeed * 0.5
}
h.animTimer += dt
// precise frame advancement
if h.animTimer >= animSpeed {
frameAdvance := int(h.animTimer / animSpeed)
h.animTimer -= animSpeed * float64(frameAdvance)
h.animFrame = (h.animFrame + frameAdvance) % config.AnimFrameWrap
}
} else {
h.animTimer = 0
}
}
func (h *Hero) getVisualState() VisualState {
if h.Stamina < h.MaxStamina*config.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{}
// center sprite horizontally, align bottom to feet
op.GeoM.Translate(-actualWidth/2, -actualHeight)
op.GeoM.Scale(config.HeroSpriteScale, config.HeroSpriteScale)
// round position to nearest pixel for crisp rendering
drawX := float64(int(h.X + 0.5))
drawY := float64(int(h.Y + 0.5))
op.GeoM.Translate(drawX, drawY)
// apply visual state coloring
state := h.getVisualState()
switch state {
case StateExhausted:
op.ColorScale.ScaleWithColor(color.RGBA{R: 255, G: 100, B: 100, A: 255})
case StateSprinting:
// no color modification for sprinting
case StateIdle:
// no color modification for idle
}
op.Filter = ebiten.FilterNearest
screen.DrawImage(sprite, op)
}
}
func (h *Hero) getCurrentSprite() *ebiten.Image {
return getKnightSprite(h.direction, h.isMoving, h.animFrame)
}
func (h *Hero) AddTag(tag Tag) {
if h.Tags == nil {
h.Tags = make(map[Tag]bool)
}
h.Tags[tag] = true
}
func (h *Hero) RemoveTag(tag Tag) {
if h.Tags == nil {
return
}
delete(h.Tags, tag)
}
func (h *Hero) HasTag(tag Tag) bool {
if h.Tags == nil {
return false
}
return h.Tags[tag]
}
func (h *Hero) GetDirection() Direction {
return h.direction
}