From bd33e7e12343d957d3c28c2e4e0ab776b8f4db18 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Wed, 19 Nov 2025 01:19:03 -0700 Subject: [PATCH] First run at basic structure --- .gitignore | 5 + Makefile | 50 +++++++++ cmd/bigfeelings/main.go | 19 ++++ go.mod | 22 ++++ go.sum | 28 +++++ internal/game/game.go | 118 ++++++++++++++++++++ internal/hero/hero.go | 117 ++++++++++++++++++++ internal/status/status.go | 79 ++++++++++++++ internal/ui/hud/elements.go | 209 ++++++++++++++++++++++++++++++++++++ internal/ui/hud/hud.go | 52 +++++++++ 10 files changed, 699 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/bigfeelings/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/game/game.go create mode 100644 internal/hero/hero.go create mode 100644 internal/status/status.go create mode 100644 internal/ui/hud/elements.go create mode 100644 internal/ui/hud/hud.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84bde79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Binaries +bin/ + +# macOS +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9ed4f4e --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +.PHONY: run build clean fmt + +export CGO_CFLAGS=-Wno-deprecated-declarations + +run: + @go run ./cmd/bigfeelings + +build: + @go build -o bin/bigfeelings ./cmd/bigfeelings + +clean: + @rm -rf bin/ + @go clean + +fmt: + @go fmt ./... + +# Install dependencies +deps: + @go mod download + @go mod tidy + +# Run with verbose output +run-verbose: + @go run -v ./cmd/bigfeelings + +# Build for current platform +build-local: clean + @mkdir -p bin + @go build -o bin/bigfeelings ./cmd/bigfeelings + @echo "Built: bin/bigfeelings" + +# Build for multiple platforms +build-all: clean + @mkdir -p bin + GOOS=darwin GOARCH=arm64 go build -o bin/bigfeelings-darwin-arm64 ./cmd/bigfeelings + GOOS=darwin GOARCH=amd64 go build -o bin/bigfeelings-darwin-amd64 ./cmd/bigfeelings + GOOS=linux GOARCH=amd64 go build -o bin/bigfeelings-linux-amd64 ./cmd/bigfeelings + GOOS=windows GOARCH=amd64 go build -o bin/bigfeelings-windows-amd64.exe ./cmd/bigfeelings + @echo "Built all platforms" + +help: + @echo "Available targets:" + @echo " run - Run the game" + @echo " build - Build binary to bin/" + @echo " clean - Remove build artifacts" + @echo " fmt - Format code" +\ @echo " deps - Download and tidy dependencies" + @echo " build-local - Clean build for current platform" + @echo " build-all - Build for all platforms" diff --git a/cmd/bigfeelings/main.go b/cmd/bigfeelings/main.go new file mode 100644 index 0000000..0bb38e9 --- /dev/null +++ b/cmd/bigfeelings/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "github.com/hajimehoshi/ebiten/v2" + + game "github.com/atridad/BigFeelings/internal/game" +) + +func main() { + ebiten.SetWindowSize(game.ScreenWidth, game.ScreenHeight) + ebiten.SetWindowTitle(game.WindowTitle) + ebiten.SetTPS(game.TargetTPS) + + if err := ebiten.RunGame(game.New()); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7da6200 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/atridad/BigFeelings + +go 1.24.0 + +toolchain go1.24.3 + +require ( + github.com/hajimehoshi/ebiten/v2 v2.9.4 + golang.org/x/image v0.31.0 +) + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/go-text/typesetting v0.3.0 // indirect + github.com/jezek/xgb v1.1.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cd12ee3 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= +github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/hajimehoshi/bitmapfont/v4 v4.1.0 h1:eE3qa5Do4qhowZVIHjsrX5pYyyPN6sAFWMsO7QREm3U= +github.com/hajimehoshi/bitmapfont/v4 v4.1.0/go.mod h1:/PD+aLjAJ0F2UoQx6hkOfXqWN7BkroDUMr5W+IT1dpE= +github.com/hajimehoshi/ebiten/v2 v2.9.4 h1:IlPJpwtksylmmvNhQjv4W2bmCFWXtjY7Z10Esise1bk= +github.com/hajimehoshi/ebiten/v2 v2.9.4/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..ddc361f --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,118 @@ +package game + +import ( + "image/color" + "time" + + "github.com/hajimehoshi/ebiten/v2" + + "github.com/atridad/BigFeelings/internal/hero" + "github.com/atridad/BigFeelings/internal/status" + "github.com/atridad/BigFeelings/internal/ui/hud" +) + +const ( + ScreenWidth = 960 + ScreenHeight = 540 + + TargetTPS = 60 + WindowTitle = "Big Feelings" +) + +var ( + backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255} +) + +type controls struct { + Left bool + Right bool + Up bool + Down 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), + } +} + +type Game struct { + state *state +} + +func New() *Game { + return &Game{state: newState()} +} + +func (g *Game) Update() error { + g.state.update(readControls()) + 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 + status *status.Manager + hud hud.Overlay + bounds hero.Bounds + lastTick time.Time +} + +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}}, + }), + hud: hud.Overlay{ + X: ScreenWidth - 220, + Y: 20, + Color: color.White, + }, + bounds: hero.Bounds{ + Width: ScreenWidth, + Height: ScreenHeight, + }, + lastTick: now, + } +} + +func (s *state) update(input controls) { + now := time.Now() + dt := now.Sub(s.lastTick).Seconds() + s.lastTick = now + + s.hero.Update(hero.Input{ + Left: input.Left, + Right: input.Right, + Up: input.Up, + Down: input.Down, + }, 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()) +} diff --git a/internal/hero/hero.go b/internal/hero/hero.go new file mode 100644 index 0000000..82d6cfe --- /dev/null +++ b/internal/hero/hero.go @@ -0,0 +1,117 @@ +package hero + +import ( + "image/color" + "math" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// Direction flags from the controls. +type Input struct { + Left bool + Right bool + Up bool + Down bool +} + +// Playfield limits for movement. +type Bounds struct { + Width float64 + Height float64 +} + +// Player avatar data. +type Hero struct { + X float64 + Y float64 + Radius float64 + Speed float64 + Color color.NRGBA +} + +// Spawn settings for the avatar. +type Config struct { + StartX float64 + StartY float64 + Radius float64 + Speed float64 + Color color.NRGBA +} + +// Builds an avatar from the config with fallbacks. +func New(cfg Config) *Hero { + if cfg.Radius <= 0 { + cfg.Radius = 24 + } + if cfg.Speed <= 0 { + cfg.Speed = 180 + } + if cfg.Color.A == 0 { + cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255} + } + + return &Hero{ + X: cfg.StartX, + Y: cfg.StartY, + Radius: cfg.Radius, + Speed: cfg.Speed, + Color: cfg.Color, + } +} + +// Applies movement input and clamps to the playfield. +func (h *Hero) Update(input Input, dt float64, bounds Bounds) { + dx, dy := 0.0, 0.0 + + if input.Left { + dx -= 1 + } + if input.Right { + dx += 1 + } + if input.Up { + dy -= 1 + } + if input.Down { + dy += 1 + } + + if dx != 0 || dy != 0 { + length := math.Hypot(dx, dy) + dx /= length + dy /= length + } + + h.X += dx * h.Speed * dt + h.Y += dy * h.Speed * dt + + maxX := math.Max(h.Radius, bounds.Width-h.Radius) + maxY := math.Max(h.Radius, bounds.Height-h.Radius) + + h.X = clamp(h.X, h.Radius, maxX) + h.Y = clamp(h.Y, h.Radius, maxY) +} + +// Renders the avatar as a filled circle. +func (h *Hero) Draw(screen *ebiten.Image) { + vector.FillCircle( + screen, + float32(h.X), + float32(h.Y), + float32(h.Radius), + h.Color, + false, + ) +} + +func clamp(value, min, max float64) float64 { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..f16a49f --- /dev/null +++ b/internal/status/status.go @@ -0,0 +1,79 @@ +package status + +import ( + "fmt" + "image/color" +) + +// HUD resource entry. +type Meter struct { + Label string + Base float64 + Level float64 + Color color.NRGBA +} + +// Meter template values. +type Config struct { + Label string + Base float64 + Color color.NRGBA +} + +// Collection of meters. +type Manager struct { + meters []Meter +} + +// Builds meters from configs. +func NewManager(cfgs []Config) *Manager { + meters := make([]Meter, len(cfgs)) + for i, cfg := range cfgs { + meters[i] = Meter{ + Label: safeLabel(cfg.Label, i), + Base: clamp(cfg.Base, 0, 100), + Level: clamp(cfg.Base, 0, 100), + Color: nonTransparent(cfg.Color), + } + } + + return &Manager{meters: meters} +} + +// Resets levels to base. +func (m *Manager) Update() { + for i := range m.meters { + m.meters[i].Level = m.meters[i].Base + } +} + +// Meters exposes a copy of the internal slice to prevent mutation. +func (m *Manager) Meters() []Meter { + out := make([]Meter, len(m.meters)) + copy(out, m.meters) + return out +} + +func clamp(value, min, max float64) float64 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func safeLabel(label string, fallback int) string { + if label == "" { + return fmt.Sprintf("Meter %d", fallback+1) + } + return label +} + +func nonTransparent(c color.NRGBA) color.NRGBA { + if c.A == 0 { + c.A = 255 + } + return c +} diff --git a/internal/ui/hud/elements.go b/internal/ui/hud/elements.go new file mode 100644 index 0000000..f7bf099 --- /dev/null +++ b/internal/ui/hud/elements.go @@ -0,0 +1,209 @@ +package hud + +import ( + "fmt" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/text/v2" + "golang.org/x/image/font/basicfont" + + "github.com/atridad/BigFeelings/internal/status" +) + +var ( + basicFace = text.NewGoXFace(basicfont.Face7x13) + basicFaceAscent = basicFace.Metrics().HAscent +) + +func drawHUDText(screen *ebiten.Image, txt string, clr color.Color, x, y int) { + if clr == nil { + clr = color.White + } + op := &text.DrawOptions{} + op.GeoM.Translate(float64(x), float64(y)-basicFaceAscent) + op.ColorScale.ScaleWithColor(clr) + text.Draw(screen, txt, basicFace, op) +} + +// Drawable HUD chunk. +type Element interface { + Draw(screen *ebiten.Image, x, y int) (width, height int) +} + +// Plain text node. +type Label struct { + Text string + Color color.Color +} + +func (l Label) Draw(screen *ebiten.Image, x, y int) (int, int) { + if l.Color == nil { + l.Color = color.White + } + drawHUDText(screen, l.Text, l.Color, x, y) + width := len(l.Text) * 7 // approximate width for basicfont + return width, 13 +} + +// Percent readout node. +type PercentageDisplay struct { + Meter status.Meter + Color color.Color +} + +func (p PercentageDisplay) Draw(screen *ebiten.Image, x, y int) (int, int) { + if p.Color == nil { + p.Color = color.White + } + txt := fmt.Sprintf("%3.0f%%", p.Meter.Level) + drawHUDText(screen, txt, p.Color, x, y) + return len(txt) * 7, 13 +} + +// Combined label and percent. +type MeterLabel struct { + Meter status.Meter + Color color.Color +} + +func (m MeterLabel) Draw(screen *ebiten.Image, x, y int) (int, int) { + if m.Color == nil { + m.Color = color.White + } + txt := fmt.Sprintf("%s: %3.0f%%", m.Meter.Label, m.Meter.Level) + drawHUDText(screen, txt, m.Color, x, y) + return len(txt) * 7, 13 +} + +// Horizontal meter bar. +type Bar struct { + Meter status.Meter + MaxWidth int + Height int + BorderColor color.Color + ShowBorder bool +} + +func (b Bar) Draw(screen *ebiten.Image, x, y int) (int, int) { + maxWidth := b.MaxWidth + if maxWidth <= 0 { + maxWidth = 180 + } + height := b.Height + if height <= 0 { + height = 8 + } + + fillWidth := int((b.Meter.Level / 100.0) * float64(maxWidth)) + if fillWidth < 0 { + fillWidth = 0 + } + if fillWidth > maxWidth { + fillWidth = maxWidth + } + + // Draw border if requested + if b.ShowBorder { + borderColor := b.BorderColor + if borderColor == nil { + borderColor = color.RGBA{R: 80, G: 80, B: 80, A: 255} + } + // Top border + drawRect(screen, x, y, maxWidth, 1, borderColor) + // Bottom border + drawRect(screen, x, y+height-1, maxWidth, 1, borderColor) + // Left border + drawRect(screen, x, y, 1, height, borderColor) + // Right border + drawRect(screen, x+maxWidth-1, y, 1, height, borderColor) + } + + // Draw filled portion + if fillWidth > 0 { + drawRect(screen, x, y, fillWidth, height, b.Meter.Color) + } + + return maxWidth, height +} + +// Helper for filled rectangles. +func drawRect(screen *ebiten.Image, x, y, width, height int, clr color.Color) { + if width <= 0 || height <= 0 { + return + } + + // Create a small 1x1 pixel image and scale it + img := ebiten.NewImage(1, 1) + img.Fill(clr) + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(float64(width), float64(height)) + op.GeoM.Translate(float64(x), float64(y)) + + screen.DrawImage(img, op) +} + +// Empty padding block. +type Spacer struct { + Width int + Height int +} + +func (s Spacer) Draw(screen *ebiten.Image, x, y int) (int, int) { + return s.Width, s.Height +} + +// Horizontal layout row. +type Row struct { + Elements []Element + Spacing int +} + +func (r Row) Draw(screen *ebiten.Image, x, y int) (int, int) { + totalWidth := 0 + maxHeight := 0 + currentX := x + + for i, elem := range r.Elements { + w, h := elem.Draw(screen, currentX, y) + totalWidth += w + if h > maxHeight { + maxHeight = h + } + currentX += w + if i < len(r.Elements)-1 { + currentX += r.Spacing + totalWidth += r.Spacing + } + } + + return totalWidth, maxHeight +} + +// Vertical stack layout. +type Column struct { + Elements []Element + Spacing int +} + +func (c Column) Draw(screen *ebiten.Image, x, y int) (int, int) { + maxWidth := 0 + totalHeight := 0 + currentY := y + + for i, elem := range c.Elements { + w, h := elem.Draw(screen, x, currentY) + if w > maxWidth { + maxWidth = w + } + totalHeight += h + currentY += h + if i < len(c.Elements)-1 { + currentY += c.Spacing + totalHeight += c.Spacing + } + } + + return maxWidth, totalHeight +} diff --git a/internal/ui/hud/hud.go b/internal/ui/hud/hud.go new file mode 100644 index 0000000..e0e143f --- /dev/null +++ b/internal/ui/hud/hud.go @@ -0,0 +1,52 @@ +package hud + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + + "github.com/atridad/BigFeelings/internal/status" +) + +// HUD overlay anchor. +type Overlay struct { + X int + Y int + Color color.Color +} + +// Paints the HUD overlay. +func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) { + if o.Color == nil { + o.Color = color.White + } + + // Instruction text + instructions := Column{ + Elements: []Element{ + Label{Text: "Systems Prototype", Color: o.Color}, + Label{Text: "Move with Arrow Keys / WASD", Color: o.Color}, + Label{Text: "Track resource signals and plan ahead.", Color: o.Color}, + }, + Spacing: 7, + } + instructions.Draw(screen, 16, 16) + + // Meter column + meterElements := make([]Element, 0, len(meters)) + for _, meter := range meters { + meterElements = append(meterElements, Column{ + Elements: []Element{ + MeterLabel{Meter: meter, Color: o.Color}, + Bar{Meter: meter, MaxWidth: 180, Height: 8, ShowBorder: false}, + }, + Spacing: 2, + }) + } + + meterPanel := Column{ + Elements: meterElements, + Spacing: 16, + } + meterPanel.Draw(screen, o.X, o.Y) +}