First run at basic structure

This commit is contained in:
2025-11-19 01:19:03 -07:00
commit bd33e7e123
10 changed files with 699 additions and 0 deletions

118
internal/game/game.go Normal file
View File

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

117
internal/hero/hero.go Normal file
View File

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

79
internal/status/status.go Normal file
View File

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

209
internal/ui/hud/elements.go Normal file
View File

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

52
internal/ui/hud/hud.go Normal file
View File

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