Using a temp free online sprite for now to test sprites

This commit is contained in:
2025-11-19 09:08:25 -07:00
parent c5a3bcb3f4
commit 8eb5909919
11 changed files with 370 additions and 271 deletions

View File

@@ -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 {