Files
LilGuy/internal/hero/hero.go
2025-12-16 09:13:22 -07:00

401 lines
7.6 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
Brightness float64
}
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},
},
Brightness: 1.0,
}
}
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
}
if h.Brightness < 1.0 {
op.ColorScale.Scale(float32(h.Brightness), float32(h.Brightness), float32(h.Brightness), 1.0)
}
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
}