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
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:

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"
game "github.com/atridad/BigFeelings/internal/game"
game "github.com/atridad/LilGuy/internal/game"
)
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

View File

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

View File

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

View File

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

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"
"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()

View File

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

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})
// Draw game title
titleText := "BIG FEELINGS"
titleText := "LIL GUY"
scale := 3.0
charWidth := 7.0 * scale
textWidth := float64(len(titleText)) * charWidth

View File

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

View File

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

View File

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

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