diff --git a/TODO.md b/TODO.md index 80a120a..4c040da 100644 --- a/TODO.md +++ b/TODO.md @@ -23,3 +23,7 @@ if inpututil.IsKeyJustPressed(ebiten.KeyS) { } png.Encode(f, screen) } + + +- Add some final message when the player reaches the last level, start + from scratch or a message to buy me some beer, whatever diff --git a/assets/levels/0-own.lvl b/assets/levels/0-own.lvl index c0b0eeb..78add7e 100644 --- a/assets/levels/0-own.lvl +++ b/assets/levels/0-own.lvl @@ -5,13 +5,13 @@ Background: background-lila ######## - #o o #o# + # o # # # S # # # #### # # #### # # # - #o o# + # # ######## diff --git a/assets/levels/1-start.lvl b/assets/levels/1-start.lvl index 74e257e..ec64d47 100644 --- a/assets/levels/1-start.lvl +++ b/assets/levels/1-start.lvl @@ -1,5 +1,5 @@ Description: find the fruit -Background: background-orange +Background: background-lila # ############ @@ -7,7 +7,7 @@ Background: background-orange ############ # # o S # # ######## ### - # + + # ############ # # o # # # # ######## # diff --git a/assets/loader-levels.go b/assets/loader-levels.go index 40218e4..c6b6a0a 100644 --- a/assets/loader-levels.go +++ b/assets/loader-levels.go @@ -28,6 +28,7 @@ type Tile struct { Renderable bool Velocity bool Collectible bool + Obstacle bool Particle int // -1=unused, 0-3 = show image of slice Particles []*ebiten.Image } @@ -64,6 +65,17 @@ func NewTileCollectible(class string) *Tile { } } +func NewTileObstacle(class string) *Tile { + return &Tile{ + Id: '+', + Sprite: Assets[class], + Class: class, + Solid: false, + Renderable: true, + Obstacle: true, + } +} + func NewTileParticle(class []string) *Tile { sprites := []*ebiten.Image{} @@ -109,6 +121,7 @@ func InitTiles() TileRegistry { '#': NewTileBlock("block-grey32"), 'S': NewTilePlayer(), 'o': NewTileCollectible("collectible-orange"), + '+': NewTileObstacle("obstacle-star"), '*': NewTileParticle([]string{ //"particle-ring-1", "particle-ring-2", diff --git a/assets/sprites/obstacle-star.png b/assets/sprites/obstacle-star.png new file mode 100644 index 0000000..b0167c4 Binary files /dev/null and b/assets/sprites/obstacle-star.png differ diff --git a/components/components.go b/components/components.go index 7b5d7b1..882f5fa 100644 --- a/components/components.go +++ b/components/components.go @@ -26,3 +26,4 @@ type Solid struct{} type Floor struct{} type Player struct{} type Collectible struct{} +type Obstacle struct{} diff --git a/game/game.go b/game/game.go index 0820f93..85eeac0 100644 --- a/game/game.go +++ b/game/game.go @@ -3,6 +3,7 @@ package game import ( "fmt" "image" + "log/slog" "openquell/observers" "github.com/hajimehoshi/ebiten/v2" @@ -38,7 +39,7 @@ func NewGame(width, height, cellsize, startlevel int, startscene SceneName) *Gam game.Scenes[Menu] = NewMenuScene(game) game.Scenes[About] = NewAboutScene(game) game.Scenes[Popup] = NewPopupScene(game) - game.Scenes[Play] = NewLevelScene(game, startlevel) + //game.Scenes[Play] = NewLevelScene(game, startlevel) game.Scenes[Select] = NewSelectScene(game) game.CurrentScene = startscene @@ -56,13 +57,20 @@ func (game *Game) Update() error { gameobserver := observers.GetGameObserver(game.World) // handle level ends - // FIXME: add a scene here, which asks: restart, next or abort timer := gameobserver.StopTimer if timer.IsReady() { + // a level is either lost or won, we display a small popup + // asking the user how to continue from here timer.Reset() - gameobserver.CurrentLevel++ - gameobserver.Score++ // FIXME: use level.Score(), see TODO + + slog.Debug("timer ready", "lost", gameobserver.Lost, "retry", gameobserver.Retry) + if !gameobserver.Lost { + gameobserver.Score++ // FIXME: use level.Score(), see TODO + } + + game.Scenes[Nextlevel] = NewNextlevelScene(game, gameobserver.Lost) + game.CurrentScene = Nextlevel } scene := game.GetCurrentScene() @@ -76,8 +84,27 @@ func (game *Game) Update() error { next := scene.GetNext() if next != game.CurrentScene { + if next == Play && game.CurrentScene == Nextlevel { + // switched from nextlevel (lost or won) popup to play (either retry or next level) + if !gameobserver.Retry { + gameobserver.CurrentLevel++ + } + gameobserver.Retry = false + } + + if next == Play { + // fresh setup of actual level every time we enter the play scene + game.Scenes[Play] = NewLevelScene(game, gameobserver.CurrentLevel) + } + + // make sure we stay on the selected scene scene.ResetNext() + + // finally switch game.CurrentScene = next + + // FIXME: add some reset function to gameobserver for these kinds of things + gameobserver.Lost = false } timer.Update() diff --git a/game/levels.go b/game/levels.go index 4e8688d..c38d356 100644 --- a/game/levels.go +++ b/game/levels.go @@ -30,6 +30,7 @@ type Level struct { func NewLevel(game *Game, cellsize int, plan *assets.RawLevel) *Level { gridsystem := systems.NewGridSystem(game.World, game.ScreenWidth, game.ScreenHeight, cellsize, plan.Background) + playersystem := systems.NewPlayerSystem(game.World, gridsystem) return &Level{ diff --git a/game/nextlevel_scene.go b/game/nextlevel_scene.go new file mode 100644 index 0000000..73c0de6 --- /dev/null +++ b/game/nextlevel_scene.go @@ -0,0 +1,117 @@ +package game + +import ( + "image/color" + "log/slog" + "openquell/assets" + "openquell/gameui" + "openquell/observers" + + "github.com/ebitenui/ebitenui" + "github.com/ebitenui/ebitenui/widget" + + "github.com/hajimehoshi/ebiten/v2" +) + +type NextlevelScene struct { + Game *Game + Next SceneName + Whoami SceneName + UseCache bool + Ui *ebitenui.UI + Lost bool +} + +func NewNextlevelScene(game *Game, lost bool) Scene { + scene := &NextlevelScene{ + Whoami: Nextlevel, + Game: game, + Next: Nextlevel, + Lost: lost, + } + + scene.SetupUI() + + return scene +} + +func (scene *NextlevelScene) GetNext() SceneName { + return scene.Next +} + +func (scene *NextlevelScene) ResetNext() { + scene.Next = scene.Whoami +} + +func (scene *NextlevelScene) SetNext(next SceneName) { + slog.Debug("select setnext", "next", next) + scene.Next = next +} + +func (scene *NextlevelScene) Clearscreen() bool { + // both level_scene AND the popup must not clear to get an actual popup + return false +} + +func (scene *NextlevelScene) Update() error { + scene.Ui.Update() + return nil +} + +func (scene *NextlevelScene) Draw(screen *ebiten.Image) { + background := assets.Assets["background-popup"] + + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate( + float64((scene.Game.ScreenWidth/2)-(background.Bounds().Dx()/2)), + float64((scene.Game.ScreenHeight/2)-(background.Bounds().Dy()/2)), + ) + + screen.DrawImage(assets.Assets["background-popup"], op) + + scene.Ui.Draw(screen) +} + +func (scene *NextlevelScene) SetupUI() { + blue := color.RGBA{0, 255, 128, 255} + gameobserver := observers.GetGameObserver(scene.Game.World) + + rowContainer := gameui.NewRowContainer(false) + labeltext := "Success" + + switch scene.Lost { + case true: + labeltext = "Failure" + } + + buttonRetry := gameui.NewMenuButton("Retry", *assets.FontRenderer.FontNormal, + func(args *widget.ButtonClickedEventArgs) { + scene.SetNext(Play) + gameobserver.Retry = true + }) + + buttonNext := gameui.NewMenuButton("Next Level", *assets.FontRenderer.FontNormal, + func(args *widget.ButtonClickedEventArgs) { + scene.SetNext(Play) + gameobserver.Retry = false + }) + + buttonAbort := gameui.NewMenuButton("Abort", *assets.FontRenderer.FontNormal, + func(args *widget.ButtonClickedEventArgs) { + scene.SetNext(Menu) + }) + + label := widget.NewText( + widget.TextOpts.Text(labeltext, *assets.FontRenderer.FontBig, blue), + widget.TextOpts.Position(widget.TextPositionCenter, widget.TextPositionCenter), + ) + + rowContainer.AddChild(label) + rowContainer.AddChild(buttonNext) + rowContainer.AddChild(buttonRetry) + rowContainer.AddChild(buttonAbort) + + scene.Ui = &ebitenui.UI{ + Container: rowContainer.Container(), + } +} diff --git a/game/scene.go b/game/scene.go index faf17fe..eaa2543 100644 --- a/game/scene.go +++ b/game/scene.go @@ -5,13 +5,14 @@ import ( ) const ( - Welcome = iota // startup - Menu // main top level menu - Play // actual playing happens here - About // about the game - Settings // options - Popup // in-game options - Select // select which level to play + Welcome = iota // startup + Menu // main top level menu + Play // actual playing happens here + About // about the game + Settings // options + Popup // in-game options + Select // select which level to play + Nextlevel // displayed after loose or win ) // Wrapper for different screens to be shown, as Welcome, Options, diff --git a/grid/grid.go b/grid/grid.go index 01ae99a..568ec1b 100644 --- a/grid/grid.go +++ b/grid/grid.go @@ -48,6 +48,11 @@ func NewGrid(world *ecs.World, components.Renderable, components.Collectible](world) + obsmapper := generic.NewMap3[ + components.Position, + components.Renderable, + components.Obstacle](world) + var pos *components.Position var render *components.Renderable var speed *components.Speed @@ -66,10 +71,14 @@ func NewGrid(world *ecs.World, pos, _, render, speed, _ = playermapper.Get(entity) playerobserver.AddEntity(entity) speed.Value = config.PLAYERSPEED - slog.Debug("player start pos", "X", point.X*tilesize, "Y", point.Y*tilesize, "Z", 191) + slog.Debug("player start pos", "X", point.X*tilesize, + "Y", point.Y*tilesize, "Z", 191) case tile.Collectible: entity := colmapper.New() pos, render, _ = colmapper.Get(entity) + case tile.Obstacle: + entity := obsmapper.New() + pos, render, _ = obsmapper.Get(entity) default: log.Fatalln("unsupported tile type encountered") } diff --git a/observers/game_observer.go b/observers/game_observer.go index 75ecce2..a68564d 100644 --- a/observers/game_observer.go +++ b/observers/game_observer.go @@ -9,9 +9,12 @@ import ( // Used for global game state type GameObserver struct { - CurrentLevel, Width, Height, Cellsize, Score int - StopTimer *components.Timer - Lost bool // set to true if player is struck or something, by default: win! + CurrentLevel, Width int + Height, Cellsize, Score int + StopTimer *components.Timer + Lost bool // set to true if player is struck or something, by default: win! + Retry bool + NextlevelText string } func NewGameObserver(world *ecs.World, startlevel, width, height, cellsize int) *GameObserver { @@ -34,3 +37,7 @@ func GetGameObserver(world *ecs.World) *GameObserver { observer := world.Resources().Get(observerID).(*GameObserver) return observer } + +func (observer *GameObserver) Gameover() { + observer.Lost = true +} diff --git a/src/star.xcf b/src/star.xcf new file mode 100644 index 0000000..2b5c5c2 Binary files /dev/null and b/src/star.xcf differ diff --git a/systems/collectible_system.go b/systems/collectible_system.go index 14963e9..65a16f8 100644 --- a/systems/collectible_system.go +++ b/systems/collectible_system.go @@ -30,6 +30,7 @@ func NewCollectibleSystem(world *ecs.World) *CollectibleSystem { func (system *CollectibleSystem) Update() { playerobserver := observers.GetPlayerObserver(system.World) + gameobserver := observers.GetGameObserver(system.World) posID := ecs.ComponentID[components.Position](system.World) veloID := ecs.ComponentID[components.Velocity](system.World) @@ -40,7 +41,7 @@ func (system *CollectibleSystem) Update() { query := system.Selector.Query(system.World) numcollectibles := query.Count() - if numcollectibles == 0 { + if numcollectibles == 0 || gameobserver.Lost { query.Close() return } diff --git a/systems/obstacle_system.go b/systems/obstacle_system.go new file mode 100644 index 0000000..56ecfd5 --- /dev/null +++ b/systems/obstacle_system.go @@ -0,0 +1,117 @@ +package systems + +import ( + "log/slog" + "openquell/assets" + "openquell/components" + . "openquell/components" + "openquell/config" + . "openquell/config" + "openquell/observers" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/generic" +) + +type ObstacleSystem struct { + World *ecs.World + Selector *generic.Filter3[Position, Obstacle, Renderable] +} + +func NewObstacleSystem(world *ecs.World) *ObstacleSystem { + system := &ObstacleSystem{ + Selector: generic.NewFilter3[Position, Obstacle, Renderable](), + World: world, + } + + return system +} + +func (system *ObstacleSystem) Update() { + playerobserver := observers.GetPlayerObserver(system.World) + gameobserver := observers.GetGameObserver(system.World) + + if gameobserver.Lost { + return + } + + posID := ecs.ComponentID[components.Position](system.World) + veloID := ecs.ComponentID[components.Velocity](system.World) + + EntitiesToRemove := []ecs.Entity{} + + query := system.Selector.Query(system.World) + gameover := false + + for query.Next() { + obsposition, obstacle, _ := query.Get() + + for player := range playerobserver.Entities { + playerposition := (*Position)(system.World.Get(player, posID)) + playervelocity := (*Velocity)(system.World.Get(player, veloID)) + + ok, _ := playerposition.Intersects(obsposition, playervelocity) + if ok { + slog.Debug("bumped into obstacle", "obstacle", obstacle) + EntitiesToRemove = append(EntitiesToRemove, player) + gameover = true + } + } + } + + for _, entity := range EntitiesToRemove { + system.World.RemoveEntity(entity) + } + + if gameover { + // winner, winner, chicken dinner! + timer := gameobserver.StopTimer + + if !timer.Running { + timer.Start(LEVEL_END_WAIT) + } + + gameobserver.Gameover() + } +} + +func (system *ObstacleSystem) Draw(screen *ebiten.Image) { + // write the movable tiles + op := &ebiten.DrawImageOptions{} + query := system.Selector.Query(system.World) + + for query.Next() { + pos, _, sprite := query.Get() + + op.GeoM.Reset() + op.GeoM.Translate(float64(pos.X), float64(pos.Y)) + + screen.DrawImage(sprite.Image, op) + } +} + +func (system *ObstacleSystem) AddParticle(position *components.Position) { + particleobserver := observers.GetParticleObserver(system.World) + + ptmapper := generic.NewMap3[ + components.Position, + components.Particle, + components.Timer, + ](system.World) + + entity := ptmapper.New() + pos, particle, timer := ptmapper.Get(entity) + particleobserver.AddEntity(entity) + + particle.Index = assets.Tiles['*'].Particle + particle.Particles = assets.Tiles['*'].Particles + + pos.Update( + position.X-(16), // FIXME: use global tilesize! + position.Y-(16), + 64, + ) + + timer.Start(config.PARTICLE_LOOPWAIT) +} diff --git a/systems/player_system.go b/systems/player_system.go index 9b28343..b8a80b2 100644 --- a/systems/player_system.go +++ b/systems/player_system.go @@ -15,6 +15,7 @@ type PlayerSystem struct { Selector *generic.Filter5[Position, Velocity, Player, Renderable, Speed] Particle *ParticleSystem Collectible *CollectibleSystem + Obstacle *ObstacleSystem Grid *GridSystem } @@ -23,6 +24,7 @@ func NewPlayerSystem(world *ecs.World, grid *GridSystem) *PlayerSystem { Selector: generic.NewFilter5[Position, Velocity, Player, Renderable, Speed](), Particle: NewParticleSystem(world, grid.Tilesize), Collectible: NewCollectibleSystem(world), + Obstacle: NewObstacleSystem(world), Grid: grid, World: world, } @@ -75,6 +77,7 @@ func (system PlayerSystem) Update() error { system.Particle.Update() system.Collectible.Update() + system.Obstacle.Update() return nil } @@ -93,6 +96,7 @@ func (system *PlayerSystem) Draw(screen *ebiten.Image) { screen.DrawImage(sprite.Image, op) } + system.Obstacle.Draw(screen) system.Collectible.Draw(screen) system.Particle.Draw(screen) }