Name change
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user