Using a temp free online sprite for now to test sprites
BIN
assets/hero/avt1_bk1.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/hero/avt1_bk2.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/hero/avt1_fr1.gif
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/hero/avt1_fr2.gif
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/hero/avt1_lf1.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/hero/avt1_lf2.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/hero/avt1_rt1.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/hero/avt1_rt2.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
@@ -13,18 +13,57 @@ import (
|
|||||||
"github.com/atridad/BigFeelings/internal/ui/menu"
|
"github.com/atridad/BigFeelings/internal/ui/menu"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// Tweak these values to change game settings
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ScreenWidth = 960
|
ScreenWidth = 960
|
||||||
ScreenHeight = 540
|
ScreenHeight = 540
|
||||||
|
TargetTPS = 60
|
||||||
TargetTPS = 60
|
WindowTitle = "Big Feelings"
|
||||||
WindowTitle = "Big Feelings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255}
|
backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Hero configuration
|
||||||
|
const (
|
||||||
|
heroStartX = ScreenWidth / 2
|
||||||
|
heroStartY = ScreenHeight / 2
|
||||||
|
heroRadius = 28.0
|
||||||
|
heroSpeed = 180.0
|
||||||
|
heroMaxStamina = 100.0
|
||||||
|
heroStaminaDrain = 50.0
|
||||||
|
heroStaminaRegen = 30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
heroColor = color.NRGBA{R: 210, G: 220, B: 255, A: 255}
|
||||||
|
)
|
||||||
|
|
||||||
|
// HUD configuration
|
||||||
|
const (
|
||||||
|
hudX = ScreenWidth - 220
|
||||||
|
hudY = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stamina bar colors
|
||||||
|
var (
|
||||||
|
staminaNormalColor = color.NRGBA{R: 0, G: 255, B: 180, A: 255}
|
||||||
|
staminaLowColor = color.NRGBA{R: 255, G: 60, B: 60, A: 255}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
staminaLowThreshold = 0.2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
type gameState int
|
type gameState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -40,63 +79,10 @@ type controls struct {
|
|||||||
Sprint bool
|
Sprint 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),
|
|
||||||
Sprint: ebiten.IsKeyPressed(ebiten.KeyShift),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
state *state
|
state *state
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Game {
|
|
||||||
return &Game{state: newState()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Game) Update() error {
|
|
||||||
// Handle escape key to toggle pause
|
|
||||||
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
|
||||||
if g.state.gameState == statePlaying {
|
|
||||||
g.state.gameState = statePaused
|
|
||||||
g.state.pauseMenu.Reset()
|
|
||||||
} else if g.state.gameState == statePaused {
|
|
||||||
g.state.gameState = statePlaying
|
|
||||||
// Reset lastTick to prevent delta time accumulation while paused
|
|
||||||
g.state.lastTick = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.state.gameState == statePlaying {
|
|
||||||
g.state.update(readControls())
|
|
||||||
} else if g.state.gameState == statePaused {
|
|
||||||
if selectedOption := g.state.pauseMenu.Update(); selectedOption != nil {
|
|
||||||
switch *selectedOption {
|
|
||||||
case menu.OptionResume:
|
|
||||||
g.state.gameState = statePlaying
|
|
||||||
// Reset lastTick to prevent delta time accumulation while paused
|
|
||||||
g.state.lastTick = time.Now()
|
|
||||||
case menu.OptionQuit:
|
|
||||||
return ebiten.Termination
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Game) Draw(screen *ebiten.Image) {
|
|
||||||
g.state.draw(screen)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
|
||||||
return ScreenWidth, ScreenHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
type state struct {
|
type state struct {
|
||||||
hero *hero.Hero
|
hero *hero.Hero
|
||||||
hud hud.Overlay
|
hud hud.Overlay
|
||||||
@@ -106,22 +92,30 @@ type state struct {
|
|||||||
gameState gameState
|
gameState gameState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// INITIALIZATION
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func New() *Game {
|
||||||
|
return &Game{state: newState()}
|
||||||
|
}
|
||||||
|
|
||||||
func newState() *state {
|
func newState() *state {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &state{
|
return &state{
|
||||||
hero: hero.New(hero.Config{
|
hero: hero.New(hero.Config{
|
||||||
StartX: ScreenWidth / 2,
|
StartX: heroStartX,
|
||||||
StartY: ScreenHeight / 2,
|
StartY: heroStartY,
|
||||||
Radius: 28,
|
Radius: heroRadius,
|
||||||
Speed: 180,
|
Speed: heroSpeed,
|
||||||
Color: color.NRGBA{R: 210, G: 220, B: 255, A: 255},
|
Color: heroColor,
|
||||||
MaxStamina: 100,
|
MaxStamina: heroMaxStamina,
|
||||||
StaminaDrain: 50,
|
StaminaDrain: heroStaminaDrain,
|
||||||
StaminaRegen: 30,
|
StaminaRegen: heroStaminaRegen,
|
||||||
}),
|
}),
|
||||||
hud: hud.Overlay{
|
hud: hud.Overlay{
|
||||||
X: ScreenWidth - 220,
|
X: hudX,
|
||||||
Y: 20,
|
Y: hudY,
|
||||||
Color: color.White,
|
Color: color.White,
|
||||||
},
|
},
|
||||||
bounds: hero.Bounds{
|
bounds: hero.Bounds{
|
||||||
@@ -134,6 +128,52 @@ func newState() *state {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// INPUT
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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),
|
||||||
|
Sprint: ebiten.IsKeyPressed(ebiten.KeyShift),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// UPDATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func (g *Game) Update() error {
|
||||||
|
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
||||||
|
if g.state.gameState == statePlaying {
|
||||||
|
g.state.gameState = statePaused
|
||||||
|
g.state.pauseMenu.Reset()
|
||||||
|
} else if g.state.gameState == statePaused {
|
||||||
|
g.state.gameState = statePlaying
|
||||||
|
g.state.lastTick = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.state.gameState == statePlaying {
|
||||||
|
g.state.update(readControls())
|
||||||
|
} else if g.state.gameState == statePaused {
|
||||||
|
if selectedOption := g.state.pauseMenu.Update(); selectedOption != nil {
|
||||||
|
switch *selectedOption {
|
||||||
|
case menu.OptionResume:
|
||||||
|
g.state.gameState = statePlaying
|
||||||
|
g.state.lastTick = time.Now()
|
||||||
|
case menu.OptionQuit:
|
||||||
|
return ebiten.Termination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *state) update(input controls) {
|
func (s *state) update(input controls) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
dt := now.Sub(s.lastTick).Seconds()
|
dt := now.Sub(s.lastTick).Seconds()
|
||||||
@@ -148,14 +188,21 @@ func (s *state) update(input controls) {
|
|||||||
}, dt, s.bounds)
|
}, dt, s.bounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// RENDERING
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func (g *Game) Draw(screen *ebiten.Image) {
|
||||||
|
g.state.draw(screen)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *state) draw(screen *ebiten.Image) {
|
func (s *state) draw(screen *ebiten.Image) {
|
||||||
screen.Fill(backgroundColor)
|
screen.Fill(backgroundColor)
|
||||||
s.hero.Draw(screen)
|
s.hero.Draw(screen)
|
||||||
|
|
||||||
// Create stamina meter from hero's stamina
|
staminaColor := staminaNormalColor
|
||||||
staminaColor := color.NRGBA{R: 0, G: 255, B: 180, A: 255}
|
if s.hero.Stamina < s.hero.MaxStamina*staminaLowThreshold {
|
||||||
if s.hero.Stamina < s.hero.MaxStamina*0.2 {
|
staminaColor = staminaLowColor
|
||||||
staminaColor = color.NRGBA{R: 255, G: 60, B: 60, A: 255}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
staminaMeter := status.Meter{
|
staminaMeter := status.Meter{
|
||||||
@@ -166,8 +213,11 @@ func (s *state) draw(screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
s.hud.Draw(screen, []status.Meter{staminaMeter})
|
s.hud.Draw(screen, []status.Meter{staminaMeter})
|
||||||
|
|
||||||
// Draw pause menu if paused
|
|
||||||
if s.gameState == statePaused {
|
if s.gameState == statePaused {
|
||||||
s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight)
|
s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
||||||
|
return ScreenWidth, ScreenHeight
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,37 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Direction flags from the controls.
|
// ============================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// Tweak these values to change gameplay behavior
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Default values if not specified in config
|
||||||
|
defaultRadius = 24.0
|
||||||
|
defaultSpeed = 180.0
|
||||||
|
defaultMaxStamina = 100.0
|
||||||
|
defaultStaminaDrain = 50.0 // Per second when sprinting
|
||||||
|
defaultStaminaRegen = 30.0 // Per second when not sprinting
|
||||||
|
|
||||||
|
// Sprint mechanics
|
||||||
|
sprintSpeedMultiplier = 2.0
|
||||||
|
sprintRecoveryThreshold = 0.2 // 20% stamina needed to sprint again
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
normalAnimSpeed = 0.15 // Seconds per frame when walking
|
||||||
|
sprintAnimSpeed = 0.08 // Seconds per frame when sprinting
|
||||||
|
|
||||||
|
// Visual state thresholds
|
||||||
|
exhaustedThreshold = 0.2 // Show exhausted state below 20% stamina
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
type Input struct {
|
type Input struct {
|
||||||
Left bool
|
Left bool
|
||||||
Right bool
|
Right bool
|
||||||
@@ -17,13 +44,11 @@ type Input struct {
|
|||||||
Sprint bool
|
Sprint bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playfield limits for movement.
|
|
||||||
type Bounds struct {
|
type Bounds struct {
|
||||||
Width float64
|
Width float64
|
||||||
Height float64
|
Height float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visual states for the hero.
|
|
||||||
type VisualState int
|
type VisualState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -32,23 +57,42 @@ const (
|
|||||||
StateExhausted
|
StateExhausted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Player avatar data.
|
type Direction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DirDown Direction = iota
|
||||||
|
DirUp
|
||||||
|
DirLeft
|
||||||
|
DirRight
|
||||||
|
)
|
||||||
|
|
||||||
type Hero struct {
|
type Hero struct {
|
||||||
X float64
|
// Position and size
|
||||||
Y float64
|
X float64
|
||||||
Radius float64
|
Y float64
|
||||||
Speed float64
|
Radius float64
|
||||||
Color color.NRGBA
|
|
||||||
Stamina float64
|
// Movement
|
||||||
MaxStamina float64
|
Speed float64
|
||||||
StaminaDrain float64
|
|
||||||
StaminaRegen float64
|
// Appearance
|
||||||
|
Color color.NRGBA
|
||||||
|
|
||||||
|
// Stamina system
|
||||||
|
Stamina float64
|
||||||
|
MaxStamina float64
|
||||||
|
StaminaDrain float64
|
||||||
|
StaminaRegen float64
|
||||||
|
|
||||||
|
// Internal state
|
||||||
canSprint bool
|
canSprint bool
|
||||||
wasSprintHeld bool
|
wasSprintHeld bool
|
||||||
isSprinting bool
|
isSprinting bool
|
||||||
|
direction Direction
|
||||||
|
animFrame int
|
||||||
|
animTimer float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn settings for the avatar.
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
StartX float64
|
StartX float64
|
||||||
StartY float64
|
StartY float64
|
||||||
@@ -60,25 +104,28 @@ type Config struct {
|
|||||||
StaminaRegen float64
|
StaminaRegen float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds an avatar from the config with fallbacks.
|
// ============================================================
|
||||||
|
// INITIALIZATION
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
func New(cfg Config) *Hero {
|
func New(cfg Config) *Hero {
|
||||||
if cfg.Radius <= 0 {
|
if cfg.Radius <= 0 {
|
||||||
cfg.Radius = 24
|
cfg.Radius = defaultRadius
|
||||||
}
|
}
|
||||||
if cfg.Speed <= 0 {
|
if cfg.Speed <= 0 {
|
||||||
cfg.Speed = 180
|
cfg.Speed = defaultSpeed
|
||||||
}
|
}
|
||||||
if cfg.Color.A == 0 {
|
if cfg.Color.A == 0 {
|
||||||
cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255}
|
cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255}
|
||||||
}
|
}
|
||||||
if cfg.MaxStamina <= 0 {
|
if cfg.MaxStamina <= 0 {
|
||||||
cfg.MaxStamina = 100
|
cfg.MaxStamina = defaultMaxStamina
|
||||||
}
|
}
|
||||||
if cfg.StaminaDrain <= 0 {
|
if cfg.StaminaDrain <= 0 {
|
||||||
cfg.StaminaDrain = 50
|
cfg.StaminaDrain = defaultStaminaDrain
|
||||||
}
|
}
|
||||||
if cfg.StaminaRegen <= 0 {
|
if cfg.StaminaRegen <= 0 {
|
||||||
cfg.StaminaRegen = 30
|
cfg.StaminaRegen = defaultStaminaRegen
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Hero{
|
return &Hero{
|
||||||
@@ -93,24 +140,40 @@ func New(cfg Config) *Hero {
|
|||||||
StaminaRegen: cfg.StaminaRegen,
|
StaminaRegen: cfg.StaminaRegen,
|
||||||
canSprint: true,
|
canSprint: true,
|
||||||
wasSprintHeld: false,
|
wasSprintHeld: false,
|
||||||
|
direction: DirDown,
|
||||||
|
animFrame: 0,
|
||||||
|
animTimer: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies movement input and clamps to the playfield.
|
// ============================================================
|
||||||
|
// UPDATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
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.updateStamina(input, dt)
|
||||||
|
h.updateAnimation(input, dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) {
|
||||||
dx, dy := 0.0, 0.0
|
dx, dy := 0.0, 0.0
|
||||||
|
|
||||||
if input.Left {
|
if input.Left {
|
||||||
dx -= 1
|
dx -= 1
|
||||||
|
h.direction = DirLeft
|
||||||
}
|
}
|
||||||
if input.Right {
|
if input.Right {
|
||||||
dx += 1
|
dx += 1
|
||||||
|
h.direction = DirRight
|
||||||
}
|
}
|
||||||
if input.Up {
|
if input.Up {
|
||||||
dy -= 1
|
dy -= 1
|
||||||
|
h.direction = DirUp
|
||||||
}
|
}
|
||||||
if input.Down {
|
if input.Down {
|
||||||
dy += 1
|
dy += 1
|
||||||
|
h.direction = DirDown
|
||||||
}
|
}
|
||||||
|
|
||||||
isMoving := dx != 0 || dy != 0
|
isMoving := dx != 0 || dy != 0
|
||||||
@@ -122,28 +185,9 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
speed := h.Speed
|
speed := h.Speed
|
||||||
|
|
||||||
if !input.Sprint {
|
|
||||||
h.wasSprintHeld = false
|
|
||||||
if h.Stamina >= h.MaxStamina*0.2 {
|
|
||||||
h.canSprint = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && isMoving
|
h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && isMoving
|
||||||
if h.isSprinting {
|
if h.isSprinting {
|
||||||
speed *= 2.0
|
speed *= sprintSpeedMultiplier
|
||||||
h.wasSprintHeld = true
|
|
||||||
h.Stamina -= h.StaminaDrain * dt
|
|
||||||
if h.Stamina <= 0 {
|
|
||||||
h.Stamina = 0
|
|
||||||
h.canSprint = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
h.Stamina += h.StaminaRegen * dt
|
|
||||||
if h.Stamina > h.MaxStamina {
|
|
||||||
h.Stamina = h.MaxStamina
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.X += dx * speed * dt
|
h.X += dx * speed * dt
|
||||||
@@ -156,9 +200,54 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
|
|||||||
h.Y = clamp(h.Y, h.Radius, maxY)
|
h.Y = clamp(h.Y, h.Radius, maxY)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the current visual state based on hero state.
|
func (h *Hero) updateStamina(input Input, dt float64) {
|
||||||
|
if !input.Sprint {
|
||||||
|
h.wasSprintHeld = false
|
||||||
|
if h.Stamina >= h.MaxStamina*sprintRecoveryThreshold {
|
||||||
|
h.canSprint = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.isSprinting {
|
||||||
|
h.wasSprintHeld = true
|
||||||
|
h.Stamina -= h.StaminaDrain * dt
|
||||||
|
if h.Stamina <= 0 {
|
||||||
|
h.Stamina = 0
|
||||||
|
h.canSprint = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.Stamina += h.StaminaRegen * dt
|
||||||
|
if h.Stamina > h.MaxStamina {
|
||||||
|
h.Stamina = h.MaxStamina
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hero) updateAnimation(input Input, dt float64) {
|
||||||
|
isMoving := input.Left || input.Right || input.Up || input.Down
|
||||||
|
|
||||||
|
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 {
|
||||||
|
h.animFrame = 0
|
||||||
|
h.animTimer = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
func (h *Hero) getVisualState() VisualState {
|
func (h *Hero) getVisualState() VisualState {
|
||||||
if h.Stamina < h.MaxStamina*0.2 {
|
if h.Stamina < h.MaxStamina*exhaustedThreshold {
|
||||||
return StateExhausted
|
return StateExhausted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,175 +258,70 @@ func (h *Hero) getVisualState() VisualState {
|
|||||||
return StateIdle
|
return StateIdle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders the avatar.
|
// ============================================================
|
||||||
|
// RENDERING
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
func (h *Hero) Draw(screen *ebiten.Image) {
|
func (h *Hero) Draw(screen *ebiten.Image) {
|
||||||
state := h.getVisualState()
|
sprite := h.getCurrentSprite()
|
||||||
|
|
||||||
vector.FillCircle(
|
if sprite != nil {
|
||||||
screen,
|
op := &ebiten.DrawImageOptions{}
|
||||||
float32(h.X),
|
bounds := sprite.Bounds()
|
||||||
float32(h.Y),
|
w, height := float64(bounds.Dx()), float64(bounds.Dy())
|
||||||
float32(h.Radius),
|
op.GeoM.Translate(-w/2, -height/2)
|
||||||
h.Color,
|
op.GeoM.Translate(h.X, h.Y)
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
eyeOffsetX := h.Radius * 0.3
|
state := h.getVisualState()
|
||||||
eyeOffsetY := h.Radius * 0.25
|
switch state {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
switch state {
|
screen.DrawImage(sprite, op)
|
||||||
case StateExhausted:
|
|
||||||
drawExhaustedFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY)
|
|
||||||
case StateSprinting:
|
|
||||||
drawSprintingFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY)
|
|
||||||
case StateIdle:
|
|
||||||
drawIdleFace(screen, h.X, h.Y, h.Radius, eyeOffsetX, eyeOffsetY)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawIdleFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
func (h *Hero) getCurrentSprite() *ebiten.Image {
|
||||||
eyeRadius := radius * 0.15
|
var sprite *ebiten.Image
|
||||||
|
|
||||||
vector.FillCircle(
|
switch h.direction {
|
||||||
screen,
|
case DirUp:
|
||||||
float32(x-eyeOffsetX),
|
if h.animFrame == 0 {
|
||||||
float32(y-eyeOffsetY),
|
sprite = spriteBack1
|
||||||
float32(eyeRadius),
|
} else {
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
sprite = spriteBack2
|
||||||
false,
|
}
|
||||||
)
|
case DirDown:
|
||||||
|
if h.animFrame == 0 {
|
||||||
vector.FillCircle(
|
sprite = spriteFront1
|
||||||
screen,
|
} else {
|
||||||
float32(x+eyeOffsetX),
|
sprite = spriteFront2
|
||||||
float32(y-eyeOffsetY),
|
}
|
||||||
float32(eyeRadius),
|
case DirLeft:
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
if h.animFrame == 0 {
|
||||||
false,
|
sprite = spriteLeft1
|
||||||
)
|
} else {
|
||||||
|
sprite = spriteLeft2
|
||||||
smileRadius := radius * 0.5
|
}
|
||||||
smileY := y + radius*0.15
|
case DirRight:
|
||||||
for angle := 0.3; angle <= 2.84; angle += 0.15 {
|
if h.animFrame == 0 {
|
||||||
smileX := x + smileRadius*math.Cos(angle)
|
sprite = spriteRight1
|
||||||
smileYPos := smileY + smileRadius*0.3*math.Sin(angle)
|
} else {
|
||||||
vector.FillCircle(
|
sprite = spriteRight2
|
||||||
screen,
|
|
||||||
float32(smileX),
|
|
||||||
float32(smileYPos),
|
|
||||||
float32(radius*0.08),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func drawSprintingFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
|
||||||
eyeWidth := radius * 0.2
|
|
||||||
eyeHeight := radius * 0.12
|
|
||||||
|
|
||||||
for ex := -eyeWidth; ex <= eyeWidth; ex += radius * 0.08 {
|
|
||||||
for ey := -eyeHeight; ey <= eyeHeight; ey += radius * 0.08 {
|
|
||||||
if ex*ex/(eyeWidth*eyeWidth)+ey*ey/(eyeHeight*eyeHeight) <= 1 {
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(x-eyeOffsetX+ex),
|
|
||||||
float32(y-eyeOffsetY+ey),
|
|
||||||
float32(radius*0.05),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for ex := -eyeWidth; ex <= eyeWidth; ex += radius * 0.08 {
|
return sprite
|
||||||
for ey := -eyeHeight; ey <= eyeHeight; ey += radius * 0.08 {
|
|
||||||
if ex*ex/(eyeWidth*eyeWidth)+ey*ey/(eyeHeight*eyeHeight) <= 1 {
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(x+eyeOffsetX+ex),
|
|
||||||
float32(y-eyeOffsetY+ey),
|
|
||||||
float32(radius*0.05),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mouthY := y + radius*0.3
|
|
||||||
mouthWidth := radius * 0.5
|
|
||||||
for mx := -mouthWidth; mx <= mouthWidth; mx += radius * 0.08 {
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(x+mx),
|
|
||||||
float32(mouthY),
|
|
||||||
float32(radius*0.06),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawExhaustedFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
// ============================================================
|
||||||
eyeSize := radius * 0.15
|
// UTILITIES
|
||||||
|
// ============================================================
|
||||||
for i := -eyeSize; i <= eyeSize; i += radius * 0.08 {
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(x-eyeOffsetX+i),
|
|
||||||
float32(y-eyeOffsetY+i),
|
|
||||||
float32(radius*0.05),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(x-eyeOffsetX+i),
|
|
||||||
float32(y-eyeOffsetY-i),
|
|
||||||
float32(radius*0.05),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := -eyeSize; i <= eyeSize; i += radius * 0.08 {
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(x+eyeOffsetX+i),
|
|
||||||
float32(y-eyeOffsetY+i),
|
|
||||||
float32(radius*0.05),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(x+eyeOffsetX+i),
|
|
||||||
float32(y-eyeOffsetY-i),
|
|
||||||
float32(radius*0.05),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
mouthY := y + radius*0.35
|
|
||||||
mouthWidth := radius * 0.2
|
|
||||||
mouthHeight := radius * 0.25
|
|
||||||
|
|
||||||
for angle := 0.0; angle < 2*math.Pi; angle += 0.3 {
|
|
||||||
mx := x + mouthWidth*math.Cos(angle)
|
|
||||||
my := mouthY + mouthHeight*math.Sin(angle)
|
|
||||||
vector.FillCircle(
|
|
||||||
screen,
|
|
||||||
float32(mx),
|
|
||||||
float32(my),
|
|
||||||
float32(radius*0.06),
|
|
||||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(value, min, max float64) float64 {
|
func clamp(value, min, max float64) float64 {
|
||||||
if value < min {
|
if value < min {
|
||||||
|
|||||||
65
internal/hero/sprites.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package hero
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
_ "image/gif"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSprite(path string) *ebiten.Image {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
panic(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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ebiten.NewImageFromImage(rgba)
|
||||||
|
}
|
||||||