diff --git a/Makefile b/Makefile index 4ef5f87..3b5bbd6 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,10 @@ export CGO_CFLAGS=-Wno-deprecated-declarations run: - @go run ./cmd/bigfeelings + @go run ./cmd/lilguy build: - @go build -o bin/bigfeelings ./cmd/bigfeelings + @go build -o bin/lilguy ./cmd/lilguy clean: @rm -rf bin/ @@ -22,21 +22,21 @@ deps: # Run with verbose output run-verbose: - @go run -v ./cmd/bigfeelings + @go run -v ./cmd/lilguy # Build for current platform build-local: clean @mkdir -p bin - @go build -o bin/bigfeelings ./cmd/bigfeelings - @echo "Built: bin/bigfeelings" + @go build -o bin/lilguy ./cmd/lilguy + @echo "Built: bin/lilguy" # 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 + GOOS=darwin GOARCH=arm64 go build -o bin/lilguy-darwin-arm64 ./cmd/lilguy + GOOS=darwin GOARCH=amd64 go build -o bin/lilguy-darwin-amd64 ./cmd/lilguy + GOOS=linux GOARCH=amd64 go build -o bin/lilguy-linux-amd64 ./cmd/lilguy + GOOS=windows GOARCH=amd64 go build -o bin/lilguy-windows-amd64.exe ./cmd/lilguy @echo "Built all platforms" help: diff --git a/assets/hero/a_000.png b/assets/hero/a_000.png new file mode 100644 index 0000000..9e19785 Binary files /dev/null and b/assets/hero/a_000.png differ diff --git a/assets/hero/a_001.png b/assets/hero/a_001.png new file mode 100644 index 0000000..4ad54a0 Binary files /dev/null and b/assets/hero/a_001.png differ diff --git a/assets/hero/a_002.png b/assets/hero/a_002.png new file mode 100644 index 0000000..769115f Binary files /dev/null and b/assets/hero/a_002.png differ diff --git a/assets/hero/a_003.png b/assets/hero/a_003.png new file mode 100644 index 0000000..a12a27c Binary files /dev/null and b/assets/hero/a_003.png differ diff --git a/assets/hero/a_004.png b/assets/hero/a_004.png new file mode 100644 index 0000000..8d35065 Binary files /dev/null and b/assets/hero/a_004.png differ diff --git a/assets/hero/a_005.png b/assets/hero/a_005.png new file mode 100644 index 0000000..b5b6bd5 Binary files /dev/null and b/assets/hero/a_005.png differ diff --git a/assets/hero/a_006.png b/assets/hero/a_006.png new file mode 100644 index 0000000..a955da3 Binary files /dev/null and b/assets/hero/a_006.png differ diff --git a/assets/hero/a_007.png b/assets/hero/a_007.png new file mode 100644 index 0000000..8fef35f Binary files /dev/null and b/assets/hero/a_007.png differ diff --git a/assets/hero/a_008.png b/assets/hero/a_008.png new file mode 100644 index 0000000..d896761 Binary files /dev/null and b/assets/hero/a_008.png differ diff --git a/assets/hero/a_009.png b/assets/hero/a_009.png new file mode 100644 index 0000000..43dab52 Binary files /dev/null and b/assets/hero/a_009.png differ diff --git a/assets/hero/a_010.png b/assets/hero/a_010.png new file mode 100644 index 0000000..b7c2c80 Binary files /dev/null and b/assets/hero/a_010.png differ diff --git a/assets/hero/a_011.png b/assets/hero/a_011.png new file mode 100644 index 0000000..9d3944e Binary files /dev/null and b/assets/hero/a_011.png differ diff --git a/assets/hero/a_012.png b/assets/hero/a_012.png new file mode 100644 index 0000000..b773a98 Binary files /dev/null and b/assets/hero/a_012.png differ diff --git a/assets/hero/a_013.png b/assets/hero/a_013.png new file mode 100644 index 0000000..80b98d4 Binary files /dev/null and b/assets/hero/a_013.png differ diff --git a/assets/hero/a_014.png b/assets/hero/a_014.png new file mode 100644 index 0000000..6b68d54 Binary files /dev/null and b/assets/hero/a_014.png differ diff --git a/assets/hero/a_015.png b/assets/hero/a_015.png new file mode 100644 index 0000000..2e880f1 Binary files /dev/null and b/assets/hero/a_015.png differ diff --git a/assets/hero/a_016.png b/assets/hero/a_016.png new file mode 100644 index 0000000..208d6da Binary files /dev/null and b/assets/hero/a_016.png differ diff --git a/assets/hero/a_017.png b/assets/hero/a_017.png new file mode 100644 index 0000000..22408d9 Binary files /dev/null and b/assets/hero/a_017.png differ diff --git a/assets/hero/a_018.png b/assets/hero/a_018.png new file mode 100644 index 0000000..93fe85d Binary files /dev/null and b/assets/hero/a_018.png differ diff --git a/assets/hero/a_019.png b/assets/hero/a_019.png new file mode 100644 index 0000000..2c9238e Binary files /dev/null and b/assets/hero/a_019.png differ diff --git a/assets/hero/a_020.png b/assets/hero/a_020.png new file mode 100644 index 0000000..37757a7 Binary files /dev/null and b/assets/hero/a_020.png differ diff --git a/assets/hero/a_021.png b/assets/hero/a_021.png new file mode 100644 index 0000000..fbc8e38 Binary files /dev/null and b/assets/hero/a_021.png differ diff --git a/assets/hero/a_022.png b/assets/hero/a_022.png new file mode 100644 index 0000000..fa67173 Binary files /dev/null and b/assets/hero/a_022.png differ diff --git a/assets/hero/a_023.png b/assets/hero/a_023.png new file mode 100644 index 0000000..4381afd Binary files /dev/null and b/assets/hero/a_023.png differ diff --git a/assets/hero/a_024.png b/assets/hero/a_024.png new file mode 100644 index 0000000..e8da0d6 Binary files /dev/null and b/assets/hero/a_024.png differ diff --git a/assets/hero/a_025.png b/assets/hero/a_025.png new file mode 100644 index 0000000..c56209c Binary files /dev/null and b/assets/hero/a_025.png differ diff --git a/assets/hero/a_026.png b/assets/hero/a_026.png new file mode 100644 index 0000000..3183431 Binary files /dev/null and b/assets/hero/a_026.png differ diff --git a/assets/hero/a_027.png b/assets/hero/a_027.png new file mode 100644 index 0000000..d4a3a00 Binary files /dev/null and b/assets/hero/a_027.png differ diff --git a/assets/hero/a_028.png b/assets/hero/a_028.png new file mode 100644 index 0000000..adafebd Binary files /dev/null and b/assets/hero/a_028.png differ diff --git a/assets/hero/a_029.png b/assets/hero/a_029.png new file mode 100644 index 0000000..0d92dd2 Binary files /dev/null and b/assets/hero/a_029.png differ diff --git a/assets/hero/a_030.png b/assets/hero/a_030.png new file mode 100644 index 0000000..991667b Binary files /dev/null and b/assets/hero/a_030.png differ diff --git a/assets/hero/a_031.png b/assets/hero/a_031.png new file mode 100644 index 0000000..421a65b Binary files /dev/null and b/assets/hero/a_031.png differ diff --git a/assets/hero/a_032.png b/assets/hero/a_032.png new file mode 100644 index 0000000..a755fe3 Binary files /dev/null and b/assets/hero/a_032.png differ diff --git a/assets/hero/a_033.png b/assets/hero/a_033.png new file mode 100644 index 0000000..022cc08 Binary files /dev/null and b/assets/hero/a_033.png differ diff --git a/assets/hero/a_034.png b/assets/hero/a_034.png new file mode 100644 index 0000000..4c2e6ad Binary files /dev/null and b/assets/hero/a_034.png differ diff --git a/assets/hero/a_035.png b/assets/hero/a_035.png new file mode 100644 index 0000000..737c811 Binary files /dev/null and b/assets/hero/a_035.png differ diff --git a/assets/hero/a_036.png b/assets/hero/a_036.png new file mode 100644 index 0000000..b331fe8 Binary files /dev/null and b/assets/hero/a_036.png differ diff --git a/assets/hero/a_037.png b/assets/hero/a_037.png new file mode 100644 index 0000000..0fb1d8d Binary files /dev/null and b/assets/hero/a_037.png differ diff --git a/assets/hero/a_038.png b/assets/hero/a_038.png new file mode 100644 index 0000000..5e6042d Binary files /dev/null and b/assets/hero/a_038.png differ diff --git a/assets/hero/a_039.png b/assets/hero/a_039.png new file mode 100644 index 0000000..e3a2c89 Binary files /dev/null and b/assets/hero/a_039.png differ diff --git a/assets/hero/a_040.png b/assets/hero/a_040.png new file mode 100644 index 0000000..bdc075d Binary files /dev/null and b/assets/hero/a_040.png differ diff --git a/assets/hero/a_041.png b/assets/hero/a_041.png new file mode 100644 index 0000000..ce0c8e1 Binary files /dev/null and b/assets/hero/a_041.png differ diff --git a/assets/hero/a_042.png b/assets/hero/a_042.png new file mode 100644 index 0000000..ea1c2a1 Binary files /dev/null and b/assets/hero/a_042.png differ diff --git a/assets/hero/avt1_bk1.gif b/assets/hero/avt1_bk1.gif deleted file mode 100644 index 76b75bc..0000000 Binary files a/assets/hero/avt1_bk1.gif and /dev/null differ diff --git a/assets/hero/avt1_bk2.gif b/assets/hero/avt1_bk2.gif deleted file mode 100644 index edb072b..0000000 Binary files a/assets/hero/avt1_bk2.gif and /dev/null differ diff --git a/assets/hero/avt1_fr1.gif b/assets/hero/avt1_fr1.gif deleted file mode 100644 index 8d6cd21..0000000 Binary files a/assets/hero/avt1_fr1.gif and /dev/null differ diff --git a/assets/hero/avt1_fr2.gif b/assets/hero/avt1_fr2.gif deleted file mode 100644 index 5057551..0000000 Binary files a/assets/hero/avt1_fr2.gif and /dev/null differ diff --git a/assets/hero/avt1_lf1.gif b/assets/hero/avt1_lf1.gif deleted file mode 100644 index 159dc4d..0000000 Binary files a/assets/hero/avt1_lf1.gif and /dev/null differ diff --git a/assets/hero/avt1_lf2.gif b/assets/hero/avt1_lf2.gif deleted file mode 100644 index 8f10b7a..0000000 Binary files a/assets/hero/avt1_lf2.gif and /dev/null differ diff --git a/assets/hero/avt1_rt1.gif b/assets/hero/avt1_rt1.gif deleted file mode 100644 index 88ff789..0000000 Binary files a/assets/hero/avt1_rt1.gif and /dev/null differ diff --git a/assets/hero/avt1_rt2.gif b/assets/hero/avt1_rt2.gif deleted file mode 100644 index 3e63bf9..0000000 Binary files a/assets/hero/avt1_rt2.gif and /dev/null differ diff --git a/cmd/bigfeelings/main.go b/cmd/bigfeelings/main.go index d52fbe8..4a8423c 100644 --- a/cmd/bigfeelings/main.go +++ b/cmd/bigfeelings/main.go @@ -5,7 +5,7 @@ import ( "github.com/hajimehoshi/ebiten/v2" - game "github.com/atridad/BigFeelings/internal/game" + game "github.com/atridad/LilGuy/internal/game" ) func main() { diff --git a/go.mod b/go.mod index caf46cc..a123fe6 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/atridad/BigFeelings +module github.com/atridad/LilGuy go 1.25.4 diff --git a/internal/game/game.go b/internal/game/game.go index ca1e8a2..dc3ba14 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -6,9 +6,9 @@ import ( "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" - "github.com/atridad/BigFeelings/internal/save" - "github.com/atridad/BigFeelings/internal/screens" - "github.com/atridad/BigFeelings/internal/ui/menu" + "github.com/atridad/LilGuy/internal/save" + "github.com/atridad/LilGuy/internal/screens" + "github.com/atridad/LilGuy/internal/ui/menu" ) // Window and display configuration. @@ -17,7 +17,7 @@ const ( ScreenWidth = 960 ScreenHeight = 540 TargetTPS = 60 - WindowTitle = "Big Feelings" + WindowTitle = "Lil Guy" ) // Game states define the different screens and modes the game can be in. @@ -87,18 +87,18 @@ func (f *FPSCap) Cycle() { type controls struct { Left bool Right bool - Up bool - Down bool + Jump bool Sprint bool + Shoot 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), + Jump: ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW) || ebiten.IsKeyPressed(ebiten.KeySpace), Sprint: ebiten.IsKeyPressed(ebiten.KeyShift), + Shoot: inpututil.IsKeyJustPressed(ebiten.KeyK), } } @@ -311,14 +311,15 @@ func (g *Game) updatePlaying() error { g.state.gameplayScreen.Update(screens.GameplayInput{ Left: input.Left, Right: input.Right, - Up: input.Up, - Down: input.Down, + Jump: input.Jump, Sprint: input.Sprint, + Shoot: input.Shoot, }, delta) // Periodic auto-save if now.Sub(g.state.lastAutoSave) >= g.state.autoSaveInterval { g.saveGame() + g.state.gameplayScreen.ShowSaveNotification() g.state.lastAutoSave = now } @@ -343,6 +344,9 @@ func (g *Game) updatePaused() error { case menu.OptionSave: // Save game immediately g.saveGame() + g.state.gameplayScreen.ShowSaveNotification() + g.state.gameState = statePlaying + g.state.lastTick = time.Now() case menu.OptionMainMenu: // Save game before returning to main menu g.saveGame() diff --git a/internal/hero/hero.go b/internal/hero/hero.go index 1230c8c..5bc6ede 100644 --- a/internal/hero/hero.go +++ b/internal/hero/hero.go @@ -2,44 +2,51 @@ package hero import ( "image/color" - "math" "github.com/hajimehoshi/ebiten/v2" + + "github.com/atridad/LilGuy/internal/projectile" ) -// Default values and gameplay constants. - const ( - // Default values if not specified in config defaultRadius = 24.0 - defaultSpeed = 180.0 + defaultSpeed = 200.0 defaultMaxStamina = 100.0 - defaultStaminaDrain = 50.0 // Per second when sprinting - defaultStaminaRegen = 30.0 // Per second when not sprinting + defaultStaminaDrain = 50.0 + defaultStaminaRegen = 30.0 - // Sprint mechanics - sprintSpeedMultiplier = 2.0 - sprintRecoveryThreshold = 0.2 // 20% stamina needed to sprint again + sprintSpeedMultiplier = 1.8 + sprintRecoveryThreshold = 0.2 - // Animation - normalAnimSpeed = 0.15 // Seconds per frame when walking - sprintAnimSpeed = 0.08 // Seconds per frame when sprinting + normalAnimSpeed = 0.15 + idleAnimSpeed = 0.3 + sprintAnimSpeed = 0.08 - // Visual state thresholds - exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina + exhaustedThreshold = 0.2 + + animFrameWrap = 4096 + heroSpriteScale = 0.175 + fixedSpriteHeight = 329.0 + fixedSpriteWidth = 315.0 + + gravity = 1200.0 + jumpStrength = -450.0 + maxFallSpeed = 800.0 + groundFriction = 0.85 + airFriction = 0.95 ) type Input struct { Left bool Right bool - Up bool - Down bool + Jump bool Sprint bool } type Bounds struct { Width float64 Height float64 + Ground float64 } type VisualState int @@ -53,37 +60,38 @@ const ( type Direction int const ( - DirDown Direction = iota - DirUp - DirLeft + DirLeft Direction = iota DirRight ) type Hero struct { - // Position and size X float64 Y float64 Radius float64 - // Movement + VelocityX float64 + VelocityY float64 + Speed float64 - // Appearance Color color.NRGBA - // Stamina system Stamina float64 MaxStamina float64 StaminaDrain float64 StaminaRegen float64 - // Internal state canSprint bool wasSprintHeld bool isSprinting bool + isMoving bool + isGrounded bool direction Direction animFrame int animTimer float64 + lastAnimKey animationKey + + ProjectileConfig projectile.ProjectileConfig } type Config struct { @@ -129,60 +137,80 @@ func New(cfg Config) *Hero { StaminaRegen: cfg.StaminaRegen, canSprint: true, wasSprintHeld: false, - direction: DirDown, + direction: DirRight, animFrame: 0, animTimer: 0, + lastAnimKey: animationKey{direction: DirRight, state: animIdle}, + ProjectileConfig: projectile.ProjectileConfig{ + Speed: 500.0, + Radius: 4.0, + Color: color.NRGBA{R: 255, G: 0, B: 0, A: 255}, + }, } } func (h *Hero) Update(input Input, dt float64, bounds Bounds) { h.updateMovement(input, dt, bounds) h.updateStamina(input, dt) - h.updateAnimation(input, dt) + h.updateAnimation(dt) } func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) { - dx, dy := 0.0, 0.0 + h.VelocityY += gravity * dt + if h.VelocityY > maxFallSpeed { + h.VelocityY = maxFallSpeed + } + h.Y += h.VelocityY * dt + + footPosition := h.Y + if footPosition >= bounds.Ground { + h.Y = bounds.Ground + h.VelocityY = 0 + h.isGrounded = true + } else { + h.isGrounded = false + } + + if input.Jump && h.isGrounded { + h.VelocityY = jumpStrength + h.isGrounded = false + } + + targetVelocityX := 0.0 if input.Left { - dx -= 1 + targetVelocityX -= h.Speed h.direction = DirLeft } if input.Right { - dx += 1 + targetVelocityX += h.Speed h.direction = DirRight } - if input.Up { - dy -= 1 - h.direction = DirUp - } - if input.Down { - dy += 1 - h.direction = DirDown - } - isMoving := dx != 0 || dy != 0 + h.isMoving = targetVelocityX != 0 - if isMoving { - length := math.Hypot(dx, dy) - dx /= length - dy /= length - } - - speed := h.Speed - h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && isMoving + h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && h.isMoving if h.isSprinting { - speed *= sprintSpeedMultiplier + targetVelocityX *= sprintSpeedMultiplier } - h.X += dx * speed * dt - h.Y += dy * speed * dt + friction := groundFriction + if !h.isGrounded { + friction = airFriction + } - maxX := math.Max(h.Radius, bounds.Width-h.Radius) - maxY := math.Max(h.Radius, bounds.Height-h.Radius) + h.VelocityX = h.VelocityX*friction + targetVelocityX*(1-friction) - h.X = clamp(h.X, h.Radius, maxX) - h.Y = clamp(h.Y, h.Radius, maxY) + h.X += h.VelocityX * dt + + if h.X < h.Radius { + h.X = h.Radius + h.VelocityX = 0 + } + if h.X > bounds.Width-h.Radius { + h.X = bounds.Width - h.Radius + h.VelocityX = 0 + } } func (h *Hero) updateStamina(input Input, dt float64) { @@ -208,22 +236,30 @@ func (h *Hero) updateStamina(input Input, dt float64) { } } -func (h *Hero) updateAnimation(input Input, dt float64) { - isMoving := input.Left || input.Right || input.Up || input.Down - +func (h *Hero) updateAnimation(dt float64) { + isMoving := h.isMoving + key := animationKey{direction: h.direction, state: animIdle} 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 { + key.state = animMove + } + + if key != h.lastAnimKey { h.animFrame = 0 h.animTimer = 0 + h.lastAnimKey = key + } + + if isMoving { + animSpeed := normalAnimSpeed * 0.5 + if h.isSprinting { + animSpeed = sprintAnimSpeed * 0.5 + } + h.animTimer += dt + frameAdvance := int(h.animTimer / animSpeed) + if frameAdvance > 0 { + h.animTimer -= animSpeed * float64(frameAdvance) + h.animFrame = (h.animFrame + frameAdvance) % animFrameWrap + } } } @@ -243,10 +279,13 @@ func (h *Hero) Draw(screen *ebiten.Image) { sprite := h.getCurrentSprite() if sprite != nil { - op := &ebiten.DrawImageOptions{} bounds := sprite.Bounds() - w, height := float64(bounds.Dx()), float64(bounds.Dy()) - op.GeoM.Translate(-w/2, -height/2) + actualHeight := float64(bounds.Dy()) + actualWidth := float64(bounds.Dx()) + + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(-actualWidth/2, -actualHeight) + op.GeoM.Scale(heroSpriteScale, heroSpriteScale) op.GeoM.Translate(h.X, h.Y) state := h.getVisualState() @@ -254,9 +293,7 @@ func (h *Hero) Draw(screen *ebiten.Image) { 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 } screen.DrawImage(sprite, op) @@ -264,44 +301,9 @@ func (h *Hero) Draw(screen *ebiten.Image) { } func (h *Hero) getCurrentSprite() *ebiten.Image { - var sprite *ebiten.Image - - 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 - } - } - - return sprite + return getKnightSprite(h.direction, h.isMoving, h.animFrame) } -func clamp(value, min, max float64) float64 { - if value < min { - return min - } - if value > max { - return max - } - return value +func (h *Hero) GetDirection() Direction { + return h.direction } diff --git a/internal/hero/sprites.go b/internal/hero/sprites.go index d572bd6..6d54af7 100644 --- a/internal/hero/sprites.go +++ b/internal/hero/sprites.go @@ -1,65 +1,151 @@ package hero import ( + "fmt" "image" - "image/color" - _ "image/gif" + _ "image/png" "os" + "path/filepath" + "sort" "github.com/hajimehoshi/ebiten/v2" ) +const ( + heroDir = "assets/hero" +) + +type animState int + +const ( + animIdle animState = iota + animMove +) + +type animationKey struct { + direction Direction + state animState +} + 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 + knightAnimations map[animationKey][]*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") + knightAnimations = make(map[animationKey][]*ebiten.Image) + + if err := loadKnightAnimations(); err != nil { + panic(err) + } } -func loadSprite(path string) *ebiten.Image { - file, err := os.Open(path) - if err != nil { - panic(err) +func getKnightSprite(direction Direction, moving bool, frameIndex int) *ebiten.Image { + state := animIdle + if moving { + state = animMove } - defer file.Close() + return frameFromSet(direction, state, frameIndex) +} - img, _, err := image.Decode(file) +func loadKnightAnimations() error { + frames, err := loadAnimationFrames(heroDir) if err != nil { - panic(err) + return 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), - }) - } + knightAnimations[animationKey{DirLeft, animMove}] = frames + knightAnimations[animationKey{DirLeft, animIdle}] = frames + + flippedFrames := make([]*ebiten.Image, len(frames)) + for i, frame := range frames { + flippedFrames[i] = flipImageHorizontally(frame) + } + + knightAnimations[animationKey{DirRight, animMove}] = flippedFrames + knightAnimations[animationKey{DirRight, animIdle}] = flippedFrames + + return nil +} + +func loadAnimationFrames(dir string) ([]*ebiten.Image, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if filepath.Ext(name) == ".png" { + files = append(files, filepath.Join(dir, name)) } } - return ebiten.NewImageFromImage(rgba) + sort.Strings(files) + + var frames []*ebiten.Image + for _, file := range files { + img, err := loadImage(file) + if err != nil { + return nil, fmt.Errorf("load frame %s: %w", file, err) + } + frames = append(frames, img) + } + + return frames, nil +} + +func loadImage(path string) (*ebiten.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + return nil, err + } + + return ebiten.NewImageFromImage(img), nil +} + +func flipImageHorizontally(img *ebiten.Image) *ebiten.Image { + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + + flipped := ebiten.NewImage(w, h) + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(-1, 1) + op.GeoM.Translate(float64(w), 0) + + flipped.DrawImage(img, op) + + return flipped +} + +func frameFromSet(direction Direction, state animState, frameIndex int) *ebiten.Image { + if frameIndex < 0 { + frameIndex = 0 + } + + if frames := knightAnimations[animationKey{direction, state}]; len(frames) > 0 { + return frames[frameIndex%len(frames)] + } + + if state == animMove { + if frames := knightAnimations[animationKey{direction, animIdle}]; len(frames) > 0 { + return frames[frameIndex%len(frames)] + } + } else { + if frames := knightAnimations[animationKey{direction, animMove}]; len(frames) > 0 { + return frames[frameIndex%len(frames)] + } + } + + return nil } diff --git a/internal/projectile/projectile.go b/internal/projectile/projectile.go new file mode 100644 index 0000000..eeb5b29 --- /dev/null +++ b/internal/projectile/projectile.go @@ -0,0 +1,95 @@ +package projectile + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +type ProjectileConfig struct { + Speed float64 + Radius float64 + Color color.NRGBA +} + +type Projectile struct { + X float64 + Y float64 + VelocityX float64 + VelocityY float64 + Radius float64 + Color color.NRGBA + Active bool +} + +func New(x, y, directionX, directionY float64, config ProjectileConfig) *Projectile { + return &Projectile{ + X: x, + Y: y, + VelocityX: directionX * config.Speed, + VelocityY: directionY * config.Speed, + Radius: config.Radius, + Color: config.Color, + Active: true, + } +} + +func (p *Projectile) Update(dt float64, screenWidth, screenHeight float64) { + p.X += p.VelocityX * dt + p.Y += p.VelocityY * dt + + if p.X < 0 || p.X > screenWidth || p.Y < 0 || p.Y > screenHeight { + p.Active = false + } +} + +func (p *Projectile) Draw(screen *ebiten.Image) { + if !p.Active { + return + } + vector.DrawFilledCircle( + screen, + float32(p.X), + float32(p.Y), + float32(p.Radius), + p.Color, + false, + ) +} + +type Manager struct { + Projectiles []*Projectile +} + +func NewManager() *Manager { + return &Manager{ + Projectiles: make([]*Projectile, 0), + } +} + +func (m *Manager) Shoot(x, y, directionX float64, config ProjectileConfig) { + p := New(x, y, directionX, 0, config) + m.Projectiles = append(m.Projectiles, p) +} + +func (m *Manager) Update(dt float64, screenWidth, screenHeight float64) { + for i := len(m.Projectiles) - 1; i >= 0; i-- { + p := m.Projectiles[i] + p.Update(dt, screenWidth, screenHeight) + + if !p.Active { + m.Projectiles = append(m.Projectiles[:i], m.Projectiles[i+1:]...) + } + } +} + +func (m *Manager) Draw(screen *ebiten.Image) { + for _, p := range m.Projectiles { + p.Draw(screen) + } +} + +func (m *Manager) Clear() { + m.Projectiles = make([]*Projectile, 0) +} diff --git a/internal/screens/gameplay.go b/internal/screens/gameplay.go index 2987a05..e6c24ef 100644 --- a/internal/screens/gameplay.go +++ b/internal/screens/gameplay.go @@ -6,26 +6,32 @@ import ( "time" "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/vector" - "github.com/atridad/BigFeelings/internal/hero" - "github.com/atridad/BigFeelings/internal/save" - "github.com/atridad/BigFeelings/internal/status" - "github.com/atridad/BigFeelings/internal/ui/hud" + "github.com/atridad/LilGuy/internal/hero" + "github.com/atridad/LilGuy/internal/projectile" + "github.com/atridad/LilGuy/internal/save" + "github.com/atridad/LilGuy/internal/status" + "github.com/atridad/LilGuy/internal/ui/hud" + "github.com/atridad/LilGuy/internal/world" ) var ( - backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255} + backgroundColor = color.NRGBA{R: 135, G: 206, B: 235, A: 255} + saveNotificationColor = color.NRGBA{R: 50, G: 200, B: 50, A: 255} ) -// Hero settings. const ( - heroStartX = 960 / 2 // ScreenWidth / 2 - heroStartY = 540 / 2 // ScreenHeight / 2 + heroStartX = 960 / 2 + heroStartY = 540 / 2 heroRadius = 28.0 heroSpeed = 180.0 heroMaxStamina = 100.0 heroStaminaDrain = 50.0 heroStaminaRegen = 30.0 + + saveNotificationDuration = 2 * time.Second ) var ( @@ -59,9 +65,9 @@ const ( type GameplayInput struct { Left bool Right bool - Up bool - Down bool + Jump bool Sprint bool + Shoot bool } // Manages the main gameplay state including the hero, HUD, and game world. @@ -69,6 +75,8 @@ type GameplayInput struct { type GameplayScreen struct { hero *hero.Hero hud hud.Overlay + world *world.World + projectiles *projectile.Manager bounds hero.Bounds lastTick time.Time gameStartTime time.Time @@ -77,10 +85,25 @@ type GameplayScreen struct { fpsFrames int fpsAccumulator time.Duration fpsValue float64 + + saveNotificationTimer time.Duration + showSaveNotification bool } // Creates a new gameplay screen instance. func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool) *GameplayScreen { + w := world.NewWorld() + + groundHeight := 16.0 + w.AddSurface(&world.Surface{ + X: 0, + Y: float64(screenHeight) - groundHeight, + Width: float64(screenWidth), + Height: groundHeight, + Tag: world.TagGround, + Color: color.NRGBA{R: 34, G: 139, B: 34, A: 255}, + }) + return &GameplayScreen{ hero: hero.New(hero.Config{ StartX: heroStartX, @@ -97,9 +120,12 @@ func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool) *Gamepla Y: hudY, Color: color.White, }, + world: w, + projectiles: projectile.NewManager(), bounds: hero.Bounds{ Width: float64(screenWidth), Height: float64(screenHeight), + Ground: float64(screenHeight) - groundHeight, }, lastTick: time.Now(), gameStartTime: time.Now(), @@ -114,15 +140,30 @@ func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) { g.hero.Update(hero.Input{ Left: input.Left, Right: input.Right, - Up: input.Up, - Down: input.Down, + Jump: input.Jump, Sprint: input.Sprint, }, dt, g.bounds) - // Track total play time + if input.Shoot { + direction := 1.0 + if g.hero.GetDirection() == hero.DirLeft { + direction = -1.0 + } + g.projectiles.Shoot(g.hero.X, g.hero.Y-20, direction, g.hero.ProjectileConfig) + } + + g.projectiles.Update(dt, g.bounds.Width, g.bounds.Height) + g.totalPlayTime += delta g.trackFPS(delta) + + if g.showSaveNotification { + g.saveNotificationTimer -= delta + if g.saveNotificationTimer <= 0 { + g.showSaveNotification = false + } + } } // Calculates the current FPS if FPS monitoring is enabled. @@ -144,6 +185,9 @@ func (g *GameplayScreen) trackFPS(delta time.Duration) { // Renders the gameplay screen. func (g *GameplayScreen) Draw(screen *ebiten.Image) { screen.Fill(backgroundColor) + + g.world.Draw(screen) + g.projectiles.Draw(screen) g.hero.Draw(screen) staminaColor := staminaNormalColor @@ -181,10 +225,63 @@ func (g *GameplayScreen) Draw(screen *ebiten.Image) { } g.hud.Draw(screen, meters) + + if g.showSaveNotification { + g.drawSaveNotification(screen) + } +} + +func (g *GameplayScreen) drawSaveNotification(screen *ebiten.Image) { + centerX := float32(g.bounds.Width / 2) + centerY := float32(30) + + boxWidth := float32(140) + boxHeight := float32(40) + + vector.DrawFilledRect(screen, + centerX-boxWidth/2, + centerY-boxHeight/2, + boxWidth, + boxHeight, + color.NRGBA{R: 0, G: 0, B: 0, A: 180}, + false) + + vector.StrokeRect(screen, + centerX-boxWidth/2, + centerY-boxHeight/2, + boxWidth, + boxHeight, + 2, + saveNotificationColor, + false) + + msg := "Game Saved!" + textX := centerX - 45 + textY := centerY - 5 + ebitenutil.DebugPrintAt(screen, msg, int(textX), int(textY)) +} + +func (g *GameplayScreen) ShowSaveNotification() { + g.showSaveNotification = true + g.saveNotificationTimer = saveNotificationDuration } // Resets the gameplay screen to its initial state. func (g *GameplayScreen) Reset() { + screenWidth := int(g.bounds.Width) + screenHeight := int(g.bounds.Height) + groundHeight := 16.0 + + w := world.NewWorld() + w.AddSurface(&world.Surface{ + X: 0, + Y: float64(screenHeight) - groundHeight, + Width: float64(screenWidth), + Height: groundHeight, + Tag: world.TagGround, + Color: color.NRGBA{R: 34, G: 139, B: 34, A: 255}, + }) + g.hero = hero.New(hero.Config{ StartX: heroStartX, StartY: heroStartY, @@ -195,6 +292,13 @@ func (g *GameplayScreen) Reset() { StaminaDrain: heroStaminaDrain, StaminaRegen: heroStaminaRegen, }) + g.world = w + g.projectiles = projectile.NewManager() + g.bounds = hero.Bounds{ + Width: float64(screenWidth), + Height: float64(screenHeight), + Ground: float64(screenHeight) - groundHeight, + } g.lastTick = time.Now() g.gameStartTime = time.Now() g.totalPlayTime = 0 @@ -215,6 +319,20 @@ func (g *GameplayScreen) SaveState() *save.GameState { // LoadState restores gameplay state from saved data. func (g *GameplayScreen) LoadState(state *save.GameState) { + screenWidth := int(g.bounds.Width) + screenHeight := int(g.bounds.Height) + groundHeight := 16.0 + + w := world.NewWorld() + w.AddSurface(&world.Surface{ + X: 0, + Y: float64(screenHeight) - groundHeight, + Width: float64(screenWidth), + Height: groundHeight, + Tag: world.TagGround, + Color: color.NRGBA{R: 34, G: 139, B: 34, A: 255}, + }) + g.hero = hero.New(hero.Config{ StartX: state.HeroX, StartY: state.HeroY, @@ -226,6 +344,13 @@ func (g *GameplayScreen) LoadState(state *save.GameState) { StaminaRegen: heroStaminaRegen, }) g.hero.Stamina = state.HeroStamina + g.world = w + g.projectiles = projectile.NewManager() + g.bounds = hero.Bounds{ + Width: float64(screenWidth), + Height: float64(screenHeight), + Ground: float64(screenHeight) - groundHeight, + } g.totalPlayTime = time.Duration(state.PlayTimeMS) * time.Millisecond g.lastTick = time.Now() g.gameStartTime = time.Now() diff --git a/internal/screens/splash.go b/internal/screens/splash.go index 908882b..60ce95e 100644 --- a/internal/screens/splash.go +++ b/internal/screens/splash.go @@ -82,7 +82,7 @@ func (s *SplashScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight int) } // Draw large game title - titleText := "BIG FEELINGS" + titleText := "LIL GUY" // Calculate size for large text (scale up the basic font) scale := 4.0 diff --git a/internal/screens/title.go b/internal/screens/title.go index d361eb9..419ff13 100644 --- a/internal/screens/title.go +++ b/internal/screens/title.go @@ -130,7 +130,7 @@ func (t *TitleScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight int) screen.Fill(color.RGBA{R: 20, G: 20, B: 30, A: 255}) // Draw game title - titleText := "BIG FEELINGS" + titleText := "LIL GUY" scale := 3.0 charWidth := 7.0 * scale textWidth := float64(len(titleText)) * charWidth diff --git a/internal/ui/hud/elements.go b/internal/ui/hud/elements.go index a370692..11512e3 100644 --- a/internal/ui/hud/elements.go +++ b/internal/ui/hud/elements.go @@ -8,7 +8,7 @@ import ( "github.com/hajimehoshi/ebiten/v2/text/v2" "golang.org/x/image/font/basicfont" - "github.com/atridad/BigFeelings/internal/status" + "github.com/atridad/LilGuy/internal/status" ) var ( diff --git a/internal/ui/hud/hud.go b/internal/ui/hud/hud.go index 967b9ad..5f2d8d4 100644 --- a/internal/ui/hud/hud.go +++ b/internal/ui/hud/hud.go @@ -5,7 +5,7 @@ import ( "github.com/hajimehoshi/ebiten/v2" - "github.com/atridad/BigFeelings/internal/status" + "github.com/atridad/LilGuy/internal/status" ) // HUD overlay anchor. @@ -24,7 +24,7 @@ func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) { // Instruction text instructions := Column{ Elements: []Element{ - Label{Text: "Big Feelings", Color: o.Color}, + Label{Text: "Lil Guy", Color: o.Color}, Label{Text: "Move with Arrow Keys / WASD", Color: o.Color}, Label{Text: "Hold Shift to Sprint", Color: o.Color}}, Spacing: 7, diff --git a/internal/ui/menu/menu.go b/internal/ui/menu/menu.go index 2831327..caf1bde 100644 --- a/internal/ui/menu/menu.go +++ b/internal/ui/menu/menu.go @@ -9,7 +9,7 @@ import ( "github.com/hajimehoshi/ebiten/v2/vector" "golang.org/x/image/font/basicfont" - "github.com/atridad/BigFeelings/internal/screens" + "github.com/atridad/LilGuy/internal/screens" ) var ( diff --git a/internal/world/surface.go b/internal/world/surface.go new file mode 100644 index 0000000..46a2124 --- /dev/null +++ b/internal/world/surface.go @@ -0,0 +1,121 @@ +package world + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +type SurfaceTag int + +const ( + TagGround SurfaceTag = iota + TagWall + TagPlatform + TagHazard +) + +type Surface struct { + X float64 + Y float64 + Width float64 + Height float64 + Tag SurfaceTag + Color color.NRGBA +} + +func (s *Surface) Bounds() (x, y, width, height float64) { + return s.X, s.Y, s.Width, s.Height +} + +func (s *Surface) Contains(x, y float64) bool { + return x >= s.X && x <= s.X+s.Width && y >= s.Y && y <= s.Y+s.Height +} + +func (s *Surface) IsSolid() bool { + switch s.Tag { + case TagGround, TagWall, TagPlatform: + return true + case TagHazard: + return false + default: + return false + } +} + +func (s *Surface) IsWalkable() bool { + switch s.Tag { + case TagGround, TagPlatform: + return true + case TagWall, TagHazard: + return false + default: + return false + } +} + +func (s *Surface) Draw(screen *ebiten.Image) { + vector.DrawFilledRect( + screen, + float32(s.X), + float32(s.Y), + float32(s.Width), + float32(s.Height), + s.Color, + false, + ) +} + +type World struct { + Surfaces []*Surface +} + +func NewWorld() *World { + return &World{ + Surfaces: make([]*Surface, 0), + } +} + +func (w *World) AddSurface(surface *Surface) { + w.Surfaces = append(w.Surfaces, surface) +} + +func (w *World) GetSurfacesWithTag(tag SurfaceTag) []*Surface { + var result []*Surface + for _, s := range w.Surfaces { + if s.Tag == tag { + result = append(result, s) + } + } + return result +} + +func (w *World) GetSurfacesAt(x, y float64) []*Surface { + var result []*Surface + for _, s := range w.Surfaces { + if s.Contains(x, y) { + result = append(result, s) + } + } + return result +} + +func (w *World) CheckCollision(x, y, width, height float64) *Surface { + for _, s := range w.Surfaces { + if !s.IsSolid() { + continue + } + if x+width > s.X && x < s.X+s.Width && + y+height > s.Y && y < s.Y+s.Height { + return s + } + } + return nil +} + +func (w *World) Draw(screen *ebiten.Image) { + for _, s := range w.Surfaces { + s.Draw(screen) + } +}