diff --git a/TODO.md b/TODO.md index 600e787..e448b58 100644 --- a/TODO.md +++ b/TODO.md @@ -32,4 +32,10 @@ if inpututil.IsKeyJustPressed(ebiten.KeyS) { velocity and included snap in so that the player ends up right on the edge of the obstacle and not inside as it is now -- Check player-player collisions! +- Check player-player collisions! + +- Do all collision detections in ONE system + +- do not use the map anymore for collision detection + +- check swept AABB instead of my collision detection, to allow for higher speeds diff --git a/assets/levels/00-deadly-obstacles.lvl b/assets/levels/00-deadly-obstacles.lvl index 710e398..41dafbc 100644 --- a/assets/levels/00-deadly-obstacles.lvl +++ b/assets/levels/00-deadly-obstacles.lvl @@ -4,7 +4,7 @@ Background: background-lila ####### - # # + #o # # t # #> S <# # # diff --git a/assets/levels/04-mayhem.lvl b/assets/levels/04-mayhem.lvl index db2966d..52353b9 100644 --- a/assets/levels/04-mayhem.lvl +++ b/assets/levels/04-mayhem.lvl @@ -4,13 +4,13 @@ Background: background-lila - ########## - # v# + ########W# + # v # # # #S s# - # # - #^ # - ########## + ############ + # o # + ######## # diff --git a/assets/loader-levels.go b/assets/loader-levels.go index f75ec09..5defb2a 100644 --- a/assets/loader-levels.go +++ b/assets/loader-levels.go @@ -25,18 +25,42 @@ type Tile struct { Id byte Sprite *ebiten.Image Class string - Solid bool - Player bool - IsPrimary bool - Renderable bool - Velocity bool - Collectible bool - Transient bool - Particle int // -1=unused, 0-3 = show image of slice - Tiles []*ebiten.Image - TileNames []string // same thing, only the names - Obstacle bool - Direction int // obstacles + Solid bool // wall brick + Player bool // player sphere + IsPrimary bool // primary player sphere + Renderable bool // visible, has sprite + Velocity bool // movable + Collectible bool // collectible, vanishes once collected + Transient bool // turns into brick wall when traversed + Destroyable bool // turns into empty floor when bumped into twice + Particle int // -1=unused, 0-3 = show image of slice + Tiles []*ebiten.Image // has N sprites + TileNames []string // same thing, only the names + Obstacle bool // is an obstacle/enemy + Direction int // obstacle business end shows into this direction +} + +func (tile *Tile) Clone() *Tile { + newtile := &Tile{ + Id: tile.Id, + Sprite: tile.Sprite, + Class: tile.Class, + Solid: tile.Solid, + Player: tile.Player, + IsPrimary: tile.IsPrimary, + Renderable: tile.Renderable, + Velocity: tile.Velocity, + Collectible: tile.Collectible, + Transient: tile.Transient, + Destroyable: tile.Destroyable, + Particle: tile.Particle, + Tiles: tile.Tiles, + TileNames: tile.TileNames, + Obstacle: tile.Obstacle, + Direction: tile.Direction, + } + + return newtile } const ( @@ -128,7 +152,7 @@ func NewTileTranswall(class []string) *Tile { } return &Tile{ - Id: '*', + Id: 't', Class: "transwall", Solid: false, Renderable: true, @@ -139,6 +163,27 @@ func NewTileTranswall(class []string) *Tile { } } +func NewTileHiddenDoor(class []string) *Tile { + sprites := []*ebiten.Image{} + names := []string{} + + for _, sprite := range class { + sprites = append(sprites, Assets[sprite]) + names = append(names, sprite) + } + + return &Tile{ + Id: 'W', + Class: "hiddendoor", + Solid: false, + Renderable: true, + Destroyable: true, + Tiles: sprites, + Sprite: sprites[0], // initially use the first + TileNames: names, + } +} + // used to map level data bytes to actual tiles type TileRegistry map[byte]*Tile @@ -164,7 +209,8 @@ type RawLevel struct { func InitTiles() TileRegistry { return TileRegistry{ ' ': {Id: ' ', Class: "floor", Renderable: false}, - '#': NewTileBlock("block-grey32"), + //'#': NewTileBlock("block-grey32"), + '#': NewTileBlock("block-greycolored"), 'B': NewTileBlock("block-orange-32"), 'S': NewTilePlayer(Primary), 's': NewTilePlayer(Secondary), @@ -183,6 +229,7 @@ func InitTiles() TileRegistry { "particle-ring-6", }), 't': NewTileTranswall([]string{"transwall", "block-orange-32"}), + 'W': NewTileHiddenDoor([]string{"block-greycolored", "block-greycolored-damaged"}), } } diff --git a/assets/sprites/block-greycolored-damaged.png b/assets/sprites/block-greycolored-damaged.png new file mode 100644 index 0000000..aa9df25 Binary files /dev/null and b/assets/sprites/block-greycolored-damaged.png differ diff --git a/assets/sprites/block-greycolored.png b/assets/sprites/block-greycolored.png new file mode 100644 index 0000000..a72c4d0 Binary files /dev/null and b/assets/sprites/block-greycolored.png differ diff --git a/components/destroyable.go b/components/destroyable.go new file mode 100644 index 0000000..f29f375 --- /dev/null +++ b/components/destroyable.go @@ -0,0 +1,29 @@ +package components + +import ( + "log" + + "github.com/hajimehoshi/ebiten/v2" +) + +// A hidden door in a wall. If the player bumps into it once, it shows +// damage and it vanishes the next time. + +type Destroyable struct { + Activated bool + Sprites []*ebiten.Image + Current int // sprite index +} + +func (door *Destroyable) GetNext() *ebiten.Image { + if len(door.Sprites) > door.Current { + door.Current++ + return door.Sprites[door.Current] + } + + log.Fatalf("not enough sprites in transient tile, have %d sprites, index requested: %d", + len(door.Sprites), door.Current+1, + ) + + return nil +} diff --git a/game/game.go b/game/game.go index b75ca31..ba16f05 100644 --- a/game/game.go +++ b/game/game.go @@ -96,7 +96,6 @@ func (game *Game) Update() error { if next == Play { // fresh setup of actual level every time we enter the play scene - //game.Scenes[Play] = NewLevelScene(game, gameobserver.CurrentLevel) game.Scenes[Play].SetLevel(gameobserver.CurrentLevel) } diff --git a/game/levels.go b/game/levels.go index 69e7047..4cb52e8 100644 --- a/game/levels.go +++ b/game/levels.go @@ -3,6 +3,7 @@ package game import ( "image" "log" + "log/slog" "openquell/assets" "openquell/components" "openquell/grid" @@ -15,12 +16,16 @@ import ( "github.com/mlange-42/arche/ecs" ) +type Map map[image.Point]*assets.Tile +type BackupMap map[image.Point]assets.Tile + type Level struct { Cellsize, Width, Height int World *ecs.World Name string Description string - Mapslice map[image.Point]*assets.Tile + Mapslice Map + BackupMapslice Map GridContainer *grid.GridContainer Systems []systems.System Grid *grid.Grid @@ -45,16 +50,21 @@ func NewLevel(game *Game, cellsize int, plan *assets.RawLevel) *Level { systemlist = append(systemlist, systems.NewTransientSystem(game.World, gridcontainer)) + systemlist = append(systemlist, systems.NewDestroyableSystem(game.World, gridcontainer)) + + mapslice, backupmap := LevelToSlice(game, plan, cellsize) + return &Level{ - Mapslice: LevelToSlice(game, plan, cellsize), - Cellsize: cellsize, - World: game.World, - Width: game.ScreenWidth, - Height: game.ScreenHeight, - Description: plan.Description, - Name: plan.Name, - GridContainer: gridcontainer, - Systems: systemlist, + Mapslice: mapslice, + BackupMapslice: backupmap, + Cellsize: cellsize, + World: game.World, + Width: game.ScreenWidth, + Height: game.ScreenHeight, + Description: plan.Description, + Name: plan.Name, + GridContainer: gridcontainer, + Systems: systemlist, } } @@ -77,6 +87,12 @@ func (level *Level) Position2Point(position *components.Position) image.Point { } } +func (level *Level) RestoreMap() { + for point, tile := range level.BackupMapslice { + level.Mapslice[point] = tile.Clone() + } +} + func (level *Level) SetupGrid(game *Game) { // generic variant does not work here: // selector := generic.NewFilter1[components.Position]() @@ -92,15 +108,20 @@ func (level *Level) SetupGrid(game *Game) { playerobserver := observers.GetPlayerObserver(level.World) playerobserver.RemoveEntities() + // get rid of possibly manipulated map + level.RestoreMap() + // setup world + slog.Debug("new grid?") level.GridContainer.SetGrid( grid.NewGrid(game.World, level.Cellsize, level.Width, level.Height, level.Mapslice)) } // parses a RawLevel and generates a mapslice from it, which is being used as grid -func LevelToSlice(game *Game, level *assets.RawLevel, tilesize int) map[image.Point]*assets.Tile { +func LevelToSlice(game *Game, level *assets.RawLevel, tilesize int) (Map, Map) { size := game.ScreenWidth * game.ScreenHeight - mapslice := make(map[image.Point]*assets.Tile, size) + mapslice := make(Map, size) + backupmap := make(Map, size) for y, line := range strings.Split(string(level.Data), "\n") { if len(line) != game.ScreenWidth/tilesize && y < game.ScreenHeight/tilesize { @@ -113,9 +134,11 @@ func LevelToSlice(game *Game, level *assets.RawLevel, tilesize int) map[image.Po log.Fatalf("unregistered tile type %c encountered", char) } - mapslice[image.Point{x, y}] = assets.Tiles[byte(char)] + tile := assets.Tiles[byte(char)] + mapslice[image.Point{x, y}] = tile + backupmap[image.Point{x, y}] = tile.Clone() } } - return mapslice + return mapslice, backupmap } diff --git a/grid/grid.go b/grid/grid.go index 7870f96..5c6edc1 100644 --- a/grid/grid.go +++ b/grid/grid.go @@ -61,12 +61,18 @@ func NewGrid(world *ecs.World, components.Renderable, components.Transient](world) + doormapper := generic.NewMap3[ + components.Position, + components.Renderable, + components.Destroyable](world) + var pos *components.Position var vel *components.Velocity var render *components.Renderable var speed *components.Speed var transient *components.Transient var player *components.Player + var destroyable *components.Destroyable playerobserver := observers.GetPlayerObserver(world) obstacleobserver := observers.GetObstacleObserver(world) @@ -99,6 +105,10 @@ func NewGrid(world *ecs.World, entity := transmapper.New() pos, render, transient = transmapper.Get(entity) transient.Sprites = tile.TileNames + case tile.Destroyable: + entity := doormapper.New() + pos, render, destroyable = doormapper.Get(entity) + destroyable.Sprites = tile.Tiles default: log.Fatalln("unsupported tile type encountered") } @@ -143,7 +153,15 @@ func (grid *Grid) GetTile( return tile } -func (grid *Grid) SetTile(tile *assets.Tile, point image.Point) { +func (grid *Grid) RemoveTile(point image.Point) { + delete(grid.Map, point) +} + +func (grid *Grid) SetFloorTile(point image.Point) { + grid.Map[point] = assets.Tiles[' '] +} + +func (grid *Grid) SetSolidTile(tile *assets.Tile, point image.Point) { solidmapper := generic.NewMap4[ components.Position, components.Renderable, diff --git a/src/block-grey-damage.xcf b/src/block-grey-damage.xcf new file mode 100644 index 0000000..728eb2d Binary files /dev/null and b/src/block-grey-damage.xcf differ diff --git a/src/block-grey.xcf b/src/block-grey.xcf index 7d4da3d..4f11d52 100644 Binary files a/src/block-grey.xcf and b/src/block-grey.xcf differ diff --git a/systems/destroyable_system.go b/systems/destroyable_system.go new file mode 100644 index 0000000..45db9b3 --- /dev/null +++ b/systems/destroyable_system.go @@ -0,0 +1,108 @@ +package systems + +import ( + "log/slog" + "openquell/components" + . "openquell/components" + . "openquell/config" + + "openquell/grid" + "openquell/observers" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/generic" +) + +type DestroyableSystem struct { + World *ecs.World + Selector *generic.Filter3[Position, Renderable, Destroyable] + GridContainer *grid.GridContainer + SolidMapper generic.Map4[ // needed for replacement + components.Position, + components.Renderable, + components.Tilish, + components.Solid] +} + +type DoorToRemove struct { + Entity ecs.Entity + Position *components.Position +} + +func NewDestroyableSystem(world *ecs.World, gridcontainer *grid.GridContainer) System { + solidmapper := generic.NewMap4[ + components.Position, + components.Renderable, + components.Tilish, + components.Solid](world) + + system := &DestroyableSystem{ + Selector: generic.NewFilter3[Position, Renderable, Destroyable](), + World: world, + GridContainer: gridcontainer, + SolidMapper: solidmapper, + } + + return system +} + +func (system *DestroyableSystem) Update() error { + playerobserver := observers.GetPlayerObserver(system.World) + posID := ecs.ComponentID[components.Position](system.World) + veloID := ecs.ComponentID[components.Velocity](system.World) + + query := system.Selector.Query(system.World) + + EntitiestoRemove := []*DoorToRemove{} + + for query.Next() { + doorposition, renderable, door := query.Get() + + for player := range playerobserver.Entities { + playerposition := (*Position)(system.World.Get(player, posID)) + playervelocity := (*Velocity)(system.World.Get(player, veloID)) + + ok, newpos := doorposition.Intersects(playerposition, playervelocity) + if ok { + // player bumped into hidden wall, activate it, snap in player + slog.Debug("bump not die", "originalpos", playerposition) + playervelocity.Change(Stop) + playerposition.Set(newpos) + + if door.Activated { + // player bumps into the door a second time, now hide it + EntitiestoRemove = append(EntitiestoRemove, + &DoorToRemove{Entity: query.Entity(), Position: doorposition}) + } else { + slog.Debug("activating destroyable", "doorpos", doorposition) + door.Activated = true + renderable.Image = door.GetNext() + } + } + } + } + + for _, door := range EntitiestoRemove { + slog.Debug("hiding destroyable", "doorpos", door.Position.Point()) + + // remove door entity + system.World.RemoveEntity(door.Entity) + } + + return nil +} + +func (system *DestroyableSystem) Draw(screen *ebiten.Image) { + // write transients (these are no tiles!) + op := &ebiten.DrawImageOptions{} + query := system.Selector.Query(system.World) + + for query.Next() { + pos, render, _ := query.Get() + + op.GeoM.Reset() + op.GeoM.Translate(float64(pos.X), float64(pos.Y)) + screen.DrawImage(render.Image, op) + } +} diff --git a/systems/grid_system.go b/systems/grid_system.go index ffcda72..495a1f6 100644 --- a/systems/grid_system.go +++ b/systems/grid_system.go @@ -3,6 +3,7 @@ package systems import ( "image" "image/draw" + "log/slog" . "openquell/components" "github.com/hajimehoshi/ebiten/v2" @@ -44,8 +45,8 @@ func (system *GridSystem) Update() error { return nil } func (system *GridSystem) Draw(screen *ebiten.Image) { op := &ebiten.DrawImageOptions{} query := system.Selector.Query(system.World) - if !system.UseCache || query.Count() != system.Count { + slog.Debug("entity number changes", "old", system.Count, "current", query.Count()) // map not cached or cacheable, write it to the cache draw.Draw(system.Cache, system.Background.Bounds(), system.Background, image.ZP, draw.Src) @@ -54,6 +55,7 @@ func (system *GridSystem) Draw(screen *ebiten.Image) { for query.Next() { sprite, pos, _ := query.Get() + //slog.Debug("drawing sprite", "sprite", sprite, "point", pos.Point()) draw.Draw( system.Cache, image.Rect(pos.X, pos.Y, pos.X+pos.Cellsize, pos.Y+pos.Cellsize), diff --git a/systems/transient_system.go b/systems/transient_system.go index 1a03437..5e4bcc0 100644 --- a/systems/transient_system.go +++ b/systems/transient_system.go @@ -95,7 +95,7 @@ func (system *TransientSystem) Update() error { // also setup the grid tile with a new solid, so that // collision detection works - system.GridContainer.Grid.SetTile( + system.GridContainer.Grid.SetSolidTile( assets.NewTileBlock(convertible.NewSprite), convertible.Position.Point(), )