351 lines
6.9 KiB
Go
351 lines
6.9 KiB
Go
package hero
|
|
|
|
import (
|
|
"image/color"
|
|
"math"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
|
)
|
|
|
|
// Direction flags from the controls.
|
|
type Input struct {
|
|
Left bool
|
|
Right bool
|
|
Up bool
|
|
Down bool
|
|
Sprint bool
|
|
}
|
|
|
|
// Playfield limits for movement.
|
|
type Bounds struct {
|
|
Width float64
|
|
Height float64
|
|
}
|
|
|
|
// Visual states for the hero.
|
|
type VisualState int
|
|
|
|
const (
|
|
StateIdle VisualState = iota
|
|
StateSprinting
|
|
StateExhausted
|
|
)
|
|
|
|
// Player avatar data.
|
|
type Hero struct {
|
|
X float64
|
|
Y float64
|
|
Radius float64
|
|
Speed float64
|
|
Color color.NRGBA
|
|
Stamina float64
|
|
MaxStamina float64
|
|
StaminaDrain float64
|
|
StaminaRegen float64
|
|
canSprint bool
|
|
wasSprintHeld bool
|
|
isSprinting bool
|
|
}
|
|
|
|
// Spawn settings for the avatar.
|
|
type Config struct {
|
|
StartX float64
|
|
StartY float64
|
|
Radius float64
|
|
Speed float64
|
|
Color color.NRGBA
|
|
MaxStamina float64
|
|
StaminaDrain float64
|
|
StaminaRegen float64
|
|
}
|
|
|
|
// Builds an avatar from the config with fallbacks.
|
|
func New(cfg Config) *Hero {
|
|
if cfg.Radius <= 0 {
|
|
cfg.Radius = 24
|
|
}
|
|
if cfg.Speed <= 0 {
|
|
cfg.Speed = 180
|
|
}
|
|
if cfg.Color.A == 0 {
|
|
cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255}
|
|
}
|
|
if cfg.MaxStamina <= 0 {
|
|
cfg.MaxStamina = 100
|
|
}
|
|
if cfg.StaminaDrain <= 0 {
|
|
cfg.StaminaDrain = 50
|
|
}
|
|
if cfg.StaminaRegen <= 0 {
|
|
cfg.StaminaRegen = 30
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// Applies movement input and clamps to the playfield.
|
|
func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
|
|
dx, dy := 0.0, 0.0
|
|
|
|
if input.Left {
|
|
dx -= 1
|
|
}
|
|
if input.Right {
|
|
dx += 1
|
|
}
|
|
if input.Up {
|
|
dy -= 1
|
|
}
|
|
if input.Down {
|
|
dy += 1
|
|
}
|
|
|
|
isMoving := dx != 0 || dy != 0
|
|
|
|
if isMoving {
|
|
length := math.Hypot(dx, dy)
|
|
dx /= length
|
|
dy /= length
|
|
}
|
|
|
|
speed := h.Speed
|
|
|
|
if !input.Sprint {
|
|
h.wasSprintHeld = false
|
|
if h.Stamina >= h.MaxStamina*0.2 {
|
|
h.canSprint = true
|
|
}
|
|
}
|
|
|
|
h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && isMoving
|
|
if h.isSprinting {
|
|
speed *= 2.0
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Returns the current visual state based on hero state.
|
|
func (h *Hero) getVisualState() VisualState {
|
|
if h.Stamina < h.MaxStamina*0.2 {
|
|
return StateExhausted
|
|
}
|
|
|
|
if h.isSprinting {
|
|
return StateSprinting
|
|
}
|
|
|
|
return StateIdle
|
|
}
|
|
|
|
// Renders the avatar.
|
|
func (h *Hero) Draw(screen *ebiten.Image) {
|
|
state := h.getVisualState()
|
|
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(h.X),
|
|
float32(h.Y),
|
|
float32(h.Radius),
|
|
h.Color,
|
|
false,
|
|
)
|
|
|
|
eyeOffsetX := h.Radius * 0.3
|
|
eyeOffsetY := h.Radius * 0.25
|
|
|
|
switch state {
|
|
case StateExhausted:
|
|
drawExhaustedFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY)
|
|
case StateSprinting:
|
|
drawSprintingFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY)
|
|
case StateIdle:
|
|
drawIdleFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY)
|
|
}
|
|
}
|
|
|
|
func drawIdleFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
|
eyeRadius := radius * 0.15
|
|
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x-eyeOffsetX),
|
|
float32(y-eyeOffsetY),
|
|
float32(eyeRadius),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x+eyeOffsetX),
|
|
float32(y-eyeOffsetY),
|
|
float32(eyeRadius),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
|
|
smileRadius := radius * 0.5
|
|
smileY := y + radius*0.15
|
|
for angle := 0.3; angle <= 2.84; angle += 0.15 {
|
|
smileX := x + smileRadius*math.Cos(angle)
|
|
smileYPos := smileY + smileRadius*0.3*math.Sin(angle)
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(smileX),
|
|
float32(smileYPos),
|
|
float32(radius*0.08),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
|
|
func drawSprintingFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
|
eyeWidth := radius * 0.2
|
|
eyeHeight := radius * 0.12
|
|
|
|
for ex := -eyeWidth; ex <= eyeWidth; ex += radius * 0.08 {
|
|
for ey := -eyeHeight; ey <= eyeHeight; ey += radius * 0.08 {
|
|
if ex*ex/(eyeWidth*eyeWidth)+ey*ey/(eyeHeight*eyeHeight) <= 1 {
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x-eyeOffsetX+ex),
|
|
float32(y-eyeOffsetY+ey),
|
|
float32(radius*0.05),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for ex := -eyeWidth; ex <= eyeWidth; ex += radius * 0.08 {
|
|
for ey := -eyeHeight; ey <= eyeHeight; ey += radius * 0.08 {
|
|
if ex*ex/(eyeWidth*eyeWidth)+ey*ey/(eyeHeight*eyeHeight) <= 1 {
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x+eyeOffsetX+ex),
|
|
float32(y-eyeOffsetY+ey),
|
|
float32(radius*0.05),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
mouthY := y + radius*0.3
|
|
mouthWidth := radius * 0.5
|
|
for mx := -mouthWidth; mx <= mouthWidth; mx += radius * 0.08 {
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x+mx),
|
|
float32(mouthY),
|
|
float32(radius*0.06),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
|
|
func drawExhaustedFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
|
eyeSize := radius * 0.15
|
|
|
|
for i := -eyeSize; i <= eyeSize; i += radius * 0.08 {
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x-eyeOffsetX+i),
|
|
float32(y-eyeOffsetY+i),
|
|
float32(radius*0.05),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x-eyeOffsetX+i),
|
|
float32(y-eyeOffsetY-i),
|
|
float32(radius*0.05),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
}
|
|
|
|
for i := -eyeSize; i <= eyeSize; i += radius * 0.08 {
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x+eyeOffsetX+i),
|
|
float32(y-eyeOffsetY+i),
|
|
float32(radius*0.05),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(x+eyeOffsetX+i),
|
|
float32(y-eyeOffsetY-i),
|
|
float32(radius*0.05),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
}
|
|
|
|
mouthY := y + radius*0.35
|
|
mouthWidth := radius * 0.2
|
|
mouthHeight := radius * 0.25
|
|
|
|
for angle := 0.0; angle < 2*math.Pi; angle += 0.3 {
|
|
mx := x + mouthWidth*math.Cos(angle)
|
|
my := mouthY + mouthHeight*math.Sin(angle)
|
|
vector.FillCircle(
|
|
screen,
|
|
float32(mx),
|
|
float32(my),
|
|
float32(radius*0.06),
|
|
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
|
|
func clamp(value, min, max float64) float64 {
|
|
if value < min {
|
|
return min
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
return value
|
|
}
|