Consolodated config, added portals, etc.

This commit is contained in:
2025-11-24 15:03:56 -07:00
parent f39684873b
commit 57d08f2f04
15 changed files with 895 additions and 212 deletions

80
internal/config/config.go Normal file
View File

@@ -0,0 +1,80 @@
package config
import "image/color"
const (
ScreenWidth = 960
ScreenHeight = 540
)
type GameConfig struct {
Screen ScreenConfig
Hero HeroConfig
HUD HUDConfig
Visual VisualConfig
}
type ScreenConfig struct {
Width int
Height int
}
type HeroConfig struct {
StartX float64
StartY float64
Radius float64
Speed float64
MaxStamina float64
StaminaDrain float64
StaminaRegen float64
Color color.NRGBA
}
type HUDConfig struct {
X int
Y int
Margin int
}
type VisualConfig struct {
BackgroundColor color.NRGBA
SaveNotificationColor color.NRGBA
StaminaNormalColor color.NRGBA
StaminaLowColor color.NRGBA
FPSGoodColor color.NRGBA
FPSWarnColor color.NRGBA
FPSPoorColor color.NRGBA
}
func Default() GameConfig {
return GameConfig{
Screen: ScreenConfig{
Width: ScreenWidth,
Height: ScreenHeight,
},
Hero: HeroConfig{
StartX: ScreenWidth / 2,
StartY: ScreenHeight / 2,
Radius: 28.0,
Speed: 180.0,
MaxStamina: 100.0,
StaminaDrain: 50.0,
StaminaRegen: 30.0,
Color: color.NRGBA{R: 210, G: 220, B: 255, A: 255},
},
HUD: HUDConfig{
X: ScreenWidth - 220,
Y: 20,
Margin: 16,
},
Visual: VisualConfig{
BackgroundColor: color.NRGBA{R: 135, G: 206, B: 235, A: 255},
SaveNotificationColor: color.NRGBA{R: 50, G: 200, B: 50, A: 255},
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},
},
}
}

View File

@@ -0,0 +1,60 @@
package config
import "time"
// Animation speeds
const (
NormalAnimSpeed = 0.03
IdleAnimSpeed = 0.1
SprintAnimSpeed = 0.01
JumpingAnimSpeed = 0.005
)
// Physics
const (
Gravity = 1200.0
JumpStrength = -450.0
MaxFallSpeed = 800.0
GroundFriction = 0.85
AirFriction = 0.95
)
// Gameplay
const (
SprintSpeedMultiplier = 1.8
SprintRecoveryThreshold = 0.2
ExhaustedThreshold = 0.2
StaminaLowThreshold = 0.2
)
// FPS Monitoring
const (
FPSWarnThreshold = 0.85
FPSPoorThreshold = 0.6
FPSSampleWindow = time.Second
TargetTPS = 60
)
// Sprite
const (
AnimFrameWrap = 4096
HeroSpriteScale = 0.175
FixedSpriteHeight = 329.0
FixedSpriteWidth = 315.0
)
// Notifications
const (
SaveNotificationDuration = 2 * time.Second
)
// Portal
const (
PortalThickness = 20.0
PortalTransitionCooldown = 0.5
)
// World
const (
GroundHeight = 16.0
)

View File

@@ -0,0 +1,78 @@
package config
import (
"image/color"
)
type MapConfig struct {
ID string
Number int
GroundColor color.NRGBA
BackgroundColor color.NRGBA
}
func DefaultMap1() MapConfig {
return MapConfig{
ID: "map1",
Number: 1,
GroundColor: color.NRGBA{R: 34, G: 139, B: 34, A: 255},
BackgroundColor: color.NRGBA{R: 135, G: 206, B: 235, A: 255},
}
}
func DefaultMap2() MapConfig {
return MapConfig{
ID: "map2",
Number: 2,
GroundColor: color.NRGBA{R: 139, G: 69, B: 19, A: 255},
BackgroundColor: color.NRGBA{R: 155, G: 196, B: 215, A: 255},
}
}
type PortalConfig struct {
ID string
DestinationMap string
DestinationPortal string
Color color.NRGBA
GlowColor color.NRGBA
}
func LeftPortalMap1() PortalConfig {
return PortalConfig{
ID: "map1_left",
DestinationMap: "map2",
DestinationPortal: "map2_right",
Color: color.NRGBA{R: 255, G: 100, B: 100, A: 180},
GlowColor: color.NRGBA{R: 255, G: 150, B: 150, A: 100},
}
}
func RightPortalMap1() PortalConfig {
return PortalConfig{
ID: "map1_right",
DestinationMap: "map2",
DestinationPortal: "map2_left",
Color: color.NRGBA{R: 100, G: 255, B: 100, A: 180},
GlowColor: color.NRGBA{R: 150, G: 255, B: 150, A: 100},
}
}
func LeftPortalMap2() PortalConfig {
return PortalConfig{
ID: "map2_left",
DestinationMap: "map1",
DestinationPortal: "map1_right",
Color: color.NRGBA{R: 100, G: 255, B: 100, A: 180},
GlowColor: color.NRGBA{R: 150, G: 255, B: 150, A: 100},
}
}
func RightPortalMap2() PortalConfig {
return PortalConfig{
ID: "map2_right",
DestinationMap: "map1",
DestinationPortal: "map1_left",
Color: color.NRGBA{R: 255, G: 100, B: 100, A: 180},
GlowColor: color.NRGBA{R: 255, G: 150, B: 150, A: 100},
}
}

View File

