diff --git a/game.conf b/game.conf index d493afb..3985946 100644 --- a/game.conf +++ b/game.conf @@ -1,4 +1,5 @@ game.title = "Project Ely" +game.framerate = 60.0 log.file = "output.log" log.writeToFile = false diff --git a/internal/channels/timeout.go b/internal/channels/timeout.go new file mode 100644 index 0000000..ac432db --- /dev/null +++ b/internal/channels/timeout.go @@ -0,0 +1,29 @@ +package channels + +import ( + "context" + "time" +) + +func RunWithTimeout(timeout time.Duration, function func() error) error { + var err error + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Launch a goroutine with a channel + finished := make(chan bool) + go func() { + err = function() + finished <- true + }() + + // Then wait for completion or timeout + select { + case <-ctx.Done(): + // Timed out + return ctx.Err() + case <-finished: + // Completed + } + return err +} diff --git a/internal/config/config.go b/internal/config/config.go index 7d813d6..7ab9869 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( var defaultConfig gosimpleconf.ConfigMap = gosimpleconf.ConfigMap{ "game.title": "Project Ely", + "game.framerate": "60.0", "log.writeToFile": "false", } diff --git a/internal/game/entity/commands.go b/internal/game/entity/commands.go new file mode 100644 index 0000000..c89ec65 --- /dev/null +++ b/internal/game/entity/commands.go @@ -0,0 +1,22 @@ +package entity + +// List of keys supported by all entities +const ( + COMMAND_DRAW int = iota + COMMAND_UPDATE + COMMAND_MOVE_X + COMMAND_MOVE_Y + COMMAND_SET_SPEED +) + +type EntityCommand struct { + key int + value float64 +} + +func NewEntityCommand(key int, value float64) EntityCommand { + return EntityCommand{ + key: key, + value: value, + } +} diff --git a/internal/game/entity/entity_animation.go b/internal/game/entity/entity_animation.go index 261e484..8c9516b 100644 --- a/internal/game/entity/entity_animation.go +++ b/internal/game/entity/entity_animation.go @@ -43,3 +43,7 @@ func NewEntityAnimation( func (e *entityAnimation) Draw(frame int32, windowPosition *sdl.Point) error { return e.spriteAnimation.Draw(frame, windowPosition, e.angle, e.center, e.flip) } + +func DefineAnimations() { + DefinePenguinAnimations() +} diff --git a/internal/game/entity/penguin.go b/internal/game/entity/penguin.go index 89bd88d..caa1bda 100644 --- a/internal/game/entity/penguin.go +++ b/internal/game/entity/penguin.go @@ -1,14 +1,21 @@ package entity import ( + "context" "log" "math" + "time" + "gitea.wisellama.rocks/Project-Ely/project-ely/internal/channels" "gitea.wisellama.rocks/Project-Ely/project-ely/internal/vector" "github.com/veandco/go-sdl2/sdl" ) type penguin struct { + ctx context.Context + timeout time.Duration + commandChan chan EntityCommand + // Animation parameters currentAnimation *entityAnimation animationStep int32 @@ -21,17 +28,25 @@ type penguin struct { speed float64 } -func NewPenguin(renderer *sdl.Renderer) *penguin { +func NewPenguin(ctx context.Context, renderer *sdl.Renderer) *penguin { + commandChan := make(chan EntityCommand, 10) + defaultTimeout := time.Second + position := vector.Vec2F{} direction := vector.Vec2F{} + speed := 1.0 p := penguin{ + ctx: ctx, + commandChan: commandChan, + timeout: defaultTimeout, + currentAnimation: penguinAnimations[PENGUIN_DEFAULT], animationStep: 0, facingRight: true, worldPosition: &position, - speed: 2.0, + speed: speed, direction: &direction, } @@ -61,11 +76,11 @@ func (p *penguin) SetPosition(vec *vector.Vec2F) { p.worldPosition = vec } -func (p *penguin) SetAnimation(name string) { - a, exists := penguinAnimations[name] +func (p *penguin) SetAnimation(id int) { + a, exists := penguinAnimations[id] if !exists { - log.Printf("animation does not exist: %v", name) + log.Printf("animation does not exist: %v", id) a = penguinAnimations[PENGUIN_DEFAULT] } @@ -78,15 +93,25 @@ func (p *penguin) SetAnimation(name string) { func (p *penguin) MoveX(x float64) { p.direction.X = x + p.direction.Normalize() p.updateAnimation = true } func (p *penguin) MoveY(y float64) { // (0,0) is the top left, so negative y moves up p.direction.Y = y + p.direction.Normalize() p.updateAnimation = true } +func (p *penguin) SetSpeed(s float64) { + p.speed = s +} + +func (p *penguin) GetSpeed() float64 { + return p.speed +} + func (p *penguin) SetMoveAnimation() { x := p.direction.X y := p.direction.Y @@ -124,12 +149,70 @@ func (p *penguin) Update() error { p.updateAnimation = false } - p.direction.Normalize() x := p.direction.X * p.speed y := p.direction.Y * p.speed - log.Printf("X: %v, Y: %v\n", x, y) p.worldPosition.X += x p.worldPosition.Y += y + + return nil +} + +func (p *penguin) GetCommandChan() chan EntityCommand { + return p.commandChan +} + +func (p *penguin) Run() { + running := true + for running { + select { + case c := <-p.commandChan: + go func(cmd EntityCommand) { + p.HandleWithTimeout(cmd) + }(c) + case <-p.ctx.Done(): + // Graceful shutdown + running = false + // Finish up anything in the queue + for c := range p.commandChan { + p.HandleWithTimeout(c) + } + log.Printf("entity shutdown\n") + } + } +} + +func (p *penguin) HandleWithTimeout(c EntityCommand) { + err := channels.RunWithTimeout(p.timeout, func() error { + return p.Handle(c) + }) + if err != nil { + log.Printf("%v\n", err) + } +} + +func (p *penguin) Handle(c EntityCommand) error { + var err error + switch c.key { + case COMMAND_DRAW: + err = p.Draw() + if err != nil { + log.Printf("error drawing entity: %v", err) + } + case COMMAND_UPDATE: + err = p.Update() + if err != nil { + log.Printf("error updating entity: %v", err) + } + case COMMAND_MOVE_X: + p.MoveX(c.value) + case COMMAND_MOVE_Y: + p.MoveY(c.value) + case COMMAND_SET_SPEED: + p.SetSpeed(c.value) + default: + log.Printf("unknown entity command: %v", c.key) + } + return nil } diff --git a/internal/game/entity/penguin_animations.go b/internal/game/entity/penguin_animations.go index b82edb1..53571fb 100644 --- a/internal/game/entity/penguin_animations.go +++ b/internal/game/entity/penguin_animations.go @@ -5,13 +5,13 @@ import ( "github.com/veandco/go-sdl2/sdl" ) -var penguinAnimations map[string]*entityAnimation +var penguinAnimations map[int]*entityAnimation const ( - PENGUIN_WALK_RIGHT = "walk-right" - PENGUIN_WALK_LEFT = "walk-left" - PENGUIN_STATIONARY_RIGHT = "stationary-right" - PENGUIN_STATIONARY_LEFT = "stationary-left" + PENGUIN_WALK_RIGHT int = iota + PENGUIN_WALK_LEFT + PENGUIN_STATIONARY_RIGHT + PENGUIN_STATIONARY_LEFT PENGUIN_DEFAULT = PENGUIN_STATIONARY_RIGHT ) @@ -28,7 +28,7 @@ func DefinePenguinAnimations() { ) dimensions = sdl.Point{X: 32, Y: 32} - penguinAnimations = make(map[string]*entityAnimation) + penguinAnimations = make(map[int]*entityAnimation) // Walking Right is in the spritesheet. speed = 5 diff --git a/internal/game/entity/player/player.go b/internal/game/entity/player/player.go new file mode 100644 index 0000000..68d20b9 --- /dev/null +++ b/internal/game/entity/player/player.go @@ -0,0 +1,123 @@ +package player + +import ( + "context" + "log" + "time" + + "gitea.wisellama.rocks/Project-Ely/project-ely/internal/channels" + "gitea.wisellama.rocks/Project-Ely/project-ely/internal/game/entity" + "github.com/veandco/go-sdl2/sdl" +) + +// player represents a collection of stuff controlled by the user's input. +// It contains the camera used to view the world, +// the viewport for the part of the screen to draw to (splitscreen support), +// as well as the entity the player is currently controlling. +type player struct { + ctx context.Context + timeout time.Duration + sdlEventsChan chan sdl.KeyboardEvent + + entityChan chan entity.EntityCommand + keystates map[sdl.Keycode]bool +} + +func NewPlayer(ctx context.Context) *player { + sdlEventsChan := make(chan sdl.KeyboardEvent, 10) + defaultTimeout := time.Second + + keystates := make(map[sdl.Keycode]bool) + p := player{ + ctx: ctx, + timeout: defaultTimeout, + sdlEventsChan: sdlEventsChan, + keystates: keystates, + } + return &p +} + +func (p *player) SetEntityChan(e chan entity.EntityCommand) { + p.entityChan = e +} + +func (p *player) GetSdlEventsChan() chan sdl.KeyboardEvent { + return p.sdlEventsChan +} + +func (p *player) Run() { + running := true + for running { + select { + case e := <-p.sdlEventsChan: + go func(event sdl.KeyboardEvent) { + p.HandleWithTimeout(event) + }(e) + case <-p.ctx.Done(): + // Graceful shutdown + running = false + // Finish up anything in the queue + for e := range p.sdlEventsChan { + p.HandleWithTimeout(e) + } + log.Printf("player shutdown\n") + } + } +} + +func (p *player) HandleWithTimeout(event sdl.KeyboardEvent) { + err := channels.RunWithTimeout(p.timeout, func() error { + return p.Handle(event) + }) + if err != nil { + log.Printf("%v\n", err) + } +} + +func (p *player) Handle(e sdl.KeyboardEvent) error { + log.Printf("%v", e) + // Key states (just set a boolean whether the key is actively being pressed) + keystateChanged := false + if e.Type == sdl.KEYDOWN { + if !p.keystates[e.Keysym.Sym] { + keystateChanged = true + } + p.keystates[e.Keysym.Sym] = true + } else if e.Type == sdl.KEYUP { + if p.keystates[e.Keysym.Sym] { + keystateChanged = true + } + p.keystates[e.Keysym.Sym] = false + } + + // Only send events to the entity if something actually changed + if !keystateChanged { + return nil + } + + // Speed + if p.keystates[sdl.K_LSHIFT] { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_SET_SPEED, 4) + } else { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_SET_SPEED, 2) + } + + // Move X + if p.keystates[sdl.K_d] { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_MOVE_X, 1.0) + } else if p.keystates[sdl.K_a] { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_MOVE_X, -1.0) + } else if !p.keystates[sdl.K_d] && !p.keystates[sdl.K_a] { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_MOVE_X, 0.0) + } + + // Move Y + if p.keystates[sdl.K_w] { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_MOVE_Y, -1.0) + } else if p.keystates[sdl.K_s] { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_MOVE_Y, 1.0) + } else if !p.keystates[sdl.K_w] && !p.keystates[sdl.K_s] { + p.entityChan <- entity.NewEntityCommand(entity.COMMAND_MOVE_Y, 0.0) + } + return nil +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..88027fe --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,191 @@ +package game + +import ( + "context" + "fmt" + "log" + "strconv" + "sync" + + "gitea.wisellama.rocks/Project-Ely/project-ely/internal/game/entity" + "gitea.wisellama.rocks/Project-Ely/project-ely/internal/game/entity/player" + "gitea.wisellama.rocks/Project-Ely/project-ely/internal/game/sprite" + "gitea.wisellama.rocks/Project-Ely/project-ely/internal/vector" + "gitea.wisellama.rocks/Wisellama/gosimpleconf" + "github.com/veandco/go-sdl2/sdl" +) + +type Entity interface { + Run() + GetCommandChan() chan entity.EntityCommand + Draw() error + Update() error +} + +// Run is the main function to start the game. +func Run(ctx context.Context, configMap gosimpleconf.ConfigMap) error { + var err error + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + framerate, err := strconv.ParseFloat(configMap["game.framerate"], 64) + if err != nil { + err = fmt.Errorf("error parsing framerate: %w", err) + return err + } + framerateDelay := uint32(1000 / framerate) + + err = SdlInit() + if err != nil { + return err + } + defer SdlQuit() + + gameWindow, err := NewWindow(configMap["game.title"]) + if err != nil { + err = fmt.Errorf("failed creating GameWindow: %w", err) + return err + } + defer gameWindow.Cleanup() + + var renderer *sdl.Renderer + sdl.Do(func() { + renderer, err = sdl.CreateRenderer(gameWindow.SdlWindow, -1, sdl.RENDERER_ACCELERATED) + }) + if err != nil { + err = fmt.Errorf("failed creating SDL renderer: %w", err) + return err + } + defer func() { + sdl.Do(func() { + err = renderer.Destroy() + if err != nil { + log.Printf("error destroying renderer: %v\n", err) + } + }) + }() + + err = sprite.InitSpriteCache(renderer) + if err != nil { + err = fmt.Errorf("failed in InitSpriteCache: %w", err) + return err + } + defer sprite.CleanupSpriteCache() + + entity.DefinePenguinAnimations() + + // Done with main setup, now moving on to creating specific entities + + entityList := make([]Entity, 0) + wg := sync.WaitGroup{} + + // Setup Player 1 + // Let them control a penguin to start with + player1 := player.NewPlayer(ctx) + penguin := entity.NewPenguin(ctx, renderer) + penguin.SetSpeed(2.0) + entityList = append(entityList, penguin) + player1.SetEntityChan(penguin.GetCommandChan()) + wg.Add(1) + go func() { + defer wg.Done() + penguin.Run() + }() + + p2 := entity.NewPenguin(ctx, renderer) + p2.SetPosition(&vector.Vec2F{X: 100, Y: 100}) + p2.SetAnimation(entity.PENGUIN_WALK_LEFT) + entityList = append(entityList, p2) + wg.Add(1) + go func() { + defer wg.Done() + p2.Run() + }() + + // And now starting the main loop + wg.Add(1) + go func() { + defer wg.Done() + player1.Run() + }() + + running := true + for running { + + // Allow us to exit gracefully if the context is done + select { + case <-ctx.Done(): + running = false + default: + // Keep running + } + + // Poll for SDL events + var event sdl.Event + sdl.Do(func() { + event = sdl.PollEvent() + + for event != nil { + switch e := event.(type) { + case *sdl.QuitEvent: + log.Println("QuitEvent quitting") + cancel() + case *sdl.KeyboardEvent: + if e.Keysym.Sym == sdl.K_ESCAPE { + log.Println("Esc quitting") + cancel() + } else { + // Publish the event so other components can do whatever they need with it + player1.GetSdlEventsChan() <- *e + } + } + + event = sdl.PollEvent() + } + }) + + // Background + sdl.Do(func() { + err = renderer.SetDrawColor(0, 120, 0, 255) + if err != nil { + log.Printf("error in renderer.SetDrawColor: %v\n", err) + } + + err = renderer.Clear() + if err != nil { + log.Printf("error in renderer.Clear: %v\n", err) + } + }) + + // Everything else + for _, e := range entityList { + //e.GetCommandChan() <- entity.NewEntityCommand(entity.COMMAND_UPDATE, 0) + //e.GetCommandChan() <- entity.NewEntityCommand(entity.COMMAND_DRAW, 0) + err = e.Update() + if err != nil { + log.Printf("error updating: %v", err) + } + + err = e.Draw() + if err != nil { + log.Printf("error drawing: %v", err) + } + } + + // Draw + sdl.Do(func() { + renderer.Present() + sdl.Delay(framerateDelay) + }) + } + + close(player1.GetSdlEventsChan()) + for _, e := range entityList { + close(e.GetCommandChan()) + } + + wg.Wait() + + return nil +} diff --git a/internal/game/player.go b/internal/game/player.go deleted file mode 100644 index a7d69fe..0000000 --- a/internal/game/player.go +++ /dev/null @@ -1,13 +0,0 @@ -package game - -// player represents a collection of stuff controlled by the user's input. -// It contains the camera used to view the world, -// the viewport for the part of the screen to draw to (splitscreen support), -// as well as the entity the player is currently controlling. -type player struct { -} - -func NewPlayer() *player { - p := player{} - return &p -} diff --git a/main.go b/main.go index e9ba52b..653713e 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,53 @@ package main import ( - "fmt" + "context" "log" "os" + "os/signal" "gitea.wisellama.rocks/Project-Ely/project-ely/internal/config" "gitea.wisellama.rocks/Project-Ely/project-ely/internal/game" - "gitea.wisellama.rocks/Project-Ely/project-ely/internal/game/entity" - "gitea.wisellama.rocks/Project-Ely/project-ely/internal/game/sprite" - "gitea.wisellama.rocks/Project-Ely/project-ely/internal/vector" "gitea.wisellama.rocks/Wisellama/gosimpleconf" "github.com/veandco/go-sdl2/sdl" ) func main() { + // Setup some initial context for gracefully killing + // the program with system interrupt signals like ctrl+c + // https://pace.dev/blog/2020/02/17/repond-to-ctrl-c-interrupt-signals-gracefully-with-context-in-golang-by-mat-ryer.html + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + defer func() { + signal.Stop(signalChan) + cancel() + }() + go func() { + select { + case <-signalChan: + // Graceful exit on ctrl+c + log.Printf("Attempting to exit gracefully...\n") + cancel() + case <-ctx.Done(): + } + <-signalChan // Hard exit on second ctrl+c + log.Printf("Hard kill\n") + os.Exit(2) + }() + + // Run the program + err := run(ctx) + if err != nil { + log.Printf("%v\n", err) + os.Exit(1) + } + + log.Printf("Exited gracefully!\n") +} + +func run(ctx context.Context) error { var err error // Read configuration @@ -35,155 +68,12 @@ func main() { // Start everything with the SDL goroutine context log.Printf("=== Starting %v ===", configMap["game.title"]) sdl.Main(func() { - err = run(configMap) + err = game.Run(ctx, configMap) }) - exitcode := 0 - if err != nil { - log.Printf("ERROR: %v\n", err) - exitcode = 1 - } - - os.Exit(exitcode) -} - -func run(configMap gosimpleconf.ConfigMap) error { - var err error - - err = game.SdlInit() if err != nil { return err } - defer game.SdlQuit() - - gameWindow, err := game.NewWindow(configMap["game.title"]) - if err != nil { - err = fmt.Errorf("failed creating GameWindow: %w", err) - return err - } - defer gameWindow.Cleanup() - - var renderer *sdl.Renderer - sdl.Do(func() { - renderer, err = sdl.CreateRenderer(gameWindow.SdlWindow, -1, sdl.RENDERER_ACCELERATED) - }) - if err != nil { - err = fmt.Errorf("failed creating SDL renderer: %w", err) - return err - } - defer func() { - sdl.Do(func() { - err = renderer.Destroy() - if err != nil { - log.Printf("error destroying renderer: %v\n", err) - } - }) - }() - - err = sprite.InitSpriteCache(renderer) - if err != nil { - err = fmt.Errorf("failed in InitSpriteCache: %w", err) - return err - } - defer sprite.CleanupSpriteCache() - - entity.DefinePenguinAnimations() - - penguin := entity.NewPenguin(renderer) - p2 := entity.NewPenguin(renderer) - p2.SetPosition(&vector.Vec2F{X: 100, Y: 100}) - p2.SetAnimation(entity.PENGUIN_WALK_LEFT) - - keystates := make(map[sdl.Keycode]bool) - - running := true - for running { - var event sdl.Event - sdl.Do(func() { - event = sdl.PollEvent() - }) - for event != nil { - switch e := event.(type) { - case *sdl.QuitEvent: - running = false - case *sdl.KeyboardEvent: - if e.Keysym.Sym == sdl.K_ESCAPE { - log.Println("Esc quitting") - running = false - } - - // Key states (just set a boolean whether the key is actively being pressed) - if e.Type == sdl.KEYDOWN { - keystates[e.Keysym.Sym] = true - } else if e.Type == sdl.KEYUP { - keystates[e.Keysym.Sym] = false - } - } - - sdl.Do(func() { - event = sdl.PollEvent() - }) - } - - if keystates[sdl.K_d] { - penguin.MoveX(1) - } else if keystates[sdl.K_a] { - penguin.MoveX(-1) - } else { - penguin.MoveX(0) - } - - if keystates[sdl.K_w] { - penguin.MoveY(-1) - } else if keystates[sdl.K_s] { - penguin.MoveY(1) - } else { - penguin.MoveY(0) - } - - if !keystates[sdl.K_a] && !keystates[sdl.K_d] && !keystates[sdl.K_w] && !keystates[sdl.K_s] { - penguin.MoveX(0) - penguin.MoveY(0) - } - - // Background - sdl.Do(func() { - err = renderer.SetDrawColor(0, 120, 0, 255) - if err != nil { - log.Printf("error in renderer.SetDrawColor: %v\n", err) - } - - err = renderer.Clear() - if err != nil { - log.Printf("error in renderer.Clear: %v\n", err) - } - }) - - // Everything else - err = penguin.Update() - if err != nil { - log.Printf("error updating: %v\n", err) - } - err = penguin.Draw() - if err != nil { - log.Printf("error drawing: %v\n", err) - } - - err = p2.Update() - if err != nil { - log.Printf("error updating: %v\n", err) - } - err = p2.Draw() - if err != nil { - log.Printf("error drawing: %v\n", err) - } - - // Draw - sdl.Do(func() { - renderer.Present() - sdl.Delay(16) // 60 FPS, well ok 62.5 FPS - }) - } return nil }