Name change

This commit is contained in:
2025-11-24 12:18:32 -07:00
parent cd6c1a78b0
commit 175479da69
65 changed files with 624 additions and 191 deletions

View File

@@ -3,10 +3,10 @@
export CGO_CFLAGS=-Wno-deprecated-declarations export CGO_CFLAGS=-Wno-deprecated-declarations
run: run:
@go run ./cmd/bigfeelings @go run ./cmd/lilguy
build: build:
@go build -o bin/bigfeelings ./cmd/bigfeelings @go build -o bin/lilguy ./cmd/lilguy
clean: clean:
@rm -rf bin/ @rm -rf bin/
@@ -22,21 +22,21 @@ deps:
# Run with verbose output # Run with verbose output
run-verbose: run-verbose:
@go run -v ./cmd/bigfeelings @go run -v ./cmd/lilguy
# Build for current platform # Build for current platform
build-local: clean build-local: clean
@mkdir -p bin @mkdir -p bin
@go build -o bin/bigfeelings ./cmd/bigfeelings @go build -o bin/lilguy ./cmd/lilguy
@echo "Built: bin/bigfeelings" @echo "Built: bin/lilguy"
# Build for multiple platforms # Build for multiple platforms
build-all: clean build-all: clean
@mkdir -p bin @mkdir -p bin
GOOS=darwin GOARCH=arm64 go build -o bin/bigfeelings-darwin-arm64 ./cmd/bigfeelings GOOS=darwin GOARCH=arm64 go build -o bin/lilguy-darwin-arm64 ./cmd/lilguy
GOOS=darwin GOARCH=amd64 go build -o bin/bigfeelings-darwin-amd64 ./cmd/bigfeelings GOOS=darwin GOARCH=amd64 go build -o bin/lilguy-darwin-amd64 ./cmd/lilguy
GOOS=linux GOARCH=amd64 go build -o bin/bigfeelings-linux-amd64 ./cmd/bigfeelings GOOS=linux GOARCH=amd64 go build -o bin/lilguy-linux-amd64 ./cmd/lilguy
GOOS=windows GOARCH=amd64 go build -o bin/bigfeelings-windows-amd64.exe ./cmd/bigfeelings GOOS=windows GOARCH=amd64 go build -o bin/lilguy-windows-amd64.exe ./cmd/lilguy
@echo "Built all platforms" @echo "Built all platforms"
help: help:

