394 lines
7.4 KiB
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
|
|
}
|