Name change

This commit is contained in:
2025-11-24 12:18:32 -07:00
parent cd6c1a78b0
commit 175479da69
65 changed files with 624 additions and 191 deletions

View File

@@ -2,44 +2,51 @@ package hero
import (
"image/color"
"math"
"github.com/hajimehoshi/ebiten/v2"
"github.com/atridad/LilGuy/internal/projectile"
)
// Default values and gameplay constants.
const (
// Default values if not specified in config
defaultRadius = 24.0
defaultSpeed = 180.0
defaultSpeed = 200.0
defaultMaxStamina = 100.0
defaultStaminaDrain = 50.0 // Per second when sprinting
defaultStaminaRegen = 30.0 // Per second when not sprinting
defaultStaminaDrain = 50.0
defaultStaminaRegen = 30.0
// Sprint mechanics
sprintSpeedMultiplier = 2.0
sprintRecoveryThreshold = 0.2 // 20% stamina needed to sprint again
sprintSpeedMultiplier = 1.8
sprintRecoveryThreshold = 0.2
// Animation
normalAnimSpeed = 0.15 // Seconds per frame when walking
sprintAnimSpeed = 0.08 // Seconds per frame when sprinting
normalAnimSpeed = 0.15
idleAnimSpeed = 0.3
sprintAnimSpeed = 0.08
// Visual state thresholds
exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina
exhaustedThreshold = 0.2
animFrameWrap = 4096
heroSpriteScale = 0.175
fixedSpriteHeight = 329.0
fixedSpriteWidth = 315.0
gravity = 1200.0
jumpStrength = -450.0
maxFallSpeed = 800.0
groundFriction = 0.85
airFriction = 0.95
)
type Input struct {
Left bool
Right bool
Up bool
Down bool
Jump bool
Sprint bool
}
type Bounds struct {
Width float64
Height float64
Ground float64
}
type VisualState int
@@ -53,37 +60,38 @@ const (
type Direction int
const (
DirDown Direction = iota
DirUp
DirLeft
DirLeft Direction = iota
DirRight
)
type Hero struct {
// Position and size
X float64
Y float64
Radius float64
// Movement
VelocityX float64
VelocityY float64
Speed float64
// Appearance
Color color.NRGBA
// Stamina system
Stamina float64
MaxStamina float64
StaminaDrain float64
StaminaRegen float64
// Internal state
canSprint bool
wasSprintHeld bool
isSprinting bool
isMoving bool
isGrounded bool
direction Direction
animFrame int
animTimer float64
lastAnimKey animationKey
ProjectileConfig projectile.ProjectileConfig
}
type Config struct {
@@ -129,60 +137,80 @@ func New(cfg Config) *Hero {
StaminaRegen: cfg.StaminaRegen,
canSprint: true,
wasSprintHeld: false,
direction: DirDown,
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) {
h.updateMovement(input, dt, bounds)
h.updateStamina(input, dt)
h.updateAnimation(input, dt)
h.updateAnimation(dt)
}
func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) {
dx, dy := 0.0, 0.0
h.VelocityY += gravity * dt
if h.VelocityY > maxFallSpeed {
h.VelocityY = maxFallSpeed
}
h.Y += h.VelocityY * dt
footPosition := h.Y
if footPosition >= bounds.Ground {
h.Y = bounds.Ground
h.VelocityY = 0
h.isGrounded = true
} else {
h.isGrounded = false
}
if input.Jump && h.isGrounded {
h.VelocityY = jumpStrength
h.isGrounded = false
}
targetVelocityX := 0.0
if input.Left {
dx -= 1
targetVelocityX -= h.Speed
h.direction = DirLeft
}
if input.Right {
dx += 1
targetVelocityX += h.Speed
h.direction = DirRight
}
if input.Up {
dy -= 1
h.direction = DirUp
}
if input.Down {
dy += 1
h.direction = DirDown
}
isMoving := dx != 0 || dy != 0
h.isMoving = targetVelocityX != 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
h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && h.isMoving
if h.isSprinting {
speed *= sprintSpeedMultiplier
targetVelocityX *= sprintSpeedMultiplier
}
h.X += dx * speed * dt
h.Y += dy * speed * dt
friction := groundFriction
if !h.isGrounded {
friction = airFriction
}
maxX := math.Max(h.Radius, bounds.Width-h.Radius)
maxY := math.Max(h.Radius, bounds.Height-h.Radius)
h.VelocityX = h.VelocityX*friction + targetVelocityX*(1-friction)
h.X = clamp(h.X, h.Radius, maxX)
h.Y = clamp(h.Y, h.Radius, maxY)
h.X += h.VelocityX * dt
if h.X < h.Radius {
h.X = h.Radius
h.VelocityX = 0
}
if h.X > bounds.Width-h.Radius {
h.X = bounds.Width - h.Radius
h.VelocityX = 0
}
}
func (h *Hero) updateStamina(input Input, dt float64) {
@@ -208,22 +236,30 @@ func (h *Hero) updateStamina(input Input, dt float64) {
}
}
func (h *Hero) updateAnimation(input Input, dt float64) {
isMoving := input.Left || input.Right || input.Up || input.Down
func (h *Hero) updateAnimation(dt float64) {
isMoving := h.isMoving
key := animationKey{direction: h.direction, state: animIdle}
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 {
key.state = animMove
}
if key != h.lastAnimKey {
h.animFrame = 0
h.animTimer = 0
h.lastAnimKey = key
}
if isMoving {
animSpeed := normalAnimSpeed * 0.5
if h.isSprinting {
animSpeed = sprintAnimSpeed * 0.5
}
h.animTimer += dt
frameAdvance := int(h.animTimer / animSpeed)
if frameAdvance > 0 {
h.animTimer -= animSpeed * float64(frameAdvance)
h.animFrame = (h.animFrame + frameAdvance) % animFrameWrap
}
}
}
@@ -243,10 +279,13 @@ 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)
actualHeight := float64(bounds.Dy())
actualWidth := float64(bounds.Dx())
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(-actualWidth/2, -actualHeight)
op.GeoM.Scale(heroSpriteScale, heroSpriteScale)
op.GeoM.Translate(h.X, h.Y)
state := h.getVisualState()
@@ -254,9 +293,7 @@ func (h *Hero) Draw(screen *ebiten.Image) {
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)
@@ -264,44 +301,9 @@ func (h *Hero) Draw(screen *ebiten.Image) {
}
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
return getKnightSprite(h.direction, h.isMoving, h.animFrame)
}
func clamp(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
func (h *Hero) GetDirection() Direction {
return h.direction
}

View File

@@ -1,65 +1,151 @@
package hero
import (
"fmt"
"image"
"image/color"
_ "image/gif"
_ "image/png"
"os"
"path/filepath"
"sort"
"github.com/hajimehoshi/ebiten/v2"
)
const (
heroDir = "assets/hero"
)
type animState int
const (
animIdle animState = iota
animMove
)
type animationKey struct {
direction Direction
state animState
}
var (
spriteBack1 *ebiten.Image
spriteBack2 *ebiten.Image
spriteFront1 *ebiten.Image
spriteFront2 *ebiten.Image
spriteLeft1 *ebiten.Image
spriteLeft2 *ebiten.Image
spriteRight1 *ebiten.Image
spriteRight2 *ebiten.Image
knightAnimations map[animationKey][]*ebiten.Image
)
func init() {
spriteBack1 = loadSprite("assets/hero/avt1_bk1.gif")
spriteBack2 = loadSprite("assets/hero/avt1_bk2.gif")
spriteFront1 = loadSprite("assets/hero/avt1_fr1.gif")
spriteFront2 = loadSprite("assets/hero/avt1_fr2.gif")
spriteLeft1 = loadSprite("assets/hero/avt1_lf1.gif")
spriteLeft2 = loadSprite("assets/hero/avt1_lf2.gif")
spriteRight1 = loadSprite("assets/hero/avt1_rt1.gif")
spriteRight2 = loadSprite("assets/hero/avt1_rt2.gif")
knightAnimations = make(map[animationKey][]*ebiten.Image)
if err := loadKnightAnimations(); err != nil {
panic(err)
}
}
func loadSprite(path string) *ebiten.Image {
file, err := os.Open(path)
if err != nil {
panic(err)
func getKnightSprite(direction Direction, moving bool, frameIndex int) *ebiten.Image {
state := animIdle
if moving {
state = animMove
}
defer file.Close()
return frameFromSet(direction, state, frameIndex)
}
img, _, err := image.Decode(file)
func loadKnightAnimations() error {
frames, err := loadAnimationFrames(heroDir)
if err != nil {
panic(err)
return err
}
bounds := img.Bounds()
rgba := image.NewRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, a := img.At(x, y).RGBA()
if r > 0xf000 && g > 0xf000 && b > 0xf000 {
rgba.Set(x, y, color.RGBA{0, 0, 0, 0})
} else {
rgba.Set(x, y, color.RGBA{
R: uint8(r >> 8),
G: uint8(g >> 8),
B: uint8(b >> 8),
A: uint8(a >> 8),
})
}
knightAnimations[animationKey{DirLeft, animMove}] = frames
knightAnimations[animationKey{DirLeft, animIdle}] = frames
flippedFrames := make([]*ebiten.Image, len(frames))
for i, frame := range frames {
flippedFrames[i] = flipImageHorizontally(frame)
}
knightAnimations[animationKey{DirRight, animMove}] = flippedFrames
knightAnimations[animationKey{DirRight, animIdle}] = flippedFrames
return nil
}
func loadAnimationFrames(dir string) ([]*ebiten.Image, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var files []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if filepath.Ext(name) == ".png" {
files = append(files, filepath.Join(dir, name))
}
}
return ebiten.NewImageFromImage(rgba)
sort.Strings(files)
var frames []*ebiten.Image
for _, file := range files {
img, err := loadImage(file)
if err != nil {
return nil, fmt.Errorf("load frame %s: %w", file, err)
}
frames = append(frames, img)
}
return frames, nil
}
func loadImage(path string) (*ebiten.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, err
}
return ebiten.NewImageFromImage(img), nil
}
func flipImageHorizontally(img *ebiten.Image) *ebiten.Image {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
flipped := ebiten.NewImage(w, h)
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(-1, 1)
op.GeoM.Translate(float64(w), 0)
flipped.DrawImage(img, op)
return flipped
}
func frameFromSet(direction Direction, state animState, frameIndex int) *ebiten.Image {
if frameIndex < 0 {
frameIndex = 0
}
if frames := knightAnimations[animationKey{direction, state}]; len(frames) > 0 {
return frames[frameIndex%len(frames)]
}
if state == animMove {
if frames := knightAnimations[animationKey{direction, animIdle}]; len(frames) > 0 {
return frames[frameIndex%len(frames)]
}
} else {
if frames := knightAnimations[animationKey{direction, animMove}]; len(frames) > 0 {
return frames[frameIndex%len(frames)]
}
}
return nil
}