Added menu and sprinting

This commit is contained in:
2025-11-19 08:50:39 -07:00
parent bd33e7e123
commit c5a3bcb3f4
5 changed files with 494 additions and 59 deletions

View File

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

View File

@@ -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)
}
}

View File

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

View File

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

148
internal/ui/menu/menu.go Normal file
View File

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