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"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// CONFIGURATION
|
||||
// Tweak these values to change game settings
|
||||
// ============================================================
|
||||
|
||||
const (
|
||||
ScreenWidth = 960
|
||||
ScreenHeight = 540
|
||||
|
||||
TargetTPS = 60
|
||||
WindowTitle = "Big Feelings"
|
||||
TargetTPS = 60
|
||||
WindowTitle = "Big Feelings"
|
||||
)
|
||||
|
||||
var (
|
||||
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
|
||||
|
||||
const (
|
||||
@@ -40,63 +79,10 @@ type controls struct {
|
||||
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 {
|
||||
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 {
|
||||
hero *hero.Hero
|
||||
hud hud.Overlay
|
||||
@@ -106,22 +92,30 @@ type state struct {
|
||||
gameState gameState
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================
|
||||
|
||||
func New() *Game {
|
||||
return &Game{state: newState()}
|
||||
}
|
||||
|
||||
func newState() *state {
|
||||
now := time.Now()
|
||||
return &state{
|
||||
hero: hero.New(hero.Config{
|
||||
StartX: ScreenWidth / 2,
|
||||
StartY: ScreenHeight / 2,
|
||||
Radius: 28,
|
||||
Speed: 180,
|
||||
Color: color.NRGBA{R: 210, G: 220, B: 255, A: 255},
|
||||
MaxStamina: 100,
|
||||
StaminaDrain: 50,
|
||||
StaminaRegen: 30,
|
||||
StartX: heroStartX,
|
||||
StartY: heroStartY,
|
||||
Radius: heroRadius,
|
||||
Speed: heroSpeed,
|
||||
Color: heroColor,
|
||||
MaxStamina: heroMaxStamina,
|
||||
StaminaDrain: heroStaminaDrain,
|
||||
StaminaRegen: heroStaminaRegen,
|
||||
}),
|
||||
hud: hud.Overlay{
|
||||
X: ScreenWidth - 220,
|
||||
Y: 20,
|
||||
X: hudX,
|
||||
Y: hudY,
|
||||
Color: color.White,
|
||||
},
|
||||
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) {
|
||||
now := time.Now()
|
||||
dt := now.Sub(s.lastTick).Seconds()
|
||||
@@ -148,14 +188,21 @@ func (s *state) update(input controls) {
|
||||
}, dt, s.bounds)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDERING
|
||||
// ============================================================
|
||||
|
||||
func (g *Game) Draw(screen *ebiten.Image) {
|
||||
g.state.draw(screen)
|
||||
}
|
||||
|
||||
func (s *state) draw(screen *ebiten.Image) {
|
||||
screen.Fill(backgroundColor)
|
||||
s.hero.Draw(screen)
|
||||
|
||||
// Create stamina meter from hero's stamina
|
||||
staminaColor := color.NRGBA{R: 0, G: 255, B: 180, A: 255}
|
||||
if s.hero.Stamina < s.hero.MaxStamina*0.2 {
|
||||
staminaColor = color.NRGBA{R: 255, G: 60, B: 60, A: 255}
|
||||
staminaColor := staminaNormalColor
|
||||
if s.hero.Stamina < s.hero.MaxStamina*staminaLowThreshold {
|
||||
staminaColor = staminaLowColor
|
||||
}
|
||||
|
||||
staminaMeter := status.Meter{
|
||||
@@ -166,8 +213,11 @@ func (s *state) draw(screen *ebiten.Image) {
|
||||
}
|
||||
s.hud.Draw(screen, []status.Meter{staminaMeter})
|
||||
|
||||
// Draw pause menu if paused
|
||||
if s.gameState == statePaused {
|
||||
s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
||||
return ScreenWidth, ScreenHeight
|
||||
}
|
||||
|
||||
@@ -5,10 +5,37 @@ import (
|
||||
"math"
|
||||
|
||||
"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 {
|
||||
Left bool
|
||||
Right bool
|
||||
@@ -17,13 +44,11 @@ type Input struct {
|
||||
Sprint bool
|
||||
}
|
||||
|
||||
// Playfield limits for movement.
|
||||
type Bounds struct {
|
||||
Width float64
|
||||
Height float64
|
||||
}
|
||||
|
||||
// Visual states for the hero.
|
||||
type VisualState int
|
||||
|
||||
const (
|
||||
@@ -32,23 +57,42 @@ const (
|
||||
StateExhausted
|
||||
)
|
||||
|
||||
// Player avatar data.
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
DirDown Direction = iota
|
||||
DirUp
|
||||
DirLeft
|
||||
DirRight
|
||||
)
|
||||
|
||||
type Hero struct {
|
||||
X float64
|
||||
Y float64
|
||||
Radius float64
|
||||
Speed float64
|
||||
Color color.NRGBA
|
||||
Stamina float64
|
||||
MaxStamina float64
|
||||
StaminaDrain float64
|
||||
StaminaRegen float64
|
||||
// Position and size
|
||||
X float64
|
||||
Y float64
|
||||
Radius float64
|
||||
|
||||
// Movement
|
||||
Speed float64
|
||||
|
||||
// Appearance
|
||||
Color color.NRGBA
|
||||
|
||||
// Stamina system
|
||||
Stamina float64
|
||||
MaxStamina float64
|
||||
StaminaDrain float64
|
||||
StaminaRegen float64
|
||||
|
||||
// Internal state
|
||||
canSprint bool
|
||||
wasSprintHeld bool
|
||||
isSprinting bool
|
||||
direction Direction
|
||||
animFrame int
|
||||
animTimer float64
|
||||
}
|
||||
|
||||
// Spawn settings for the avatar.
|
||||
type Config struct {
|
||||
StartX float64
|
||||
StartY float64
|
||||
@@ -60,25 +104,28 @@ type Config struct {
|
||||
StaminaRegen float64
|
||||
}
|
||||
|
||||
// Builds an avatar from the config with fallbacks.
|
||||
// ============================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================
|
||||
|
||||
func New(cfg Config) *Hero {
|
||||
if cfg.Radius <= 0 {
|
||||
cfg.Radius = 24
|
||||
cfg.Radius = defaultRadius
|
||||
}
|
||||
if cfg.Speed <= 0 {
|
||||
cfg.Speed = 180
|
||||
cfg.Speed = defaultSpeed
|
||||
}
|
||||
if cfg.Color.A == 0 {
|
||||
cfg.Color = color.NRGBA{R: 210, G: 220, B: 255, A: 255}
|
||||
}
|
||||
if cfg.MaxStamina <= 0 {
|
||||
cfg.MaxStamina = 100
|
||||
cfg.MaxStamina = defaultMaxStamina
|
||||
}
|
||||
if cfg.StaminaDrain <= 0 {
|
||||
cfg.StaminaDrain = 50
|
||||
cfg.StaminaDrain = defaultStaminaDrain
|
||||
}
|
||||
if cfg.StaminaRegen <= 0 {
|
||||
cfg.StaminaRegen = 30
|
||||
cfg.StaminaRegen = defaultStaminaRegen
|
||||
}
|
||||
|
||||
return &Hero{
|
||||
@@ -93,24 +140,40 @@ func New(cfg Config) *Hero {
|
||||
StaminaRegen: cfg.StaminaRegen,
|
||||
canSprint: true,
|
||||
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) {
|
||||
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
|
||||
|
||||
if input.Left {
|
||||
dx -= 1
|
||||
h.direction = DirLeft
|
||||
}
|
||||
if input.Right {
|
||||
dx += 1
|
||||
h.direction = DirRight
|
||||
}
|
||||
if input.Up {
|
||||
dy -= 1
|
||||
h.direction = DirUp
|
||||
}
|
||||
if input.Down {
|
||||
dy += 1
|
||||
h.direction = DirDown
|
||||
}
|
||||
|
||||
isMoving := dx != 0 || dy != 0
|
||||
@@ -122,28 +185,9 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
|
||||
}
|
||||
|
||||
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
|
||||
if h.isSprinting {
|
||||
speed *= 2.0
|
||||
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
|
||||
}
|
||||
speed *= sprintSpeedMultiplier
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if h.Stamina < h.MaxStamina*0.2 {
|
||||
if h.Stamina < h.MaxStamina*exhaustedThreshold {
|
||||
return StateExhausted
|
||||
}
|
||||
|
||||
@@ -169,175 +258,70 @@ func (h *Hero) getVisualState() VisualState {
|
||||
return StateIdle
|
||||
}
|
||||
|
||||
// Renders the avatar.
|
||||
// ============================================================
|
||||
// RENDERING
|
||||
// ============================================================
|
||||
|
||||
func (h *Hero) Draw(screen *ebiten.Image) {
|
||||
state := h.getVisualState()
|
||||
sprite := h.getCurrentSprite()
|
||||
|
||||
vector.FillCircle(
|
||||
screen,
|
||||
float32(h.X),
|
||||
float32(h.Y),
|
||||
float32(h.Radius),
|
||||
h.Color,
|
||||
false,
|
||||
)
|
||||
if sprite != nil {
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
bounds := sprite.Bounds()
|
||||
w, height := float64(bounds.Dx()), float64(bounds.Dy())
|
||||
op.GeoM.Translate(-w/2, -height/2)
|
||||
op.GeoM.Translate(h.X, h.Y)
|
||||
|
||||
eyeOffsetX := h.Radius * 0.3
|
||||
eyeOffsetY := h.Radius * 0.25
|
||||
state := h.getVisualState()
|
||||
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 {
|
||||
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)
|
||||
screen.DrawImage(sprite, op)
|
||||
}
|
||||
}
|
||||
|
||||
func drawIdleFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
||||
eyeRadius := radius * 0.15
|
||||
func (h *Hero) getCurrentSprite() *ebiten.Image {
|
||||
var sprite *ebiten.Image
|
||||
|
||||
vector.FillCircle(
|
||||
screen,
|
||||
float32(x-eyeOffsetX),
|
||||
float32(y-eyeOffsetY),
|
||||
float32(eyeRadius),
|
||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
||||
false,
|
||||
)
|
||||
|
||||
vector.FillCircle(
|
||||
screen,
|
||||
float32(x+eyeOffsetX),
|
||||
float32(y-eyeOffsetY),
|
||||
float32(eyeRadius),
|
||||
color.NRGBA{R: 0, G: 0, B: 0, A: 255},
|
||||
false,
|
||||
)
|
||||
|
||||
smileRadius := radius * 0.5
|
||||
smileY := y + radius*0.15
|
||||
for angle := 0.3; angle <= 2.84; angle += 0.15 {
|
||||
smileX := x + smileRadius*math.Cos(angle)
|
||||
smileYPos := smileY + smileRadius*0.3*math.Sin(angle)
|
||||
vector.FillCircle(
|
||||
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,
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
return sprite
|
||||
}
|
||||
|
||||
func drawExhaustedFace(screen *ebiten.Image, x, y, radius, eyeOffsetX, eyeOffsetY float64) {
|
||||
eyeSize := radius * 0.15
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// UTILITIES
|
||||
// ============================================================
|
||||
|
||||
func clamp(value, min, max float64) float64 {
|
||||
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)
|
||||
}
|
||||