308 lines
5.6 KiB
Go
308 lines
5.6 KiB
Go
package hero
|
|
|
|
import (
|
|
"image/color"
|
|
"math"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
)
|
|
|
|
// Default values and gameplay constants.
|
|
|
|
const (
|
|
// Default values if not specified in config
|
|
defaultRadius = 24.0
|
|
defaultSpeed = 180.0
|
|
defaultMaxStamina = 100.0
|
|
defaultStaminaDrain = 50.0 // Per second when sprinting
|
|
defaultStaminaRegen = 30.0 // Per second when not sprinting
|
|
|
|
// Sprint mechanics
|
|
sprintSpeedMultiplier = 2.0
|
|
sprintRecoveryThreshold = 0.2 // 20% stamina needed to sprint again
|
|
|
|
// Animation
|
|
normalAnimSpeed = 0.15 // Seconds per frame when walking
|
|
sprintAnimSpeed = 0.08 // Seconds per frame when sprinting
|
|
|
|
// Visual state thresholds
|
|
exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina
|
|
)
|
|
|
|
type Input struct {
|
|
Left bool
|
|
Right bool
|
|
Up bool
|
|
Down bool
|
|
Sprint bool
|
|
}
|
|
|
|
type Bounds struct {
|
|
Width float64
|
|
Height float64
|
|
}
|
|
|
|
type VisualState int
|
|
|
|
const (
|
|
StateIdle VisualState = iota
|
|
StateSprinting
|
|
StateExhausted
|
|
)
|
|
|
|
type Direction int
|
|
|
|
const (
|
|
DirDown Direction = iota
|
|
DirUp
|
|
DirLeft
|
|
DirRight
|
|
)
|
|
|
|
type Hero struct {
|
|
// Position and size
|
|
X float64
|
|
Y float64
|
|
Radius float64
|
|
|
|
// Movement
|
|
Speed float64
|
|
|
|
// Appearance
|
|
Color color.NRGBA
|
|
|
|
// Stamina system
|
|
Stamina float64
|
|
MaxStamina float64
|
|
StaminaDrain float64
|
|
StaminaRegen float64
|
|
|
|
// Internal state
|
|
canSprint bool
|
|
wasSprintHeld bool
|
|
isSprinting bool
|
|
direction Direction
|
|
animFrame int
|
|
animTimer 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,
|
|
Stamina: cfg.MaxStamina,
|
|
MaxStamina: cfg.MaxStamina,
|
|
StaminaDrain: cfg.StaminaDrain,
|
|
StaminaRegen: cfg.StaminaRegen,
|
|
canSprint: true,
|
|
wasSprintHeld: false,
|
|
direction: DirDown,
|
|
animFrame: 0,
|
|
animTimer: 0,
|
|
}
|
|
}
|
|
|
|
func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
|
|
h.updateMovement(input, dt, bounds)
|
|
h.updateStamina(input, dt)
|
|
h.updateAnimation(input, dt)
|
|
}
|
|
|
|
func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) {
|
|
dx, dy := 0.0, 0.0
|
|
|
|
if input.Left {
|
|
dx -= 1
|
|
h.direction = DirLeft
|
|
}
|
|
if input.Right {
|
|
dx += 1
|
|
h.direction = DirRight
|
|
}
|
|
if input.Up {
|
|
dy -= 1
|
|
h.direction = DirUp
|
|
}
|
|
if input.Down {
|
|
dy += 1
|
|
h.direction = DirDown
|
|
}
|
|
|
|
isMoving := dx != 0 || dy != 0
|
|
|
|
if isMoving {
|
|
length := math.Hypot(dx, dy)
|
|
dx /= length
|
|
dy /= length
|
|
}
|
|
|
|
speed := h.Speed
|
|
h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && isMoving
|
|
if h.isSprinting {
|
|
speed *= sprintSpeedMultiplier
|
|
}
|
|
|
|
h.X += dx * speed * dt
|
|
h.Y += dy * speed * dt
|
|
|
|
maxX := math.Max(h.Radius, bounds.Width-h.Radius)
|
|
maxY := math.Max(h.Radius, bounds.Height-h.Radius)
|
|
|
|
h.X = clamp(h.X, h.Radius, maxX)
|
|
h.Y = clamp(h.Y, h.Radius, maxY)
|
|
}
|
|
|
|
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(input Input, dt float64) {
|
|
isMoving := input.Left || input.Right || input.Up || input.Down
|
|
|
|
if isMoving {
|
|
animSpeed := normalAnimSpeed
|
|
if h.isSprinting {
|
|
animSpeed = sprintAnimSpeed
|
|
}
|
|
h.animTimer += dt
|
|
if h.animTimer >= animSpeed {
|
|
h.animTimer = 0
|
|
h.animFrame = 1 - h.animFrame
|
|
}
|
|
} else {
|
|
h.animFrame = 0
|
|
h.animTimer = 0
|
|
}
|
|
}
|
|
|
|
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 {
|
|
op := &ebiten.DrawImageOptions{}
|
|
bounds := sprite.Bounds()
|
|
w, height := float64(bounds.Dx()), float64(bounds.Dy())
|
|
op.GeoM.Translate(-w/2, -height/2)
|
|
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:
|
|
// No color change
|
|
case StateIdle:
|
|
// No color change
|
|
}
|
|
|
|
screen.DrawImage(sprite, op)
|
|
}
|
|
}
|
|
|
|
func (h *Hero) getCurrentSprite() *ebiten.Image {
|
|
var sprite *ebiten.Image
|
|
|
|
switch h.direction {
|
|
case DirUp:
|
|
if h.animFrame == 0 {
|
|
sprite = spriteBack1
|
|
} else {
|
|
sprite = spriteBack2
|
|
}
|
|
case DirDown:
|
|
if h.animFrame == 0 {
|
|
sprite = spriteFront1
|
|
} else {
|
|
sprite = spriteFront2
|
|
}
|
|
case DirLeft:
|
|
if h.animFrame == 0 {
|
|
sprite = spriteLeft1
|
|
} else {
|
|
sprite = spriteLeft2
|
|
}
|
|
case DirRight:
|
|
if h.animFrame == 0 {
|
|
sprite = spriteRight1
|
|
} else {
|
|
sprite = spriteRight2
|
|
}
|
|
}
|
|
|
|
return sprite
|
|
}
|
|
|
|
func clamp(value, min, max float64) float64 {
|
|
if value < min {
|
|
return min
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
return value
|
|
}
|