@@ -101,9 +101,10 @@ type state struct {
gameplayScreen *screens.GameplayScreen
pauseMenu *menu.PauseMenu
fpsEnabled bool
fpsCap FPSCap
saveManager *save.Manager
fpsEnabled bool
fpsCap FPSCap
portalVisibility bool
saveManager *save.Manager
lastAutoSave time.Time
autoSaveInterval time.Duration
@@ -120,6 +121,7 @@ func newState() *state {
lastTick: now,
fpsEnabled: false,
fpsCap: FPSCap60,
portalVisibility: false,
lastAutoSave: now,
autoSaveInterval: 30 * time.Second,
}
@@ -133,6 +135,7 @@ func newState() *state {
if saveManager != nil {
if settings, err := saveManager.LoadSettings(); err == nil {
s.fpsEnabled = settings.FPSMonitor
s.portalVisibility = settings.PortalVisibility
switch settings.FPSCap {
case "60":
s.fpsCap = FPSCap60
@@ -149,14 +152,16 @@ func newState() *state {
// Initialize screens
s.splashScreen = screens.NewSplashScreen()
s.titleScreen = screens.NewTitleScreen()
s.gameplayScreen = screens.NewGameplayScreen(ScreenWidth, ScreenHeight, &s.fpsEnabled)
s.gameplayScreen = screens.NewGameplayScreen(ScreenWidth, ScreenHeight, &s.fpsEnabled, &s.portalVisibility)
s.pauseMenu = menu.NewPauseMenu()
// Wire up settings references
s.titleScreen.SetFPSMonitor(&s.fpsEnabled)
s.titleScreen.SetFPSCap(&s.fpsCap)
s.titleScreen.SetPortalVisibility(&s.portalVisibility)
s.pauseMenu.SetFPSMonitor(&s.fpsEnabled)
s.pauseMenu.SetFPSCap(&s.fpsCap)
s.pauseMenu.SetPortalVisibility(&s.portalVisibility)
if saveManager != nil {
s.titleScreen.SetHasSaveGame(saveManager.HasSavedGame())
@@ -167,8 +172,9 @@ func newState() *state {
// Create initial save file
if saveManager != nil {
settings := &save.Settings{
FPSMonitor: s.fpsEnabled,
FPSCap: s.fpCapToStringHelper(s.fpsCap),
FPSMonitor: s.fpsEnabled,
FPSCap: s.fpCapToStringHelper(s.fpsCap),
PortalVisibility: s.portalVisibility,
}
saveManager.SaveSettings(settings)
}
@@ -192,6 +198,7 @@ func (s *state) fpCapToStringHelper(cap FPSCap) string {
func (g *Game) Update() error {
prevFPSEnabled := g.state.fpsEnabled
prevFPSCap := g.state.fpsCap
prevPortalVisibility := g.state.portalVisibility
currentTPS := g.state.fpsCap.TPS()
if currentTPS < 0 {
@@ -213,7 +220,7 @@ func (g *Game) Update() error {
err = g.updatePaused()
}
if prevFPSEnabled != g.state.fpsEnabled || prevFPSCap != g.state.fpsCap {
if prevFPSEnabled != g.state.fpsEnabled || prevFPSCap != g.state.fpsCap || prevPortalVisibility != g.state.portalVisibility {
g.saveSettings()
}
@@ -338,8 +345,9 @@ func (g *Game) saveSettings() {
}
settings := &save.Settings{
FPSMonitor: g.state.fpsEnabled,
FPSCap: g.fpCapToString(g.state.fpsCap),
FPSMonitor: g.state.fpsEnabled,
FPSCap: g.fpCapToString(g.state.fpsCap),
PortalVisibility: g.state.portalVisibility,
}
g.state.saveManager.SaveSettings(settings)
}

View File

@@ -5,10 +5,10 @@ import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/atridad/LilGuy/internal/config"
"github.com/atridad/LilGuy/internal/projectile"
)
// Hero defaults
const (
defaultRadius = 24.0
defaultSpeed = 200.0
@@ -16,25 +16,7 @@ const (
defaultStaminaDrain = 50.0
defaultStaminaRegen = 30.0
sprintSpeedMultiplier = 1.8
sprintRecoveryThreshold = 0.2
normalAnimSpeed = 0.15
idleAnimSpeed = 0.3
sprintAnimSpeed = 0.08
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
heroSpriteScale = 0.175
)
// Input and bounds
@@ -163,9 +145,9 @@ func (h *Hero) Update(input Input, dt float64, bounds Bounds) {
// Movement and physics
func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) {
h.VelocityY += gravity * dt
if h.VelocityY > maxFallSpeed {
h.VelocityY = maxFallSpeed
h.VelocityY += config.Gravity * dt
if h.VelocityY > config.MaxFallSpeed {
h.VelocityY = config.MaxFallSpeed
}
h.Y += h.VelocityY * dt
@@ -180,7 +162,7 @@ func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) {
}
if input.Jump && h.isGrounded {
h.VelocityY = jumpStrength
h.VelocityY = config.JumpStrength
h.isGrounded = false
}
@@ -198,12 +180,12 @@ func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) {
h.isSprinting = input.Sprint && h.canSprint && h.Stamina > 0 && h.isMoving
if h.isSprinting {
targetVelocityX *= sprintSpeedMultiplier
targetVelocityX *= config.SprintSpeedMultiplier
}
friction := groundFriction
friction := config.GroundFriction
if !h.isGrounded {
friction = airFriction
friction = config.AirFriction
}
h.VelocityX = h.VelocityX*friction + targetVelocityX*(1-friction)
@@ -223,7 +205,7 @@ func (h *Hero) updateMovement(input Input, dt float64, bounds Bounds) {
func (h *Hero) updateStamina(input Input, dt float64) {
if !input.Sprint {
h.wasSprintHeld = false
if h.Stamina >= h.MaxStamina*sprintRecoveryThreshold {
if h.Stamina >= h.MaxStamina*config.SprintRecoveryThreshold {
h.canSprint = true
}
}
@@ -259,21 +241,24 @@ func (h *Hero) updateAnimation(dt float64) {
}
if isMoving {
animSpeed := normalAnimSpeed * 0.5
animSpeed := config.NormalAnimSpeed * 0.5
if h.isSprinting {
animSpeed = sprintAnimSpeed * 0.5
animSpeed = config.SprintAnimSpeed * 0.5
}
if !h.isGrounded {
animSpeed = config.JumpingAnimSpeed * 0.5
}
h.animTimer += dt
frameAdvance := int(h.animTimer / animSpeed)
if frameAdvance > 0 {
h.animTimer -= animSpeed * float64(frameAdvance)
h.animFrame = (h.animFrame + frameAdvance) % animFrameWrap
h.animFrame = (h.animFrame + frameAdvance) % config.AnimFrameWrap
}
}
}
func (h *Hero) getVisualState() VisualState {
if h.Stamina < h.MaxStamina*exhaustedThreshold {
if h.Stamina < h.MaxStamina*config.ExhaustedThreshold {
return StateExhausted
}
@@ -296,7 +281,7 @@ func (h *Hero) Draw(screen *ebiten.Image) {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(-actualWidth/2, -actualHeight)
op.GeoM.Scale(heroSpriteScale, heroSpriteScale)
op.GeoM.Scale(config.HeroSpriteScale, config.HeroSpriteScale)
op.GeoM.Translate(h.X, h.Y)
state := h.getVisualState()

79
internal/maps/map.go Normal file
View File

@@ -0,0 +1,79 @@
package maps
import (
"image/color"
"github.com/atridad/LilGuy/internal/config"
"github.com/atridad/LilGuy/internal/portal"
"github.com/atridad/LilGuy/internal/world"
)
type Map struct {
ID string
Number int
Width float64
Height float64
World *world.World
Portals []*portal.Portal
BackgroundColor color.NRGBA
}
func NewMap(id string, number int, width, height float64) *Map {
return &Map{
ID: id,
Number: number,
Width: width,
Height: height,
World: world.NewWorld(),
Portals: make([]*portal.Portal, 0),
BackgroundColor: color.NRGBA{R: 135, G: 206, B: 235, A: 255},
}
}
func (m *Map) AddPortal(p *portal.Portal) {
m.Portals = append(m.Portals, p)
}
func (m *Map) GetPortalByID(id string) *portal.Portal {
for _, p := range m.Portals {
if p.ID == id {
return p
}
}
return nil
}
func CreateDefaultMaps(screenWidth, screenHeight float64) (*Map, *Map) {
map1 := createMapFromConfig(config.DefaultMap1(), screenWidth, screenHeight)
map2 := createMapFromConfig(config.DefaultMap2(), screenWidth, screenHeight)
addPortalFromConfig(map1, config.LeftPortalMap1(), portal.SideLeft, screenWidth, screenHeight)
addPortalFromConfig(map1, config.RightPortalMap1(), portal.SideRight, screenWidth, screenHeight)
addPortalFromConfig(map2, config.LeftPortalMap2(), portal.SideLeft, screenWidth, screenHeight)
addPortalFromConfig(map2, config.RightPortalMap2(), portal.SideRight, screenWidth, screenHeight)
return map1, map2
}
func createMapFromConfig(cfg config.MapConfig, width, height float64) *Map {
m := NewMap(cfg.ID, cfg.Number, width, height)
m.BackgroundColor = cfg.BackgroundColor
m.World.AddSurface(&world.Surface{
X: 0,
Y: height - config.GroundHeight,
Width: width,
Height: config.GroundHeight,
Tag: world.TagGround,
Color: cfg.GroundColor,
})
return m
}
func addPortalFromConfig(m *Map, cfg config.PortalConfig, side portal.PortalSide, screenWidth, screenHeight float64) {
p := portal.CreateSidePortal(cfg.ID, side, screenWidth, screenHeight)
p.DestinationMap = cfg.DestinationMap
p.DestinationPortal = cfg.DestinationPortal
p.Color = cfg.Color
p.GlowColor = cfg.GlowColor
m.AddPortal(p)
}

270
internal/portal/portal.go Normal file
View File

@@ -0,0 +1,270 @@
package portal
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"github.com/atridad/LilGuy/internal/config"
)
type PortalSide int
const (
SideLeft PortalSide = iota
SideRight
SideTop
SideBottom
)
func (s PortalSide) String() string {
switch s {
case SideLeft:
return "Left"
case SideRight:
return "Right"
case SideTop:
return "Top"
case SideBottom:
return "Bottom"
default:
return "Unknown"
}
}
type Portal struct {
ID string
X float64
Y float64
Width float64
Height float64
Side PortalSide
DestinationMap string
DestinationPortal string
Color color.NRGBA
GlowColor color.NRGBA
Enabled bool
Visible bool
GlowIntensity float64
SpawnOffsetX float64
SpawnOffsetY float64
}
type TransitionEvent struct {
Portal *Portal
HeroX float64
HeroY float64
DestinationMap string
DestinationPortal string
}
func NewPortal(id string, side PortalSide, x, y, width, height float64) *Portal {
return &Portal{
ID: id,
X: x,
Y: y,
Width: width,
Height: height,
Side: side,
Color: color.NRGBA{R: 100, G: 100, B: 255, A: 180},
GlowColor: color.NRGBA{R: 150, G: 150, B: 255, A: 100},
Enabled: true,
Visible: true,
GlowIntensity: 0,
}
}
// Contains checks if a point is within the portal bounds
func (p *Portal) Contains(x, y float64) bool {
return x >= p.X && x <= p.X+p.Width && y >= p.Y && y <= p.Y+p.Height
}
// Overlaps checks if a rectangular area overlaps with the portal
func (p *Portal) Overlaps(x, y, width, height float64) bool {
return x+width > p.X && x < p.X+p.Width &&
y+height > p.Y && y < p.Y+p.Height
}
func (p *Portal) CanTransition() bool {
return p.Enabled && p.DestinationMap != ""
}
func (p *Portal) Update(dt float64) {
if !p.Enabled {
return
}
p.GlowIntensity += dt * 2.0
if p.GlowIntensity > 6.28 { // 2*PI
p.GlowIntensity -= 6.28
}
}
func (p *Portal) Draw(screen *ebiten.Image) {
if !p.Visible {
return
}
vector.FillRect(
screen,
float32(p.X),
float32(p.Y),
float32(p.Width),
float32(p.Height),
p.Color,
false,
)
if p.Enabled {
glowAlpha := uint8(float64(p.GlowColor.A) * (0.5 + 0.5*float64(p.GlowIntensity)))
glowColor := color.NRGBA{
R: p.GlowColor.R,
G: p.GlowColor.G,
B: p.GlowColor.B,
A: glowAlpha,
}
borderWidth := float32(4)
vector.StrokeRect(
screen,
float32(p.X)-borderWidth/2,
float32(p.Y)-borderWidth/2,
float32(p.Width)+borderWidth,
float32(p.Height)+borderWidth,
borderWidth,
glowColor,
false,
)
}
}
type Manager struct {
Portals []*Portal
OnTransition func(event TransitionEvent)
transitionCooldown float64
}
func NewManager() *Manager {
return &Manager{
Portals: make([]*Portal, 0),
transitionCooldown: 0,
}
}
func (m *Manager) AddPortal(portal *Portal) {
m.Portals = append(m.Portals, portal)
}
func (m *Manager) GetPortalByID(id string) *Portal {
for _, p := range m.Portals {
if p.ID == id {
return p
}
}
return nil
}
func (m *Manager) RemovePortal(id string) {
for i, p := range m.Portals {
if p.ID == id {
m.Portals = append(m.Portals[:i], m.Portals[i+1:]...)
return
}
}
}
func (m *Manager) Clear() {
m.Portals = make([]*Portal, 0)
}
func (m *Manager) Update(dt float64) {
for _, p := range m.Portals {
p.Update(dt)
}
if m.transitionCooldown > 0 {
m.transitionCooldown -= dt
}
}
func (m *Manager) CheckCollision(x, y, width, height float64) *Portal {
if m.transitionCooldown > 0 {
return nil
}
for _, p := range m.Portals {
if !p.CanTransition() {
continue
}
if p.Overlaps(x, y, width, height) {
return p
}
}
return nil
}
func (m *Manager) TriggerTransition(portal *Portal, heroX, heroY float64) {
if portal == nil || !portal.CanTransition() {
return
}
if m.OnTransition != nil {
event := TransitionEvent{
Portal: portal,
HeroX: heroX,
HeroY: heroY,
DestinationMap: portal.DestinationMap,
DestinationPortal: portal.DestinationPortal,
}
m.OnTransition(event)
}
m.transitionCooldown = config.PortalTransitionCooldown
}
func (m *Manager) Draw(screen *ebiten.Image) {
for _, p := range m.Portals {
p.Draw(screen)
}
}
func CreateSidePortal(id string, side PortalSide, screenWidth, screenHeight float64) *Portal {
var x, y, width, height float64
switch side {
case SideLeft:
x = 0
y = 0
width = config.PortalThickness
height = screenHeight
case SideRight:
x = screenWidth - config.PortalThickness
y = 0
width = config.PortalThickness
height = screenHeight
case SideTop:
x = 0
y = 0
width = screenWidth
height = config.PortalThickness
case SideBottom:
x = 0
y = screenHeight - config.PortalThickness
width = screenWidth
height = config.PortalThickness
}
return NewPortal(id, side, x, y, width, height)
}
func CreateDoorPortal(id string, side PortalSide, x, y float64) *Portal {
const doorWidth = 80.0
const doorHeight = 120.0
return NewPortal(id, side, x, y, doorWidth, doorHeight)
}

View File

@@ -21,8 +21,9 @@ type Data struct {
}
type Settings struct {
FPSMonitor bool `toml:"fps_monitor"`
FPSCap string `toml:"fps_cap"`
FPSMonitor bool `toml:"fps_monitor"`
FPSCap string `toml:"fps_cap"`
PortalVisibility bool `toml:"portal_visibility"`
}
type GameState struct {
@@ -59,8 +60,9 @@ func (m *Manager) LoadData() (*Data, error) {
if _, err := os.Stat(m.dataPath); os.IsNotExist(err) {
return &Data{
Settings: Settings{
FPSMonitor: false,
FPSCap: "60",
FPSMonitor: false,
FPSCap: "60",
PortalVisibility: false,
},
GameState: GameState{
HasSave: false,
@@ -137,8 +139,9 @@ func (m *Manager) SaveGameState(state *GameState) error {
if err != nil {
data = &Data{
Settings: Settings{
FPSMonitor: false,
FPSCap: "60",
FPSMonitor: false,
FPSCap: "60",
PortalVisibility: false,
},
GameState: *state,
}

View File

@@ -9,7 +9,10 @@ import (
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"github.com/atridad/LilGuy/internal/config"
"github.com/atridad/LilGuy/internal/hero"
"github.com/atridad/LilGuy/internal/maps"
"github.com/atridad/LilGuy/internal/portal"
"github.com/atridad/LilGuy/internal/projectile"
"github.com/atridad/LilGuy/internal/save"
"github.com/atridad/LilGuy/internal/status"
@@ -17,51 +20,6 @@ import (
"github.com/atridad/LilGuy/internal/world"
)
// Screen and hero defaults
var (
backgroundColor = color.NRGBA{R: 135, G: 206, B: 235, A: 255}
saveNotificationColor = color.NRGBA{R: 50, G: 200, B: 50, A: 255}
)
const (
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 (
heroColor = color.NRGBA{R: 210, G: 220, B: 255, A: 255}
)
// HUD positioning and colors
const (
hudX = 960 - 220
hudY = 20
)
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}
)
// FPS monitoring thresholds
const (
staminaLowThreshold = 0.2
fpsWarnThreshold = 0.85
fpsPoorThreshold = 0.6
fpsSampleWindow = time.Second
targetTPS = 60
)
type GameplayInput struct {
Left bool
Right bool
@@ -75,6 +33,7 @@ type GameplayScreen struct {
hud hud.Overlay
world *world.World
projectiles *projectile.Manager
portals *portal.Manager
bounds hero.Bounds
lastTick time.Time
gameStartTime time.Time
@@ -86,48 +45,69 @@ type GameplayScreen struct {
saveNotificationTimer time.Duration
showSaveNotification bool
portalVisibility *bool
currentMap *maps.Map
allMaps map[string]*maps.Map
}
func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool) *GameplayScreen {
w := world.NewWorld()
func NewGameplayScreen(screenWidth, screenHeight int, fpsEnabled *bool, portalVisibility *bool) *GameplayScreen {
cfg := config.Default()
map1, map2 := maps.CreateDefaultMaps(float64(screenWidth), float64(screenHeight))
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},
})
allMaps := make(map[string]*maps.Map)
allMaps["map1"] = map1
allMaps["map2"] = map2
return &GameplayScreen{
portalMgr := portal.NewManager()
for _, p := range map1.Portals {
portalMgr.AddPortal(p)
}
if portalVisibility != nil && *portalVisibility {
for _, p := range portalMgr.Portals {
p.Visible = true
}
}
gs := &GameplayScreen{
hero: hero.New(hero.Config{
StartX: heroStartX,
StartY: heroStartY,
Radius: heroRadius,
Speed: heroSpeed,
Color: heroColor,
MaxStamina: heroMaxStamina,
StaminaDrain: heroStaminaDrain,
StaminaRegen: heroStaminaRegen,
StartX: cfg.Hero.StartX,
StartY: cfg.Hero.StartY,
Radius: cfg.Hero.Radius,
Speed: cfg.Hero.Speed,
Color: cfg.Hero.Color,
MaxStamina: cfg.Hero.MaxStamina,
StaminaDrain: cfg.Hero.StaminaDrain,
StaminaRegen: cfg.Hero.StaminaRegen,
}),
hud: hud.Overlay{
X: hudX,
Y: hudY,
Color: color.White,
X: cfg.HUD.X,
Y: cfg.HUD.Y,
Color: color.White,
ScreenName: "Map 1",
},
world: w,
world: map1.World,
projectiles: projectile.NewManager(),
portals: portalMgr,
bounds: hero.Bounds{
Width: float64(screenWidth),
Height: float64(screenHeight),
Ground: float64(screenHeight) - groundHeight,
Ground: float64(screenHeight) - config.GroundHeight,
},
lastTick: time.Now(),
gameStartTime: time.Now(),
fpsEnabled: fpsEnabled,
lastTick: time.Now(),
gameStartTime: time.Now(),
fpsEnabled: fpsEnabled,
portalVisibility: portalVisibility,
currentMap: map1,
allMaps: allMaps,
}
gs.portals.OnTransition = gs.handlePortalTransition
return gs
}
func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) {
@@ -149,6 +129,10 @@ func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) {
}
g.projectiles.Update(dt, g.bounds.Width, g.bounds.Height)
g.portals.Update(dt)
// Check for portal collisions
g.checkPortalCollision()
g.totalPlayTime += delta
@@ -162,6 +146,71 @@ func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) {
}
}
// Portal collision detection
func (g *GameplayScreen) checkPortalCollision() {
heroRadius := g.hero.Radius
heroX := g.hero.X - heroRadius
heroY := g.hero.Y - heroRadius
heroWidth := heroRadius * 2
heroHeight := heroRadius * 2
if p := g.portals.CheckCollision(heroX, heroY, heroWidth, heroHeight); p != nil {
g.portals.TriggerTransition(p, g.hero.X, g.hero.Y)
}
g.updatePortalVisibility()
}
func (g *GameplayScreen) updatePortalVisibility() {
if g.portalVisibility == nil {
return
}
visible := *g.portalVisibility
for _, p := range g.portals.Portals {
p.Visible = visible
}
}
func (g *GameplayScreen) handlePortalTransition(event portal.TransitionEvent) {
destMap, exists := g.allMaps[event.DestinationMap]
if !exists {
return
}
// Switch to destination map
g.currentMap = destMap
g.world = destMap.World
// Clear and reload portals for new map
g.portals.Clear()
for _, p := range destMap.Portals {
g.portals.AddPortal(p)
}
g.updatePortalVisibility()
// Find destination portal and position hero
destPortal := destMap.GetPortalByID(event.DestinationPortal)
if destPortal != nil {
// Position hero based on which side they're entering from
switch destPortal.Side {
case portal.SideLeft:
g.hero.X = destPortal.X + destPortal.Width + g.hero.Radius + 10
g.hero.Y = event.HeroY
case portal.SideRight:
g.hero.X = destPortal.X - g.hero.Radius - 10
g.hero.Y = event.HeroY
case portal.SideTop:
g.hero.X = event.HeroX
g.hero.Y = destPortal.Y + destPortal.Height + g.hero.Radius + 10
case portal.SideBottom:
g.hero.X = event.HeroX
g.hero.Y = destPortal.Y - g.hero.Radius - 10
}
}
}
// FPS tracking
func (g *GameplayScreen) trackFPS(delta time.Duration) {
if g.fpsEnabled == nil || !*g.fpsEnabled {
@@ -171,7 +220,7 @@ func (g *GameplayScreen) trackFPS(delta time.Duration) {
g.fpsAccumulator += delta
g.fpsFrames++
if g.fpsAccumulator >= fpsSampleWindow {
if g.fpsAccumulator >= config.FPSSampleWindow {
g.fpsValue = float64(g.fpsFrames) / g.fpsAccumulator.Seconds()
g.fpsAccumulator = 0
g.fpsFrames = 0
@@ -180,15 +229,25 @@ func (g *GameplayScreen) trackFPS(delta time.Duration) {
// Rendering
func (g *GameplayScreen) Draw(screen *ebiten.Image) {
screen.Fill(backgroundColor)
cfg := config.Default()
bgColor := cfg.Visual.BackgroundColor
if g.currentMap != nil {
bgColor = g.currentMap.BackgroundColor
}
screen.Fill(bgColor)
g.world.Draw(screen)
g.portals.Draw(screen)
g.projectiles.Draw(screen)
g.hero.Draw(screen)
staminaColor := staminaNormalColor
if g.hero.Stamina < g.hero.MaxStamina*staminaLowThreshold {
staminaColor = staminaLowColor
if g.currentMap != nil {
g.hud.ScreenName = fmt.Sprintf("Map %d", g.currentMap.Number)
}
staminaColor := cfg.Visual.StaminaNormalColor
if g.hero.Stamina < g.hero.MaxStamina*config.StaminaLowThreshold {
staminaColor = cfg.Visual.StaminaLowColor
}
staminaMeter := status.Meter{
@@ -201,13 +260,13 @@ func (g *GameplayScreen) Draw(screen *ebiten.Image) {
meters := []status.Meter{staminaMeter}
if g.fpsEnabled != nil && *g.fpsEnabled {
ratio := g.fpsValue / float64(targetTPS)
fpsColor := fpsGoodColor
ratio := g.fpsValue / float64(config.TargetTPS)
fpsColor := cfg.Visual.FPSGoodColor
switch {
case ratio < fpsPoorThreshold:
fpsColor = fpsPoorColor
case ratio < fpsWarnThreshold:
fpsColor = fpsWarnColor
case ratio < config.FPSPoorThreshold:
fpsColor = cfg.Visual.FPSPoorColor
case ratio < config.FPSWarnThreshold:
fpsColor = cfg.Visual.FPSWarnColor
}
fpsMeter := status.Meter{
@@ -233,7 +292,8 @@ func (g *GameplayScreen) drawSaveNotification(screen *ebiten.Image) {
boxWidth := float32(140)
boxHeight := float32(40)
vector.DrawFilledRect(screen,
cfg := config.Default()
vector.FillRect(screen,
centerX-boxWidth/2,
centerY-boxHeight/2,
boxWidth,
@@ -247,7 +307,7 @@ func (g *GameplayScreen) drawSaveNotification(screen *ebiten.Image) {
boxWidth,
boxHeight,
2,
saveNotificationColor,
cfg.Visual.SaveNotificationColor,
false)
msg := "Game Saved!"
@@ -258,41 +318,43 @@ func (g *GameplayScreen) drawSaveNotification(screen *ebiten.Image) {
func (g *GameplayScreen) ShowSaveNotification() {
g.showSaveNotification = true
g.saveNotificationTimer = saveNotificationDuration
g.saveNotificationTimer = config.SaveNotificationDuration
}
// State management
func (g *GameplayScreen) Reset() {
cfg := config.Default()
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},
})
map1, map2 := maps.CreateDefaultMaps(float64(screenWidth), float64(screenHeight))
g.allMaps = make(map[string]*maps.Map)
g.allMaps["map1"] = map1
g.allMaps["map2"] = map2
g.currentMap = map1
g.hero = hero.New(hero.Config{
StartX: heroStartX,
StartY: heroStartY,
Radius: heroRadius,
Speed: heroSpeed,
Color: heroColor,
MaxStamina: heroMaxStamina,
StaminaDrain: heroStaminaDrain,
StaminaRegen: heroStaminaRegen,
StartX: cfg.Hero.StartX,
StartY: cfg.Hero.StartY,
Radius: cfg.Hero.Radius,
Speed: cfg.Hero.Speed,
Color: cfg.Hero.Color,
MaxStamina: cfg.Hero.MaxStamina,
StaminaDrain: cfg.Hero.StaminaDrain,
StaminaRegen: cfg.Hero.StaminaRegen,
})
g.world = w
g.world = map1.World
g.projectiles = projectile.NewManager()
g.portals = portal.NewManager()
for _, p := range map1.Portals {
g.portals.AddPortal(p)
}
g.portals.OnTransition = g.handlePortalTransition
g.updatePortalVisibility()
g.bounds = hero.Bounds{
Width: float64(screenWidth),
Height: float64(screenHeight),
Ground: float64(screenHeight) - groundHeight,
Ground: float64(screenHeight) - config.GroundHeight,
}
g.lastTick = time.Now()
g.gameStartTime = time.Now()
@@ -312,37 +374,39 @@ func (g *GameplayScreen) SaveState() *save.GameState {
}
func (g *GameplayScreen) LoadState(state *save.GameState) {
cfg := config.Default()
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},
})
map1, map2 := maps.CreateDefaultMaps(float64(screenWidth), float64(screenHeight))
g.allMaps = make(map[string]*maps.Map)
g.allMaps["map1"] = map1
g.allMaps["map2"] = map2
g.currentMap = map1
g.hero = hero.New(hero.Config{
StartX: state.HeroX,
StartY: state.HeroY,
Radius: heroRadius,
Speed: heroSpeed,
Color: heroColor,
MaxStamina: heroMaxStamina,
StaminaDrain: heroStaminaDrain,
StaminaRegen: heroStaminaRegen,
Radius: cfg.Hero.Radius,
Speed: cfg.Hero.Speed,
Color: cfg.Hero.Color,
MaxStamina: cfg.Hero.MaxStamina,
StaminaDrain: cfg.Hero.StaminaDrain,
StaminaRegen: cfg.Hero.StaminaRegen,
})
g.hero.Stamina = state.HeroStamina
g.world = w
g.world = map1.World
g.projectiles = projectile.NewManager()
g.portals = portal.NewManager()
for _, p := range map1.Portals {
g.portals.AddPortal(p)
}
g.portals.OnTransition = g.handlePortalTransition
g.updatePortalVisibility()
g.bounds = hero.Bounds{
Width: float64(screenWidth),
Height: float64(screenHeight),
Ground: float64(screenHeight) - groundHeight,
Ground: float64(screenHeight) - config.GroundHeight,
}
g.totalPlayTime = time.Duration(state.PlayTimeMS) * time.Millisecond
g.lastTick = time.Now()

View File

@@ -15,9 +15,10 @@ type FPSCapSetting interface {
}
type SettingsScreen struct {
selectedIndex int
fpsMonitorValue *bool
fpsCapValue FPSCapSetting
selectedIndex int
fpsMonitorValue *bool
fpsCapValue FPSCapSetting
portalVisibilityValue *bool
}
func NewSettingsScreen() *SettingsScreen {
@@ -34,12 +35,16 @@ func (s *SettingsScreen) SetFPSCap(cap FPSCapSetting) {
s.fpsCapValue = cap
}
func (s *SettingsScreen) SetPortalVisibility(enabled *bool) {
s.portalVisibilityValue = enabled
}
func (s *SettingsScreen) Update() bool {
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
return true
}
settingsCount := 2
settingsCount := 3
if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) {
s.selectedIndex--
if s.selectedIndex < 0 {
@@ -58,6 +63,8 @@ func (s *SettingsScreen) Update() bool {
*s.fpsMonitorValue = !*s.fpsMonitorValue
} else if s.selectedIndex == 1 && s.fpsCapValue != nil {
s.fpsCapValue.Cycle()
} else if s.selectedIndex == 2 && s.portalVisibilityValue != nil {
*s.portalVisibilityValue = !*s.portalVisibilityValue
}
}
@@ -109,6 +116,23 @@ func (s *SettingsScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight in
s.drawText(screen, fpsCapText, color.RGBA{R: 180, G: 180, B: 200, A: 255}, leftMargin, capY)
}
// Portal visibility toggle
portalVisText := "Portal Visibility: "
if s.portalVisibilityValue != nil && *s.portalVisibilityValue {
portalVisText += "ON"
} else {
portalVisText += "OFF"
}
portalY := startY + 80
if s.selectedIndex == 2 {
indicatorX := leftMargin - 20
s.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, portalY)
s.drawText(screen, portalVisText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, portalY)
} else {
s.drawText(screen, portalVisText, color.RGBA{R: 180, G: 180, B: 200, A: 255}, leftMargin, portalY)
}
// Instructions
hintText := "Enter/Space to toggle, ESC to go back"
hintX := (screenWidth / 2) - (len(hintText) * 7 / 2)

View File

@@ -6,7 +6,6 @@ import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// Menu options
@@ -52,6 +51,10 @@ func (t *TitleScreen) SetFPSCap(cap FPSCapSetting) {
t.settingsScreen.SetFPSCap(cap)
}
func (t *TitleScreen) SetPortalVisibility(enabled *bool) {
t.settingsScreen.SetPortalVisibility(enabled)
}
func (t *TitleScreen) SetHasSaveGame(hasSave bool) {
t.hasSaveGame = hasSave
if !hasSave && t.selectedIndex == 0 {
@@ -151,15 +154,6 @@ func (t *TitleScreen) Draw(screen *ebiten.Image, screenWidth, screenHeight int)
indicatorX := optionX - 20
t.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, optionY)
t.drawText(screen, option, color.RGBA{R: 255, G: 255, B: 100, A: 255}, optionX, optionY)
boxPadding := float32(10.0)
boxWidth := float32(len(option)*7) + boxPadding*2
boxHeight := float32(20)
boxX := float32(optionX) - boxPadding
boxY := float32(optionY) - float32(basicFaceAscent) - boxPadding/2
vector.StrokeRect(screen, boxX, boxY, boxWidth, boxHeight, 2,
color.RGBA{R: 255, G: 200, B: 0, A: 255}, false)
} else {
t.drawText(screen, option, optionColor, optionX, optionY)
}

View File

@@ -114,21 +114,24 @@ func (b Bar) Draw(screen *ebiten.Image, x, y int) (int, int) {
fillWidth = maxWidth
}
if b.ShowBorder {
borderColor := b.BorderColor
if borderColor == nil {
borderColor = color.RGBA{R: 80, G: 80, B: 80, A: 255}
}
drawRect(screen, x, y, maxWidth, 1, borderColor)
drawRect(screen, x, y+height-1, maxWidth, 1, borderColor)
drawRect(screen, x, y, 1, height, borderColor)
drawRect(screen, x+maxWidth-1, y, 1, height, borderColor)
}
// Draw dark background
drawRect(screen, x, y, maxWidth, height, color.RGBA{R: 30, G: 30, B: 30, A: 255})
// Draw filled portion
if fillWidth > 0 {
drawRect(screen, x, y, fillWidth, height, b.Meter.Color)
}
// Draw border
borderColor := b.BorderColor
if borderColor == nil {
borderColor = color.RGBA{R: 180, G: 180, B: 180, A: 255}
}
drawRect(screen, x, y, maxWidth, 1, borderColor)
drawRect(screen, x, y+height-1, maxWidth, 1, borderColor)
drawRect(screen, x, y, 1, height, borderColor)
drawRect(screen, x+maxWidth-1, y, 1, height, borderColor)
return maxWidth, height
}

View File

@@ -4,14 +4,16 @@ import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"github.com/atridad/LilGuy/internal/status"
)
type Overlay struct {
X int
Y int
Color color.Color
X int
Y int
Color color.Color
ScreenName string
}
func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) {
@@ -19,35 +21,64 @@ func (o Overlay) Draw(screen *ebiten.Image, meters []status.Meter) {
o.Color = color.White
}
// Draw background box for instructions
instrBoxWidth := float32(280)
instrBoxHeight := float32(78)
instrBoxPaddingX := float32(12)
instrBoxPaddingY := float32(15)
vector.FillRect(screen, 10, 10, instrBoxWidth, instrBoxHeight,
color.NRGBA{R: 0, G: 0, B: 0, A: 180}, false)
vector.StrokeRect(screen, 10, 10, instrBoxWidth, instrBoxHeight, 2,
color.NRGBA{R: 200, G: 200, B: 200, A: 255}, false)
screenName := o.ScreenName
if screenName == "" {
screenName = "Lil Guy"
}
instructions := Column{
Elements: []Element{
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}},
Label{Text: screenName, Color: color.NRGBA{R: 255, G: 255, B: 100, A: 255}},
Label{Text: "Move with Arrow Keys / WASD", Color: color.NRGBA{R: 230, G: 230, B: 230, A: 255}},
Label{Text: "Hold Shift to Sprint", Color: color.NRGBA{R: 230, G: 230, B: 230, A: 255}},
},
Spacing: 7,
}
instructions.Draw(screen, 16, 16)
instructions.Draw(screen, int(10+instrBoxPaddingX), int(10+instrBoxPaddingY))
// Draw background box for meters
meterBoxPaddingX := float32(12)
meterBoxPaddingTop := float32(15)
meterBoxPaddingBottom := float32(8)
meterBoxWidth := float32(220)
meterBoxHeight := float32(meterBoxPaddingTop + meterBoxPaddingBottom + float32(len(meters))*35 - 5)
meterBoxX := float32(o.X) - meterBoxPaddingX
meterBoxY := float32(o.Y) - meterBoxPaddingTop
vector.FillRect(screen, meterBoxX, meterBoxY, meterBoxWidth, meterBoxHeight,
color.NRGBA{R: 0, G: 0, B: 0, A: 180}, false)
vector.StrokeRect(screen, meterBoxX, meterBoxY, meterBoxWidth, meterBoxHeight, 2,
color.NRGBA{R: 200, G: 200, B: 200, A: 255}, false)
meterElements := make([]Element, 0, len(meters))
for _, meter := range meters {
if meter.Base < 0 {
meterElements = append(meterElements,
MeterLabel{Meter: meter, Color: o.Color},
MeterLabel{Meter: meter, Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}},
)
} else {
meterElements = append(meterElements, Column{
Elements: []Element{
MeterLabel{Meter: meter, Color: o.Color},
Bar{Meter: meter, MaxWidth: 180, Height: 8, ShowBorder: false},
MeterLabel{Meter: meter, Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}},
Bar{Meter: meter, MaxWidth: 180, Height: 10, ShowBorder: true},
},
Spacing: 2,
Spacing: 4,
})
}
}
meterPanel := Column{
Elements: meterElements,
Spacing: 16,
Spacing: 18,
}
meterPanel.Draw(screen, o.X, o.Y)
}

View File

@@ -84,6 +84,10 @@ func (m *PauseMenu) SetFPSCap(cap FPSCapSetting) {
m.settingsScreen.SetFPSCap(cap)
}
func (m *PauseMenu) SetPortalVisibility(enabled *bool) {
m.settingsScreen.SetPortalVisibility(enabled)
}
// Update logic
func (m *PauseMenu) Update() *MenuOption {
@@ -140,7 +144,7 @@ func (m *PauseMenu) Draw(screen *ebiten.Image, screenWidth, screenHeight int) {
menuX := (screenWidth - menuWidth) / 2
menuY := (screenHeight - menuHeight) / 2
vector.DrawFilledRect(screen,
vector.FillRect(screen,
float32(menuX), float32(menuY),
float32(menuWidth), float32(menuHeight),
color.RGBA{R: 40, G: 40, B: 50, A: 255},
@@ -192,7 +196,7 @@ func (m *PauseMenu) drawMain(screen *ebiten.Image, menuX, menuY, menuWidth, menu
}
func (m *PauseMenu) drawSettings(screen *ebiten.Image, menuX, menuY, menuWidth, menuHeight int) {
vector.DrawFilledRect(screen,
vector.FillRect(screen,
float32(menuX), float32(menuY),
float32(menuWidth), float32(menuHeight),
color.RGBA{R: 40, G: 40, B: 50, A: 255},

View File

@@ -58,7 +58,7 @@ func (s *Surface) IsWalkable() bool {
}
func (s *Surface) Draw(screen *ebiten.Image) {
vector.DrawFilledRect(
vector.FillRect(
screen,
float32(s.X),
float32(s.Y),