321 lines
6.4 KiB
Go
321 lines
6.4 KiB
Go
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"image/color"
|
|
"time"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
|
|
|
"github.com/atridad/BigFeelings/internal/hero"
|
|
"github.com/atridad/BigFeelings/internal/status"
|
|
"github.com/atridad/BigFeelings/internal/ui/hud"
|
|
"github.com/atridad/BigFeelings/internal/ui/menu"
|
|
)
|
|
|
|
// Game settings.
|
|
|
|
const (
|
|
ScreenWidth = 960
|
|
ScreenHeight = 540
|
|
TargetTPS = 60
|
|
WindowTitle = "Big Feelings"
|
|
)
|
|
|
|
// FPS cap options.
|
|
type FPSCap int
|
|
|
|
const (
|
|
FPSCap60 FPSCap = iota
|
|
FPSCap120
|
|
FPSCapUncapped
|
|
fpsCapCount
|
|
)
|
|
|
|
func (f FPSCap) TPS() int {
|
|
switch f {
|
|
case FPSCap60:
|
|
return 60
|
|
case FPSCap120:
|
|
return 120
|
|
case FPSCapUncapped:
|
|
return -1
|
|
default:
|
|
return 60
|
|
}
|
|
}
|
|
|
|
func (f FPSCap) String() string {
|
|
switch f {
|
|
case FPSCap60:
|
|
return "60 FPS"
|
|
case FPSCap120:
|
|
return "120 FPS"
|
|
case FPSCapUncapped:
|
|
return "Uncapped"
|
|
default:
|
|
return "60 FPS"
|
|
}
|
|
}
|
|
|
|
func (f *FPSCap) Cycle() {
|
|
*f = (*f + 1) % fpsCapCount
|
|
}
|
|
|
|
var (
|
|
backgroundColor = color.NRGBA{R: 0, G: 0, B: 0, A: 255}
|
|
)
|
|
|
|
// Hero settings.
|
|
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 settings.
|
|
const (
|
|
hudX = ScreenWidth - 220
|
|
hudY = 20
|
|
)
|
|
|
|
// HUD colors.
|
|
var (
|
|
staminaNormalColor = color.NRGBA{R: 0, G: 255, B: 180, A: 255}
|
|
staminaLowColor = color.NRGBA{R: 255, G: 60, B: 60, A: 255}
|
|
fpsGoodColor = color.NRGBA{R: 120, G: 255, B: 120, A: 255}
|
|
fpsWarnColor = color.NRGBA{R: 255, G: 210, B: 100, A: 255}
|
|
fpsPoorColor = color.NRGBA{R: 255, G: 120, B: 120, A: 255}
|
|
)
|
|
|
|
const (
|
|
staminaLowThreshold = 0.2
|
|
fpsWarnThreshold = 0.85
|
|
fpsPoorThreshold = 0.6
|
|
fpsSampleWindow = time.Second
|
|
)
|
|
|
|
type gameState int
|
|
|
|
const (
|
|
statePlaying gameState = iota
|
|
statePaused
|
|
)
|
|
|
|
type controls struct {
|
|
Left bool
|
|
Right bool
|
|
Up bool
|
|
Down bool
|
|
Sprint bool
|
|
}
|
|
|
|
type Game struct {
|
|
state *state
|
|
}
|
|
|
|
type state struct {
|
|
hero *hero.Hero
|
|
hud hud.Overlay
|
|
bounds hero.Bounds
|
|
lastTick time.Time
|
|
pauseMenu *menu.PauseMenu
|
|
gameState gameState
|
|
fpsEnabled bool
|
|
fpsFrames int
|
|
fpsAccumulator time.Duration
|
|
fpsValue float64
|
|
fpsCap FPSCap
|
|
}
|
|
|
|
func New() *Game {
|
|
return &Game{state: newState()}
|
|
}
|
|
|
|
func newState() *state {
|
|
now := time.Now()
|
|
s := &state{
|
|
hero: hero.New(hero.Config{
|
|
StartX: heroStartX,
|
|
StartY: heroStartY,
|
|
Radius: heroRadius,
|
|
Speed: heroSpeed,
|
|
Color: heroColor,
|
|
MaxStamina: heroMaxStamina,
|
|
StaminaDrain: heroStaminaDrain,
|
|
StaminaRegen: heroStaminaRegen,
|
|
}),
|
|
hud: hud.Overlay{
|
|
X: hudX,
|
|
Y: hudY,
|
|
Color: color.White,
|
|
},
|
|
bounds: hero.Bounds{
|
|
Width: ScreenWidth,
|
|
Height: ScreenHeight,
|
|
},
|
|
lastTick: now,
|
|
pauseMenu: menu.NewPauseMenu(),
|
|
gameState: statePlaying,
|
|
fpsEnabled: false,
|
|
fpsCap: FPSCap60,
|
|
}
|
|
s.pauseMenu.SetFPSMonitor(&s.fpsEnabled)
|
|
s.pauseMenu.SetFPSCap(&s.fpsCap)
|
|
ebiten.SetTPS(s.fpsCap.TPS())
|
|
return s
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
func (g *Game) Update() error {
|
|
// Update TPS if FPS cap changed.
|
|
currentTPS := g.state.fpsCap.TPS()
|
|
if currentTPS < 0 {
|
|
ebiten.SetTPS(ebiten.SyncWithFPS)
|
|
} else {
|
|
ebiten.SetTPS(currentTPS)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
// Track FPS.
|
|
now := time.Now()
|
|
if !g.state.lastTick.IsZero() {
|
|
g.state.trackFPS(now.Sub(g.state.lastTick))
|
|
}
|
|
|
|
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()
|
|
s.lastTick = now
|
|
|
|
s.hero.Update(hero.Input{
|
|
Left: input.Left,
|
|
Right: input.Right,
|
|
Up: input.Up,
|
|
Down: input.Down,
|
|
Sprint: input.Sprint,
|
|
}, dt, s.bounds)
|
|
}
|
|
|
|
func (s *state) trackFPS(delta time.Duration) {
|
|
if !s.fpsEnabled {
|
|
return
|
|
}
|
|
|
|
s.fpsAccumulator += delta
|
|
s.fpsFrames++
|
|
|
|
if s.fpsAccumulator >= fpsSampleWindow {
|
|
s.fpsValue = float64(s.fpsFrames) / s.fpsAccumulator.Seconds()
|
|
s.fpsAccumulator = 0
|
|
s.fpsFrames = 0
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
staminaColor := staminaNormalColor
|
|
if s.hero.Stamina < s.hero.MaxStamina*staminaLowThreshold {
|
|
staminaColor = staminaLowColor
|
|
}
|
|
|
|
staminaMeter := status.Meter{
|
|
Label: "Stamina",
|
|
Base: s.hero.MaxStamina,
|
|
Level: s.hero.Stamina,
|
|
Color: staminaColor,
|
|
}
|
|
|
|
meters := []status.Meter{staminaMeter}
|
|
|
|
if s.fpsEnabled {
|
|
// Color based on target FPS (60).
|
|
ratio := s.fpsValue / float64(TargetTPS)
|
|
fpsColor := fpsGoodColor
|
|
switch {
|
|
case ratio < fpsPoorThreshold:
|
|
fpsColor = fpsPoorColor
|
|
case ratio < fpsWarnThreshold:
|
|
fpsColor = fpsWarnColor
|
|
}
|
|
|
|
fpsMeter := status.Meter{
|
|
Label: fmt.Sprintf("Framerate: %3.0f FPS", s.fpsValue),
|
|
Base: -1, // Negative base means text-only display.
|
|
Level: 0,
|
|
Color: fpsColor,
|
|
}
|
|
meters = append(meters, fpsMeter)
|
|
}
|
|
|
|
s.hud.Draw(screen, meters)
|
|
|
|
if s.gameState == statePaused {
|
|
s.pauseMenu.Draw(screen, ScreenWidth, ScreenHeight)
|
|
}
|
|
}
|
|
|
|
func clampFloat(value, min, max float64) float64 {
|
|
if value < min {
|
|
return min
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
|
return ScreenWidth, ScreenHeight
|
|
}
|