Using a temp free online sprite for now to test sprites
This commit is contained in:
@@ -5,10 +5,37 @@ import (
|
||||
"math"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// Direction flags from the controls.
|
||||
// ============================================================
|
||||
// CONFIGURATION
|
||||
// Tweak these values to change gameplay behavior
|
||||
// ============================================================
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// TYPES
|
||||
// ============================================================
|
||||
|
||||
type Input struct {
|
||||
Left bool
|
||||
Right bool
|
||||
@@ -17,13 +44,11 @@ type Input struct {
|
||||
Sprint bool
|
||||
}
|
||||
|
||||
// Playfield limits for movement.
|
||||
type Bounds struct {
|
||||
Width float64
|
||||
Height float64
|
||||
}
|
||||
|
||||
// Visual states for the hero.
|
||||
type VisualState int
|
||||
|
||||
const (
|
||||
@@ -32,23 +57,42 @@ const (
|
||||
StateExhausted
|
||||
)
|
||||
|
||||
// Player avatar data.
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
DirDown Direction = iota
|
||||
DirUp
|
||||
DirLeft
|
||||
DirRight
|
||||
)
|
||||
|
||||
type Hero struct {
|
||||
X float64
|
||||
Y float64
|
||||
Radius float64
|
||||
Speed float64
|
||||
Color color.NRGBA
|
||||
Stamina float64
|
||||
MaxStamina float64
|
||||
StaminaDrain float64
|
||||
StaminaRegen float64
|
||||
// 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
|
||||
}
|
||||
|
||||
// Spawn settings for the avatar.
|
||||
type Config struct {
|
||||
StartX float64
|
||||
StartY float64
|
||||
@@ -60,25 +104,28 @@ type Config struct {
|
||||
StaminaRegen float64
|
||||
}
|
||||
|
||||
// Builds an avatar from the config with fallbacks.
|
||||
// ============================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================
|
||||
|
||||
func New(cfg Config) *Hero {
|
||||
if cfg.Radius <= 0 {
|
||||
cfg.Radius = 24
|
||||
cfg.Radius = defaultRadius
|
||||
}
|
||||
if cfg.Speed <= 0 {
|
||||
cfg.Speed = 180
|
||||
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 = 100
|
||||
cfg.MaxStamina = defaultMaxStamina
|
||||
}
|
||||
if cfg.StaminaDrain <= 0 {
|
||||
cfg.StaminaDrain = 50
|
||||
cfg.StaminaDrain = defaultStaminaDrain
|
||||
}
|
||||
if cfg.StaminaRegen <= 0 {
|
||||
cfg.StaminaRegen = 30
|
||||
cfg.StaminaRegen = defaultStaminaRegen
|
||||
}
|
||||
|
||||
return &Hero{
|
||||
@@ -93,24 +140,40 @@ func New(cfg Config) *Hero {
|
||||
StaminaRegen: cfg.StaminaRegen,
|
||||
canSprint: true,
|
||||
wasSprintHeld: false,
|
||||
direction: DirDown,
|
||||
animFrame: 0,
|
||||
animTimer: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Applies movement input and clamps to the playfield.
|
||||
// ============================================================
|
||||
// UPDATE
|
||||
// ============================================================
|
||||
|
||||
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
|
||||
@@ -122,28 +185,9 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
speed *= sprintSpeedMultiplier
|
||||
}
|
||||
|
||||
h.X += dx * speed * dt
|
||||
@@ -156,9 +200,54 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
|
||||
h.Y = clamp(h.Y, h.Radius, maxY)
|
||||
}
|
||||
|
||||
// Returns the current visual state based on hero state.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STATE
|
||||
// ============================================================
|
||||
|
||||
func (h *Hero) getVisualState() VisualState {
|
||||
if h.Stamina < h.MaxStamina*0.2 {
|
||||
if h.Stamina < h.MaxStamina*exhaustedThreshold {
|
||||
return StateExhausted
|
||||
}
|
||||
|
||||
@@ -169,175 +258,70 @@ func (h *Hero) getVisualState() VisualState {
|
||||
return StateIdle
|
||||
}
|
||||
|
||||
// Renders the avatar.
|
||||
// ============================================================
|
||||
// RENDERING
|
||||
// ============================================================
|
||||
|
||||
func (h *Hero) Draw(screen *ebiten.Image) {
|
||||
state := h.getVisualState()
|
||||
sprite := h.getCurrentSprite()
|
||||
|
||||
vector.FillCircle(
|
||||
screen,
|
||||
float32(h.X),
|
||||
float32(h.Y),
|
||||
float32(h.Radius),
|
||||
h.Color,
|
||||
false,
|
||||
)
|
||||
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)
|
||||
|
||||
eyeOffsetX := h.Radius * 0.3
|
||||
eyeOffsetY := h.Radius * 0.25
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
screen.DrawImage(sprite, op)
|
||||
}
|
||||
}
|
||||
|
||||
func drawIdleFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
||||
eyeRadius := radius * 0.15
|
||||
func (h *Hero) getCurrentSprite() *ebiten.Image {
|
||||
var sprite *ebiten.Image
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
return sprite
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// UTILITIES
|
||||
// ============================================================
|
||||
|
||||
func clamp(value, min, max float64) float64 {
|
||||
if value < min {
|
||||
|
||||
Reference in New Issue
Block a user