From c5a3bcb3f409302dba275d181abf73ca5032af6f Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Wed, 19 Nov 2025 08:50:39 -0700 Subject: [PATCH] Added menu and sprinting --- Makefile | 2 +- internal/game/game.go | 119 ++++++++++++----- internal/hero/hero.go | 279 +++++++++++++++++++++++++++++++++++---- internal/ui/hud/hud.go | 5 +- internal/ui/menu/menu.go | 148 +++++++++++++++++++++ 5 files changed, 494 insertions(+), 59 deletions(-) create mode 100644 internal/ui/menu/menu.go diff --git a/Makefile b/Makefile index 9ed4f4e..4ef5f87 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,6 @@ help: @echo " build - Build binary to bin/" @echo " clean - Remove build artifacts" @echo " fmt - Format code" -\ @echo " deps - Download and tidy dependencies" + @echo " deps - Download and tidy dependencies" @echo " build-local - Clean build for current platform" @echo " build-all - Build for all platforms" diff --git a/internal/game/game.go b/internal/game/game.go index ddc361f..c76baee 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -5,10 +5,12 @@ import ( "time" "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/atridad/BigFeelings/internal/hero" "github.com/atridad/BigFeelings/internal/status" "github.com/atridad/BigFeelings/internal/ui/hud" + "github.com/atridad/BigFeelings/internal/ui/menu" ) const ( @@ -23,19 +25,28 @@ var ( backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255} ) +type gameState int + +const ( + statePlaying gameState = iota + statePaused +) + type controls struct { - Left bool - Right bool - Up bool - Down bool + Left bool + Right bool + Up bool + Down bool + 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), + 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), } } @@ -48,7 +59,33 @@ func New() *Game { } func (g *Game) Update() error { - g.state.update(readControls()) + // 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 } @@ -61,27 +98,26 @@ func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { } type state struct { - hero *hero.Hero - status *status.Manager - hud hud.Overlay - bounds hero.Bounds - lastTick time.Time + hero *hero.Hero + hud hud.Overlay + bounds hero.Bounds + lastTick time.Time + pauseMenu *menu.PauseMenu + gameState gameState } 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}, - }), - status: status.NewManager([]status.Config{ - {Label: "Core", Base: 60, Color: color.NRGBA{R: 255, G: 208, B: 0, A: 255}}, - {Label: "Drive", Base: 45, Color: color.NRGBA{R: 0, G: 190, B: 255, A: 255}}, - {Label: "Flux", Base: 30, Color: color.NRGBA{R: 255, G: 92, B: 120, A: 255}}, + 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, }), hud: hud.Overlay{ X: ScreenWidth - 220, @@ -92,7 +128,9 @@ func newState() *state { Width: ScreenWidth, Height: ScreenHeight, }, - lastTick: now, + lastTick: now, + pauseMenu: menu.NewPauseMenu(), + gameState: statePlaying, } } @@ -102,17 +140,34 @@ func (s *state) update(input controls) { s.lastTick = now s.hero.Update(hero.Input{ - Left: input.Left, - Right: input.Right, - Up: input.Up, - Down: input.Down, + Left: input.Left, + Right: input.Right, + Up: input.Up, + Down: input.Down, + Sprint: input.Sprint, }, dt, s.bounds) - - s.status.Update() } func (s *state) draw(screen *ebiten.Image) { screen.Fill(backgroundColor) s.hero.Draw(screen) - s.hud.Draw(screen, s.status.Meters()) + + // 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} + } + + staminaMeter := status.Meter{ + Label: "Stamina", + Base: s.hero.MaxStamina, + Level: s.hero.Stamina, + Color: staminaColor, + } + s.hud.Draw(screen, []status.Meter{staminaMeter}) + + // Draw pause menu if paused + if s.gameState == statePaused { + s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight) + } } diff --git a/internal/hero/hero.go b/internal/hero/hero.go index 82d6cfe..e82390b 100644 --- a/internal/hero/hero.go +++ b/internal/hero/hero.go @@ -10,10 +10,11 @@ import ( // Direction flags from the controls. type Input struct { - Left bool - Right bool - Up bool - Down bool + Left bool + Right bool + Up bool + Down bool + Sprint bool } // Playfield limits for movement. @@ -22,22 +23,41 @@ type Bounds struct { Height float64 } +// Visual states for the hero. +type VisualState int + +const ( + StateIdle VisualState = iota + StateSprinting + StateExhausted +) + // Player avatar data. type Hero struct { - X float64 - Y float64 - Radius float64 - Speed float64 - Color color.NRGBA + X float64 + Y float64 + Radius float64 + Speed float64 + Color color.NRGBA + Stamina float64 + MaxStamina float64 + StaminaDrain float64 + StaminaRegen float64 + canSprint bool + wasSprintHeld bool + isSprinting bool } // Spawn settings for the avatar. type Config struct { - StartX float64 - StartY float64 - Radius float64 - Speed float64 - Color color.NRGBA + StartX float64 + StartY float64 + Radius float64 + Speed float64 + Color color.NRGBA + MaxStamina float64 + StaminaDrain float64 + StaminaRegen float64 } // Builds an avatar from the config with fallbacks. @@ -51,13 +71,28 @@ func New(cfg Config) *Hero { if cfg.Color.A == 0 { cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255} } + if cfg.MaxStamina <= 0 { + cfg.MaxStamina = 100 + } + if cfg.StaminaDrain <= 0 { + cfg.StaminaDrain = 50 + } + if cfg.StaminaRegen <= 0 { + cfg.StaminaRegen = 30 + } return &Hero{ - X: cfg.StartX, - Y: cfg.StartY, - Radius: cfg.Radius, - Speed: cfg.Speed, - Color: cfg.Color, + X: cfg.StartX, + Y: cfg.StartY, + Radius: cfg.Radius, + Speed: cfg.Speed, + Color: cfg.Color, + Stamina: cfg.MaxStamina, + MaxStamina: cfg.MaxStamina, + StaminaDrain: cfg.StaminaDrain, + StaminaRegen: cfg.StaminaRegen, + canSprint: true, + wasSprintHeld: false, } } @@ -78,14 +113,41 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) { dy += 1 } - if dx != 0 || dy != 0 { + isMoving := dx != 0 || dy != 0 + + if isMoving { length := math.Hypot(dx, dy) dx /= length dy /= length } - h.X += dx * h.Speed * dt - h.Y += dy * h.Speed * dt + 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 + } + } + + h.X += dx * speed * dt + h.Y += dy * speed * dt maxX := math.Max(h.Radius, bounds.Width-h.Radius) maxY := math.Max(h.Radius, bounds.Height-h.Radius) @@ -94,8 +156,23 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) { h.Y = clamp(h.Y, h.Radius, maxY) } -// Renders the avatar as a filled circle. +// Returns the current visual state based on hero state. +func (h *Hero) getVisualState() VisualState { + if h.Stamina < h.MaxStamina*0.2 { + return StateExhausted + } + + if h.isSprinting { + return StateSprinting + } + + return StateIdle +} + +// Renders the avatar. func (h *Hero) Draw(screen *ebiten.Image) { + state := h.getVisualState() + vector.FillCircle( screen, float32(h.X), @@ -104,6 +181,162 @@ func (h *Hero) Draw(screen *ebiten.Image) { h.Color, false, ) + + eyeOffsetX := h.Radius * 0.3 + eyeOffsetY := h.Radius * 0.25 + + 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) + } +} + +func drawIdleFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) { + eyeRadius := radius * 0.15 + + 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, + ) + } + } + } + + 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, + ) + } +} + +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, + ) + } } func clamp(value, min, max float64) float64 { diff --git a/internal/ui/hud/hud.go b/internal/ui/hud/hud.go index e0e143f..b7628d0 100644 --- a/internal/ui/hud/hud.go +++ b/internal/ui/hud/hud.go @@ -24,10 +24,9 @@ func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) { // Instruction text instructions := Column{ Elements: []Element{ - Label{Text: "Systems Prototype", Color: o.Color}, + Label{Text: "Big Feelings", Color: o.Color}, Label{Text: "Move with Arrow Keys / WASD", Color: o.Color}, - Label{Text: "Track resource signals and plan ahead.", Color: o.Color}, - }, + Label{Text: "Hold Shift to Sprint", Color: o.Color}}, Spacing: 7, } instructions.Draw(screen, 16, 16) diff --git a/internal/ui/menu/menu.go b/internal/ui/menu/menu.go new file mode 100644 index 0000000..a20be42 --- /dev/null +++ b/internal/ui/menu/menu.go @@ -0,0 +1,148 @@ +package menu + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + "golang.org/x/image/font/basicfont" +) + +var ( + basicFace = text.NewGoXFace(basicfont.Face7x13) + basicFaceAscent = basicFace.Metrics().HAscent +) + +type MenuOption int + +const ( + OptionResume MenuOption = iota + OptionSettings + OptionQuit + optionCount +) + +type PauseMenu struct { + selectedIndex int + showWIP bool +} + +func NewPauseMenu() *PauseMenu { + return &PauseMenu{ + selectedIndex: 0, + showWIP: false, + } +} + +// Returns the selected option if one was chosen, nil otherwise +func (m *PauseMenu) Update() *MenuOption { + // Handle up/down navigation + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) { + m.selectedIndex-- + if m.selectedIndex < 0 { + m.selectedIndex = int(optionCount) - 1 + } + m.showWIP = false + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) { + m.selectedIndex++ + if m.selectedIndex >= int(optionCount) { + m.selectedIndex = 0 + } + m.showWIP = false + } + + // Handle selection + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) { + selected := MenuOption(m.selectedIndex) + if selected == OptionSettings { + m.showWIP = true + return nil + } + return &selected + } + + return nil +} + +func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) { + // Draw semi-transparent overlay + overlay := ebiten.NewImage(screenWidth, screenHeight) + overlay.Fill(color.RGBA{R: 0, G: 0, B: 0, A: 180}) + screen.DrawImage(overlay, nil) + + // Menu dimensions + menuWidth := 400 + menuHeight := 300 + menuX := (screenWidth - menuWidth) / 2 + menuY := (screenHeight - menuHeight) / 2 + + // Draw menu background + vector.DrawFilledRect(screen, + float32(menuX), float32(menuY), + float32(menuWidth), float32(menuHeight), + color.RGBA{R: 40, G: 40, B: 50, A: 255}, + false, + ) + + // Draw menu border + vector.StrokeRect(screen, + float32(menuX), float32(menuY), + float32(menuWidth), float32(menuHeight), + 2, + color.RGBA{R: 100, G: 100, B: 120, A: 255}, + false, + ) + + // Draw title + titleText := "PAUSED" + titleX := menuX + (menuWidth / 2) - (len(titleText) * 7 / 2) + titleY := menuY + 50 + m.drawText(screen, titleText, color.White, titleX, titleY) + + // Draw menu options + options := []string{"Resume", "Settings", "Quit"} + startY := menuY + 110 + + for i, option := range options { + optionY := startY + (i * 40) + optionX := menuX + (menuWidth / 2) - (len(option) * 7 / 2) + + // Draw selection indicator + if i == m.selectedIndex { + indicatorX := optionX - 20 + m.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, optionY) + m.drawText(screen, option, color.RGBA{R: 255, G: 255, B: 100, A: 255}, optionX, optionY) + } else { + m.drawText(screen, option, color.RGBA{R: 180, G: 180, B: 180, A: 255}, optionX, optionY) + } + } + + // Draw WIP message if settings was selected + if m.showWIP { + wipY := startY + 140 + wipText := "Work In Progress" + wipX := menuX + (menuWidth / 2) - (len(wipText) * 7 / 2) + m.drawText(screen, wipText, color.RGBA{R: 255, G: 150, B: 0, A: 255}, wipX, wipY) + } + + // Draw controls hint at bottom + hintText := "Use Arrow Keys/WASD to navigate, Enter/Space to select" + hintX := menuX + (menuWidth / 2) - (len(hintText) * 7 / 2) + hintY := menuY + menuHeight - 30 + m.drawText(screen, hintText, color.RGBA{R: 150, G: 150, B: 150, A: 255}, hintX, hintY) +} + +func (m *PauseMenu) drawText(screen *ebiten.Image, txt string, clr color.Color, x, y int) { + op := &text.DrawOptions{} + op.GeoM.Translate(float64(x), float64(y)-basicFaceAscent) + op.ColorScale.ScaleWithColor(clr) + text.Draw(screen, txt, basicFace, op) +} + +func (m *PauseMenu) Reset() { + m.selectedIndex = 0 + m.showWIP = false +}