diff --git a/assets/hero/avt1_bk1.gif b/assets/hero/avt1_bk1.gif new file mode 100644 index 0000000..76b75bc Binary files /dev/null and b/assets/hero/avt1_bk1.gif differ diff --git a/assets/hero/avt1_bk2.gif b/assets/hero/avt1_bk2.gif new file mode 100644 index 0000000..edb072b Binary files /dev/null and b/assets/hero/avt1_bk2.gif differ diff --git a/assets/hero/avt1_fr1.gif b/assets/hero/avt1_fr1.gif new file mode 100644 index 0000000..8d6cd21 Binary files /dev/null and b/assets/hero/avt1_fr1.gif differ diff --git a/assets/hero/avt1_fr2.gif b/assets/hero/avt1_fr2.gif new file mode 100644 index 0000000..5057551 Binary files /dev/null and b/assets/hero/avt1_fr2.gif differ diff --git a/assets/hero/avt1_lf1.gif b/assets/hero/avt1_lf1.gif new file mode 100644 index 0000000..159dc4d Binary files /dev/null and b/assets/hero/avt1_lf1.gif differ diff --git a/assets/hero/avt1_lf2.gif b/assets/hero/avt1_lf2.gif new file mode 100644 index 0000000..8f10b7a Binary files /dev/null and b/assets/hero/avt1_lf2.gif differ diff --git a/assets/hero/avt1_rt1.gif b/assets/hero/avt1_rt1.gif new file mode 100644 index 0000000..88ff789 Binary files /dev/null and b/assets/hero/avt1_rt1.gif differ diff --git a/assets/hero/avt1_rt2.gif b/assets/hero/avt1_rt2.gif new file mode 100644 index 0000000..3e63bf9 Binary files /dev/null and b/assets/hero/avt1_rt2.gif differ diff --git a/internal/game/game.go b/internal/game/game.go index c76baee..745ce56 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -13,18 +13,57 @@ import ( "github.com/atridad/BigFeelings/internal/ui/menu" ) +// ============================================================ +// CONFIGURATION +// Tweak these values to change game settings +// ============================================================ + const ( ScreenWidth = 960 ScreenHeight = 540 - - TargetTPS = 60 - WindowTitle = "Big Feelings" + TargetTPS = 60 + WindowTitle = "Big Feelings" ) var ( backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255} ) +// Hero configuration +const ( + heroStartX = ScreenWidth / 2 + heroStartY = ScreenHeight / 2 + heroRadius = 28.0 + heroSpeed = 180.0 + heroMaxStamina = 100.0 + heroStaminaDrain = 50.0 + heroStaminaRegen = 30.0 +) + +var ( + heroColor = color.NRGBA{R: 210, G: 220, B: 255, A: 255} +) + +// HUD configuration +const ( + hudX = ScreenWidth - 220 + hudY = 20 +) + +// Stamina bar colors +var ( + staminaNormalColor = color.NRGBA{R: 0, G: 255, B: 180, A: 255} + staminaLowColor = color.NRGBA{R: 255, G: 60, B: 60, A: 255} +) + +const ( + staminaLowThreshold = 0.2 +) + +// ============================================================ +// TYPES +// ============================================================ + type gameState int const ( @@ -40,63 +79,10 @@ type controls struct { Sprint bool } -func readControls() controls { - return controls{ - Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA), - Right: ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD), - Up: ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW), - Down: ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS), - Sprint: ebiten.IsKeyPressed(ebiten.KeyShift), - } -} - type Game struct { state *state } -func New() *Game { - return &Game{state: newState()} -} - -func (g *Game) Update() error { - // Handle escape key to toggle pause - if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { - if g.state.gameState == statePlaying { - g.state.gameState = statePaused - g.state.pauseMenu.Reset() - } else if g.state.gameState == statePaused { - g.state.gameState = statePlaying - // Reset lastTick to prevent delta time accumulation while paused - g.state.lastTick = time.Now() - } - } - - if g.state.gameState == statePlaying { - g.state.update(readControls()) - } else if g.state.gameState == statePaused { - if selectedOption := g.state.pauseMenu.Update(); selectedOption != nil { - switch *selectedOption { - case menu.OptionResume: - g.state.gameState = statePlaying - // Reset lastTick to prevent delta time accumulation while paused - g.state.lastTick = time.Now() - case menu.OptionQuit: - return ebiten.Termination - } - } - } - - return nil -} - -func (g *Game) Draw(screen *ebiten.Image) { - g.state.draw(screen) -} - -func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { - return ScreenWidth, ScreenHeight -} - type state struct { hero *hero.Hero hud hud.Overlay @@ -106,22 +92,30 @@ type state struct { gameState gameState } +// ============================================================ +// INITIALIZATION +// ============================================================ + +func New() *Game { + return &Game{state: newState()} +} + func newState() *state { now := time.Now() return &state{ hero: hero.New(hero.Config{ - StartX: ScreenWidth / 2, - StartY: ScreenHeight / 2, - Radius: 28, - Speed: 180, - Color: color.NRGBA{R: 210, G: 220, B: 255, A: 255}, - MaxStamina: 100, - StaminaDrain: 50, - StaminaRegen: 30, + StartX: heroStartX, + StartY: heroStartY, + Radius: heroRadius, + Speed: heroSpeed, + Color: heroColor, + MaxStamina: heroMaxStamina, + StaminaDrain: heroStaminaDrain, + StaminaRegen: heroStaminaRegen, }), hud: hud.Overlay{ - X: ScreenWidth - 220, - Y: 20, + X: hudX, + Y: hudY, Color: color.White, }, bounds: hero.Bounds{ @@ -134,6 +128,52 @@ func newState() *state { } } +// ============================================================ +// INPUT +// ============================================================ + +func readControls() controls { + return controls{ + Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA), + Right: ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD), + Up: ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW), + Down: ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS), + Sprint: ebiten.IsKeyPressed(ebiten.KeyShift), + } +} + +// ============================================================ +// UPDATE +// ============================================================ + +func (g *Game) Update() error { + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + if g.state.gameState == statePlaying { + g.state.gameState = statePaused + g.state.pauseMenu.Reset() + } else if g.state.gameState == statePaused { + g.state.gameState = statePlaying + g.state.lastTick = time.Now() + } + } + + if g.state.gameState == statePlaying { + g.state.update(readControls()) + } else if g.state.gameState == statePaused { + if selectedOption := g.state.pauseMenu.Update(); selectedOption != nil { + switch *selectedOption { + case menu.OptionResume: + g.state.gameState = statePlaying + g.state.lastTick = time.Now() + case menu.OptionQuit: + return ebiten.Termination + } + } + } + + return nil +} + func (s *state) update(input controls) { now := time.Now() dt := now.Sub(s.lastTick).Seconds() @@ -148,14 +188,21 @@ func (s *state) update(input controls) { }, dt, s.bounds) } +// ============================================================ +// RENDERING +// ============================================================ + +func (g *Game) Draw(screen *ebiten.Image) { + g.state.draw(screen) +} + func (s *state) draw(screen *ebiten.Image) { screen.Fill(backgroundColor) s.hero.Draw(screen) - // Create stamina meter from hero's stamina - staminaColor := color.NRGBA{R: 0, G: 255, B: 180, A: 255} - if s.hero.Stamina < s.hero.MaxStamina*0.2 { - staminaColor = color.NRGBA{R: 255, G: 60, B: 60, A: 255} + staminaColor := staminaNormalColor + if s.hero.Stamina < s.hero.MaxStamina*staminaLowThreshold { + staminaColor = staminaLowColor } staminaMeter := status.Meter{ @@ -166,8 +213,11 @@ func (s *state) draw(screen *ebiten.Image) { } s.hud.Draw(screen, []status.Meter{staminaMeter}) - // Draw pause menu if paused if s.gameState == statePaused { s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight) } } + +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { + return ScreenWidth, ScreenHeight +} diff --git a/internal/hero/hero.go b/internal/hero/hero.go index e82390b..dda8190 100644 --- a/internal/hero/hero.go +++ b/internal/hero/hero.go @@ -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 { diff --git a/internal/hero/sprites.go b/internal/hero/sprites.go new file mode 100644 index 0000000..d572bd6 --- /dev/null +++ b/internal/hero/sprites.go @@ -0,0 +1,65 @@ +package hero + +import ( + "image" + "image/color" + _ "image/gif" + "os" + + "github.com/hajimehoshi/ebiten/v2" +) + +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 +) + +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") +} + +func loadSprite(path string) *ebiten.Image { + file, err := os.Open(path) + if err != nil { + panic(err) + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + panic(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), + }) + } + } + } + + return ebiten.NewImageFromImage(rgba) +}