From 8eb5909919c429b84bd4ccd0490c49690d33e67f Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Wed, 19 Nov 2025 09:08:25 -0700 Subject: [PATCH] Using a temp free online sprite for now to test sprites --- assets/hero/avt1_bk1.gif | Bin 0 -> 1146 bytes assets/hero/avt1_bk2.gif | Bin 0 -> 1156 bytes assets/hero/avt1_fr1.gif | Bin 0 -> 1191 bytes assets/hero/avt1_fr2.gif | Bin 0 -> 1206 bytes assets/hero/avt1_lf1.gif | Bin 0 -> 1097 bytes assets/hero/avt1_lf2.gif | Bin 0 -> 1116 bytes assets/hero/avt1_rt1.gif | Bin 0 -> 1107 bytes assets/hero/avt1_rt2.gif | Bin 0 -> 1130 bytes internal/game/game.go | 192 ++++++++++++-------- internal/hero/hero.go | 384 +++++++++++++++++++-------------------- internal/hero/sprites.go | 65 +++++++ 11 files changed, 370 insertions(+), 271 deletions(-) create mode 100644 assets/hero/avt1_bk1.gif create mode 100644 assets/hero/avt1_bk2.gif create mode 100644 assets/hero/avt1_fr1.gif create mode 100644 assets/hero/avt1_fr2.gif create mode 100644 assets/hero/avt1_lf1.gif create mode 100644 assets/hero/avt1_lf2.gif create mode 100644 assets/hero/avt1_rt1.gif create mode 100644 assets/hero/avt1_rt2.gif create mode 100644 internal/hero/sprites.go diff --git a/assets/hero/avt1_bk1.gif b/assets/hero/avt1_bk1.gif new file mode 100644 index 0000000000000000000000000000000000000000..76b75bc9951b1a1f3cdcdfd6de1850c97c1ca79c GIT binary patch literal 1146 zcmZ{ie`u9e7{|YNt?M<^_|P8PygrkQ5>bl&S9LTu-cbIuIDNb2IT9T(IU*K&p2F@V_q=Zt?8m3Jmi-?)li3E`(Qbax< zwqY?i984f4!4;AkEpINobNDseFM)IZzF>0h@u;4FQWRM$F7bkdP!5DSe%{ zLtqQQBM=s3IaCGoGEj|0oY4kOTLh&Fwg)wN{yhIDygPnf*doa&%}EK$O45ovP5wM# zS+feyZfp2Cge^%dOELeCwS z*$10}ac}`4Ar+K{o;xnXhZ!smM}SGh6u3r`Ok#5g)YyoXgI6H23>zxp*5DHSJ--ZG z0saQA0{;O20@n(A=$M5z%0@bL4fltvSju+$u%KjJE^gPMK~@)kv!vtjyXcx=y_31x z_aYCqccsq<-LfZ#dJm2aeE97^@P$)eU}QLYue1J;)AsbyBmIlIH(mU_XR7c0RXwH0 znog+t!KNJ><%54_#)jkjH@4n#;q38GpIUXBv-;$ju>*eb`|c>+de52ZiJ#7meX#q9 z=Y#V{dO8P70+W-^emQfvf1tT+?Ze~K?LQ1WI`i1AnIiPA=ssR@u4LuZ&Q#TN!99_w zgbxY>0c7ZzF2;x>lJUbyZNm(J3>GH*0{W7OK9D`iEST0 zv#0a3;n$}79_ZNnbK8ZrdtdLo`>t*6$(7%|ysP%ee!cjO+fTkZ^lEZ_>z#E6Ixg8Q d-rz;=?aQa*+h1%NuP@!YdAxB+eIS6x{{#Hj4(0#= literal 0 HcmV?d00001 diff --git a/assets/hero/avt1_bk2.gif b/assets/hero/avt1_bk2.gif new file mode 100644 index 0000000000000000000000000000000000000000..edb072b8e28e4eca597f21ecb066c9bdb9c2a89c GIT binary patch literal 1156 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 literal 0 HcmV?d00001 diff --git a/assets/hero/avt1_lf1.gif b/assets/hero/avt1_lf1.gif new file mode 100644 index 0000000000000000000000000000000000000000..159dc4db37d88c8f6720a37a14c1cb78cc359746 GIT binary patch literal 1097 zcmZ{iU1*d?6o%g!M)Jn z#RjPw9Lj1`7H`~Q3uDrjRyQC)XvL8RT4Zx0rl_nx6}lBv(E9IjzV$+oWG-gr;^Ax(M@B~S`Ml*FfZIBN1QehF1K29W7;)}ZmXeert;raV_k%MC z7m`Copao0}lmsb8X--;DR+3Sa)08jr4mgJpkP=EkYZwFDCJ`gzwsj&&q=+<;$NL>H z4p)FnL==QZ%pgfn#L~nyi7ZJ&Dn~kBEGa`eN5(5}lNmp;cX4HkaarCQC;8#nhFk~^8G#beF_?+tvisn0 zZ~-A96_kcHFcZgRc|Qjea3#1xL_-+F$R;*{K%;|5Ekqrn4Tyh&WF!8Ex93lRXTWpd z1@IDh1-vfqp=%e~DqCsaYt;By#Gkyd&o{NcRp&DO2R3}m7OfgR_}Ts$Q%W1=PQQ7f zFN9dkmkC}9qU-9p?%SEl!@-KGU*ox3mDRla#_kW#X4V{A(0u0n>Hdz5z4N{-^)1W( zyCEI!A6Q>F`opNMZyWxxXQ*QnR_xBNA4?RPzn{FT@XL4m?p^-5`C{QgYRkab*~qTh zT@{JKr9+EKo~A02_rLgURtfJtF&b@|z4G3bqcaY7ZyVa*b|SRLKls&8Rojp3_;2&0 za9`bv&c}Z@FD~?*I=S}KGrX>Fa{kxhe|*0$?HC+)5HvXGgZh=gt{g_+;nU-r$bZ&2P%WEu)FVor53DZ11_&v--|(#nPJ2 XwxB#$UVWuz>(=%^T6;Rkp2sOoD61gE5+k0hmwwx`19BkVMdBmMv#`|D>9n$hJty5 zv1x^;x3&BX!b}m*kkBN3QigP%OiUXkBWQf*@d>sRyyvWHGh4pDYF@>O?>_YF zoJ|`_b6ukyv7_k!qa&IBpIr9t)s@@Y%ksTdo!3~T^y<>ams= rsra_*-1vo+zqeiAe{s`#8d%TX3(e0g6z03W!WJ<$u_#hff|nlyu>JlH literal 0 HcmV?d00001 diff --git a/assets/hero/avt1_rt1.gif b/assets/hero/avt1_rt1.gif new file mode 100644 index 0000000000000000000000000000000000000000..88ff789a2241b5601f43b8afb86cec8de97646a1 GIT binary patch literal 1107 zcmZ{iU1*d?6o%hw(yY+Rrdd-pWAn9LNo>B92qKd!R#mrnh z9L{sz^I`M@-q_~A!995A1z-v=6(|8pffs=?zz0kN1PMXpUB$ij1b5A#a{w zY(n9QZ7n~EFjK@+Bs9qkDMLC(#w%}=nK-d$aixoKS>78b`Qg}xTnG*sffCT|Fq6k+ z_rc-d0zyJ6C=G33CXdVVehwz!N^pgUhA?O+o7f}*AqSBlL@iny(6IoC5bna;^D*E) zFb+Hb9s-Ym$HhH#?Lu2+EA4xXY9EWloWxFFMP*Ai=U_V2%*L%|&yt$5s*NR~Yi-TD z0)5D3{fWL_f7fN+6Mh4G-;BPpps=F%x2p2;*@fC|>1hAFu6)y!(DmrP_g=m-R5+?* z{d4**el%VY`r~-r@{>&m#k|<@n({3JS60<6J+!drcEiZ(LUcE}FQ4wLXd5Y8fB53L zcRs2A=bKr>M=lLcef{5`=g!~TG*%gJIk(7v;Ka=TH-B8c@$aQ?r%qpAy!(s7U-g6U zuWLTv5#L%rxcaSds(Ro4wV#gP+LUtlY=6{w=tBG$AGmQfUK#xL=-H9iE)BI@Sa$5| z-qGO)XU5kbEL%RBuIs+jee=wz#;%f~hu>$vn=^bM`0Zz7J-eF!{9XN&`fp|b56gb3 lS^4?x?e}-It$TIDy%`G+cUHw`jP6ihZCJaew6p~8J^}j}`NseN literal 0 HcmV?d00001 diff --git a/assets/hero/avt1_rt2.gif b/assets/hero/avt1_rt2.gif new file mode 100644 index 0000000000000000000000000000000000000000..3e63bf9cf748d7ce48a9e876c374ea5565588e04 GIT binary patch literal 1130 zcmZ{iU1*d?6o%i%nyg^znyhVFM)RR-6Wc}7lImz;*_Nf*Skr9DXo48nv>~W1cG4dW z^=~mkR3^IBC@UDdkzhx9p%+UrBt$3!UD6*G18OYlwh*MYfu@LB|4!#yFNBiJ#mrnh z9L{szQ&am6U(w>g!CkoL86Xds0Xz%L1oD9bzyr(z1PMXpUb)A9E${RB9LEU+0-W2x-mwY&pxTL+MU0yJO%TZI@S&fUsVl2W8K8RPO^ za0U^8hK!8hx6@*5sL5!e?C5UShDUya%hIBT^!w*ygO~B_s{HXvV#);dxNRo<_CT*VZ zjt6`;gb$KI`Ju~UDnLO-g40GpJ0!((wnK(8?mYh|d@S-rn33X?5Tqrg6d6q!L+(7m z*p$Lk+gg4aVWx;DNNAENQigPfj9cC&Gj(E5<4We@vb;M^^1`tV1t2(N7)n65!b~5R z-3Nz*3kV6Rpft3BnLaMddpVduK!PiTHH1Mc*~F$12s#M+A<7Z0z{X0%g17^B&+h^M z0{4Ljz(e2>@Hn@J0lUyv*-CpJqtwH~5hvE^DSm!KI^$rGx0|i_z5mpa&cYY+D#D=; z-rif>{qaxSi>}UP-s|UE7LCu{hnElfHx-Q+&gbpdJN9KfUmY*3JDF;4xH*zQ(`jGr zKX26`)VO-p{e~r(WLe*PN3Rxb{;{X7_seqwZ!G@)Y~zc0C4YXgdG(3vj*hx-C;LZ_ zy?Wzn`pk@$roQqE!_9-smo|6TPi~#qUUBocJq2UmHO~&#Y$!eS?g94Tb=g?jKHj$V zz~BA#3u}gk$E#j?CEHolF!;-?JD>dAqsvAfrhD2(`mplWrHS=zqy6>!%ey!mC?2ca z_9-8z_D}ZqZo9bS+H1=uhtCW~wov%w&^?7pec9Ww_{Pw#A9n9;T=3zQ!K&r?cbAU- zwX>^a;+tPTD|mGK>xNr@l(F`i2lL0)%&GMs{^+?kn=aOb)|Om*fBmMCp6HUkdFz5e F)qky^0wDkZ literal 0 HcmV?d00001 diff --git a/internal/game/game.go b/internal/game/game.go index c76baee..745ce56 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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 +} diff --git a/internal/hero/hero.go b/internal/hero/hero.go index e82390b..dda8190 100644 --- a/internal/hero/hero.go +++ b/internal/hero/hero.go @@ -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 { diff --git a/internal/hero/sprites.go b/internal/hero/sprites.go new file mode 100644 index 0000000..d572bd6 --- /dev/null +++ b/internal/hero/sprites.go @@ -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) +}