BIN
assets/hero/a_000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_003.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_004.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_005.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_006.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_007.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_008.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_009.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_010.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_011.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_012.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/hero/a_013.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_014.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_015.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/hero/a_016.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_017.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_018.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_019.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_020.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/hero/a_021.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_022.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_023.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/hero/a_024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_025.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_026.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/hero/a_027.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_028.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_029.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_030.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_031.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_032.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_033.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
assets/hero/a_034.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_035.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
assets/hero/a_036.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_037.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_038.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_039.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_040.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_041.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/hero/a_042.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -5,7 +5,7 @@ import (
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
game "github.com/atridad/BigFeelings/internal/game" game "github.com/atridad/LilGuy/internal/game"
) )
func main() { func main() {

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/atridad/BigFeelings module github.com/atridad/LilGuy
go 1.25.4 go 1.25.4

View File

@@ -6,9 +6,9 @@ import (
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/atridad/BigFeelings/internal/save" "github.com/atridad/LilGuy/internal/save"
"github.com/atridad/BigFeelings/internal/screens" "github.com/atridad/LilGuy/internal/screens"
"github.com/atridad/BigFeelings/internal/ui/menu" "github.com/atridad/LilGuy/internal/ui/menu"
) )
// Window and display configuration. // Window and display configuration.
@@ -17,7 +17,7 @@ const (
ScreenWidth = 960 ScreenWidth = 960
ScreenHeight = 540 ScreenHeight = 540
TargetTPS = 60 TargetTPS = 60
WindowTitle = "Big Feelings" WindowTitle = "Lil Guy"
) )
// Game states define the different screens and modes the game can be in. // Game states define the different screens and modes the game can be in.
@@ -87,18 +87,18 @@ func (f *FPSCap) Cycle() {
type controls struct { type controls struct {
Left bool Left bool
Right bool Right bool
Up bool Jump bool
Down bool
Sprint bool Sprint bool
Shoot bool
} }
func readControls() controls { func readControls() controls {
return controls{ return controls{
Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA), Left: ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA),
Right: ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD), Right: ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD),
Up: ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW), Jump: ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW) || ebiten.IsKeyPressed(ebiten.KeySpace),
Down: ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS),
Sprint: ebiten.IsKeyPressed(ebiten.KeyShift), 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{ g.state.gameplayScreen.Update(screens.GameplayInput{
Left: input.Left, Left: input.Left,
Right: input.Right, Right: input.Right,
Up: input.Up, Jump: input.Jump,
Down: input.Down,
Sprint: input.Sprint, Sprint: input.Sprint,
Shoot: input.Shoot,
}, delta) }, delta)
// Periodic auto-save // Periodic auto-save
if now.Sub(g.state.lastAutoSave) >= g.state.autoSaveInterval { if now.Sub(g.state.lastAutoSave) >= g.state.autoSaveInterval {
g.saveGame() g.saveGame()
g.state.gameplayScreen.ShowSaveNotification()
g.state.lastAutoSave = now g.state.lastAutoSave = now
} }
@@ -343,6 +344,9 @@ func (g *Game) updatePaused() error {
case menu.OptionSave: case menu.OptionSave:
// Save game immediately // Save game immediately
g.saveGame() g.saveGame()
g.state.gameplayScreen.ShowSaveNotification()
g.state.gameState = statePlaying
g.state.lastTick = time.Now()
case menu.OptionMainMenu: case menu.OptionMainMenu:
// Save game before returning to main menu // Save game before returning to main menu
g.saveGame() g.saveGame()

View File

@@ -2,44 +2,51 @@ package hero
import ( import (
"image/color" "image/color"
"math"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/atridad/LilGuy/internal/projectile"
) )
// Default values and gameplay constants.
const ( const (
// Default values if not specified in config
defaultRadius = 24.0 defaultRadius = 24.0
defaultSpeed = 180.0 defaultSpeed = 200.0
defaultMaxStamina = 100.0 defaultMaxStamina = 100.0
defaultStaminaDrain = 50.0 // Per second when sprinting defaultStaminaDrain = 50.0
defaultStaminaRegen = 30.0 // Per second when not sprinting defaultStaminaRegen = 30.0
// Sprint mechanics sprintSpeedMultiplier = 1.8
sprintSpeedMultiplier = 2.0 sprintRecoveryThreshold = 0.2
sprintRecoveryThreshold = 0.2 // 20% stamina needed to sprint again
// Animation normalAnimSpeed = 0.15
normalAnimSpeed = 0.15 // Seconds per frame when walking idleAnimSpeed = 0.3
sprintAnimSpeed = 0.08 // Seconds per frame when sprinting sprintAnimSpeed = 0.08
// Visual state thresholds exhaustedThreshold = 0.2
exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina
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 { type Input struct {
Left bool Left bool
Right bool Right bool
Up bool Jump bool
Down bool
Sprint bool Sprint bool
} }
type Bounds struct { type Bounds struct {
Width float64 Width float64
Height float64 Height float64
Ground float64
} }
type VisualState int type VisualState int
@@ -53,37 +60,38 @@ const (
type Direction int type Direction int
const ( const (
DirDown Direction = iota DirLeft Direction = iota
DirUp
DirLeft
DirRight DirRight
) )
type Hero struct { type Hero struct {
// Position and size
X float64 X float64
Y float64 Y float64
Radius float64 Radius float64
// Movement VelocityX float64
VelocityY float64
Speed float64 Speed float64
// Appearance
Color color.NRGBA Color color.NRGBA
// Stamina system
Stamina float64 Stamina float64
MaxStamina float64 MaxStamina float64
StaminaDrain float64 StaminaDrain float64
StaminaRegen float64 StaminaRegen float64
// Internal state
canSprint bool canSprint bool
wasSprintHeld bool wasSprintHeld bool
isSprinting bool isSprinting bool
isMoving bool
isGrounded bool
direction Direction direction Direction
animFrame int animFrame int
animTimer float64 animTimer float64
lastAnimKey animationKey
ProjectileConfig projectile.ProjectileConfig
} }
type Config struct { type Config struct {
@@ -129,60 +137,80 @@ func New(cfg Config) *Hero {
StaminaRegen: cfg.StaminaRegen, StaminaRegen: cfg.StaminaRegen,
canSprint: true, canSprint: true,
wasSprintHeld: false, wasSprintHeld: false,
direction: DirDown, direction: DirRight,
animFrame: 0, animFrame: 0,
animTimer: 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) { func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
h.updateMovement(input, dt, bounds) h.updateMovement(input, dt, bounds)
h.updateStamina(input, dt) h.updateStamina(input, dt)
h.updateAnimation(input, dt) h.updateAnimation(dt)
} }
func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) { 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 { if input.Left {
dx -= 1 targetVelocityX -= h.Speed
h.direction = DirLeft h.direction = DirLeft
} }
if input.Right { if input.Right {
dx += 1 targetVelocityX += h.Speed
h.direction = DirRight 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 { h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && h.isMoving
length := math.Hypot(dx, dy)
dx /= length
dy /= length
}
speed := h.Speed
h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && isMoving
if h.isSprinting { if h.isSprinting {
speed *= sprintSpeedMultiplier targetVelocityX *= sprintSpeedMultiplier
} }
h.X += dx * speed * dt friction := groundFriction
h.Y += dy * speed * dt if !h.isGrounded {
friction = airFriction
}
maxX := math.Max(h.Radius, bounds.Width-h.Radius) h.VelocityX = h.VelocityX*friction + targetVelocityX*(1-friction)
maxY := math.Max(h.Radius, bounds.Height-h.Radius)
h.X = clamp(h.X, h.Radius, maxX) h.X += h.VelocityX * dt
h.Y = clamp(h.Y, h.Radius, maxY)
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) { 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) { func (h *Hero) updateAnimation(dt float64) {
isMoving := input.Left || input.Right || input.Up || input.Down isMoving := h.isMoving
key := animationKey{direction: h.direction, state: animIdle}
if isMoving { if isMoving {
animSpeed := normalAnimSpeed key.state = animMove
if h.isSprinting {
animSpeed = sprintAnimSpeed
} }
h.animTimer += dt
if h.animTimer >= animSpeed { if key != h.lastAnimKey {
h.animTimer = 0
h.animFrame = 1 - h.animFrame
}
} else {
h.animFrame = 0 h.animFrame = 0
h.animTimer = 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() sprite := h.getCurrentSprite()
if sprite != nil { if sprite != nil {
op := &ebiten.DrawImageOptions{}
bounds := sprite.Bounds() bounds := sprite.Bounds()
w, height := float64(bounds.Dx()), float64(bounds.Dy()) actualHeight := float64(bounds.Dy())
op.GeoM.Translate(-w/2, -height/2) 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) op.GeoM.Translate(h.X, h.Y)
state := h.getVisualState() state := h.getVisualState()
@@ -254,9 +293,7 @@ func (h *Hero) Draw(screen *ebiten.Image) {
case StateExhausted: case StateExhausted:
op.ColorScale.ScaleWithColor(color.RGBA{R: 255, G: 100, B: 100, A: 255}) op.ColorScale.ScaleWithColor(color.RGBA{R: 255, G: 100, B: 100, A: 255})
case StateSprinting: case StateSprinting:
// No color change
case StateIdle: case StateIdle:
// No color change
} }
screen.DrawImage(sprite, op) screen.DrawImage(sprite, op)
@@ -264,44 +301,9 @@ func (h *Hero) Draw(screen *ebiten.Image) {
} }
func (h *Hero) getCurrentSprite() *ebiten.Image { func (h *Hero) getCurrentSprite() *ebiten.Image {
var sprite *ebiten.Image return getKnightSprite(h.direction, h.isMoving, h.animFrame)
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
} }
func clamp(value, min, max float64) float64 { func (h *Hero) GetDirection() Direction {
if value < min { return h.direction
return min
}
if value > max {
return max
}
return value
} }

View File

@@ -1,65 +1,151 @@
package hero package hero
import ( import (
"fmt"
"image" "image"
"image/color" _ "image/png"
_ "image/gif"
"os" "os"
"path/filepath"
"sort"
"github.com/hajimehoshi/ebiten/v2" "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 ( var (
spriteBack1 *ebiten.Image knightAnimations map[animationKey][]*ebiten.Image
spriteBack2 *ebiten.Image
spriteFront1 *ebiten.Image
spriteFront2 *ebiten.Image
spriteLeft1 *ebiten.Image
spriteLeft2 *ebiten.Image
spriteRight1 *ebiten.Image
spriteRight2 *ebiten.Image
) )
func init() { func init() {
spriteBack1 = loadSprite("assets/hero/avt1_bk1.gif") knightAnimations = make(map[animationKey][]*ebiten.Image)
spriteBack2 = loadSprite("assets/hero/avt1_bk2.gif")
spriteFront1 = loadSprite("assets/hero/avt1_fr1.gif") if err := loadKnightAnimations(); err != nil {
spriteFront2 = loadSprite("assets/hero/avt1_fr2.gif") panic(err)
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")
} }
func loadSprite(path string) *ebiten.Image { func getKnightSprite(direction Direction, moving bool, frameIndex int) *ebiten.Image {
file, err := os.Open(path) state := animIdle
if err != nil { if moving {
panic(err) 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 { if err != nil {
panic(err) return err
} }
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))
}
}
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() bounds := img.Bounds()
rgba := image.NewRGBA(bounds) w, h := bounds.Dx(), bounds.Dy()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ { flipped := ebiten.NewImage(w, h)
r, g, b, a := img.At(x, y).RGBA()
if r > 0xf000 && g > 0xf000 && b > 0xf000 { op := &ebiten.DrawImageOptions{}
rgba.Set(x, y, color.RGBA{0, 0, 0, 0}) op.GeoM.Scale(-1, 1)
} else { op.GeoM.Translate(float64(w), 0)
rgba.Set(x, y, color.RGBA{
R: uint8(r >> 8), flipped.DrawImage(img, op)
G: uint8(g >> 8),
B: uint8(b >> 8), return flipped
A: uint8(a >> 8), }
})
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 ebiten.NewImageFromImage(rgba) return nil
} }

View File

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

View File

@@ -6,26 +6,32 @@ import (
"time" "time"
"github.com/hajimehoshi/ebiten/v2" "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/LilGuy/internal/hero"
"github.com/atridad/BigFeelings/internal/save" "github.com/atridad/LilGuy/internal/projectile"
"github.com/atridad/BigFeelings/internal/status" "github.com/atridad/LilGuy/internal/save"
"github.com/atridad/BigFeelings/internal/ui/hud" "github.com/atridad/LilGuy/internal/status"
"github.com/atridad/LilGuy/internal/ui/hud"
"github.com/atridad/LilGuy/internal/world"
) )
var ( 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 ( const (
heroStartX = 960 / 2 // ScreenWidth / 2 heroStartX = 960 / 2
heroStartY = 540 / 2 // ScreenHeight / 2 heroStartY = 540 / 2
heroRadius = 28.0 heroRadius = 28.0
heroSpeed = 180.0 heroSpeed = 180.0
heroMaxStamina = 100.0 heroMaxStamina = 100.0
heroStaminaDrain = 50.0 heroStaminaDrain = 50.0
heroStaminaRegen = 30.0 heroStaminaRegen = 30.0
saveNotificationDuration = 2 * time.Second
) )
var ( var (
@@ -59,9 +65,9 @@ const (
type GameplayInput struct { type GameplayInput struct {
Left bool Left bool
Right bool Right bool
Up bool Jump bool
Down bool
Sprint bool Sprint bool
Shoot bool
} }
// Manages the main gameplay state including the hero, HUD, and game world. // Manages the main gameplay state including the hero, HUD, and game world.
@@ -69,6 +75,8 @@ type GameplayInput struct {
type GameplayScreen struct { type GameplayScreen struct {
hero *hero.Hero hero *hero.Hero
hud hud.Overlay hud hud.Overlay
world *world.World
projectiles *projectile.Manager
bounds hero.Bounds bounds hero.Bounds
lastTick time.Time lastTick time.Time
gameStartTime time.Time gameStartTime time.Time
@@ -77,10 +85,25 @@ type GameplayScreen struct {
fpsFrames int fpsFrames int
fpsAccumulator time.Duration fpsAccumulator time.Duration
fpsValue float64 fpsValue float64
saveNotificationTimer time.Duration
showSaveNotification bool
} }
// Creates a new gameplay screen instance. // Creates a new gameplay screen instance.
func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool) *GameplayScreen { 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{ return &GameplayScreen{
hero: hero.New(hero.Config{ hero: hero.New(hero.Config{
StartX: heroStartX, StartX: heroStartX,
@@ -97,9 +120,12 @@ func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool) *Gamepla
Y: hudY, Y: hudY,
Color: color.White, Color: color.White,
}, },
world: w,
projectiles: projectile.NewManager(),
bounds: hero.Bounds{ bounds: hero.Bounds{
Width: float64(screenWidth), Width: float64(screenWidth),
Height: float64(screenHeight), Height: float64(screenHeight),
Ground: float64(screenHeight) - groundHeight,
}, },
lastTick: time.Now(), lastTick: time.Now(),
gameStartTime: time.Now(), gameStartTime: time.Now(),
@@ -114,15 +140,30 @@ func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) {
g.hero.Update(hero.Input{ g.hero.Update(hero.Input{
Left: input.Left, Left: input.Left,
Right: input.Right, Right: input.Right,
Up: input.Up, Jump: input.Jump,
Down: input.Down,
Sprint: input.Sprint, Sprint: input.Sprint,
}, dt, g.bounds) }, 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.totalPlayTime += delta
g.trackFPS(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. // Calculates the current FPS if FPS monitoring is enabled.
@@ -144,6 +185,9 @@ func (g *GameplayScreen) trackFPS(delta time.Duration) {
// Renders the gameplay screen. // Renders the gameplay screen.
func (g *GameplayScreen) Draw(screen *ebiten.Image) { func (g *GameplayScreen) Draw(screen *ebiten.Image) {
screen.Fill(backgroundColor) screen.Fill(backgroundColor)
g.world.Draw(screen)
g.projectiles.Draw(screen)
g.hero.Draw(screen) g.hero.Draw(screen)
staminaColor := staminaNormalColor staminaColor := staminaNormalColor
@@ -181,10 +225,63 @@ func (g *GameplayScreen) Draw(screen *ebiten.Image) {
} }
g.hud.Draw(screen, meters) 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. // Resets the gameplay screen to its initial state.
func (g *GameplayScreen) Reset() { 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{ g.hero = hero.New(hero.Config{
StartX: heroStartX, StartX: heroStartX,
StartY: heroStartY, StartY: heroStartY,
@@ -195,6 +292,13 @@ func (g *GameplayScreen) Reset() {
StaminaDrain: heroStaminaDrain, StaminaDrain: heroStaminaDrain,
StaminaRegen: heroStaminaRegen, 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.lastTick = time.Now()
g.gameStartTime = time.Now() g.gameStartTime = time.Now()
g.totalPlayTime = 0 g.totalPlayTime = 0
@@ -215,6 +319,20 @@ func (g *GameplayScreen) SaveState() *save.GameState {
// LoadState restores gameplay state from saved data. // LoadState restores gameplay state from saved data.
func (g *GameplayScreen) LoadState(state *save.GameState) { 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{ g.hero = hero.New(hero.Config{
StartX: state.HeroX, StartX: state.HeroX,
StartY: state.HeroY, StartY: state.HeroY,
@@ -226,6 +344,13 @@ func (g *GameplayScreen) LoadState(state *save.GameState) {
StaminaRegen: heroStaminaRegen, StaminaRegen: heroStaminaRegen,
}) })
g.hero.Stamina = state.HeroStamina 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.totalPlayTime = time.Duration(state.PlayTimeMS) * time.Millisecond
g.lastTick = time.Now() g.lastTick = time.Now()
g.gameStartTime = time.Now() g.gameStartTime = time.Now()

View File

@@ -82,7 +82,7 @@ func (s *SplashScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight int)
} }
// Draw large game title // Draw large game title
titleText := "BIG FEELINGS" titleText := "LIL GUY"
// Calculate size for large text (scale up the basic font) // Calculate size for large text (scale up the basic font)
scale := 4.0 scale := 4.0

View File

@@ -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}) screen.Fill(color.RGBA{R: 20, G: 20, B: 30, A: 255})
// Draw game title // Draw game title
titleText := "BIG FEELINGS" titleText := "LIL GUY"
scale := 3.0 scale := 3.0
charWidth := 7.0 * scale charWidth := 7.0 * scale
textWidth := float64(len(titleText)) * charWidth textWidth := float64(len(titleText)) * charWidth

View File

@@ -8,7 +8,7 @@ import (
"github.com/hajimehoshi/ebiten/v2/text/v2" "github.com/hajimehoshi/ebiten/v2/text/v2"
"golang.org/x/image/font/basicfont" "golang.org/x/image/font/basicfont"
"github.com/atridad/BigFeelings/internal/status" "github.com/atridad/LilGuy/internal/status"
) )
var ( var (

View File

@@ -5,7 +5,7 @@ import (
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/atridad/BigFeelings/internal/status" "github.com/atridad/LilGuy/internal/status"
) )
// HUD overlay anchor. // HUD overlay anchor.
@@ -24,7 +24,7 @@ func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) {
// Instruction text // Instruction text
instructions := Column{ instructions := Column{
Elements: []Element{ 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: "Move with Arrow Keys / WASD", Color: o.Color},
Label{Text: "Hold Shift to Sprint", Color: o.Color}}, Label{Text: "Hold Shift to Sprint", Color: o.Color}},
Spacing: 7, Spacing: 7,

View File

@@ -9,7 +9,7 @@ import (
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
"golang.org/x/image/font/basicfont" "golang.org/x/image/font/basicfont"
"github.com/atridad/BigFeelings/internal/screens" "github.com/atridad/LilGuy/internal/screens"
) )
var ( var (

121
internal/world/surface.go Normal file
View File

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