From 28cf513b6dc8c07ee4ef84c13813dc825efdbd72 Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Sun, 12 May 2024 23:22:39 +0300 Subject: [PATCH] Add pathfinding --- engine/path.go | 40 ++++++ engine/pathfinding.go | 131 ++++++++++++++++++ engine/util.go | 35 ++++- game/logic/logic_snippet.go | 2 +- game/model/npc.go | 39 ++++++ game/state/dialog_state.go | 52 +++++++ game/state/playing_state.go | 202 ++++++++++++++++++++------- game/ui/border_button.go | 61 -------- game/ui/dialog.go | 123 +++++++++++++++++ game/ui/ui.go | 2 +- game/world/bsp_map.go | 17 ++- game/world/dungeon.go | 230 ++++++++++++++++++++++++++++++- game/world/empty_map.go | 27 +++- game/world/entity_map.go | 23 +++- game/world/generate_bsp_map.go | 12 +- game/world/generate_empty_map.go | 3 + game/world/generate_items.go | 7 +- game/world/map.go | 20 ++- game/world/multilevel_map.go | 4 +- game/world/tile.go | 22 +++ 20 files changed, 920 insertions(+), 132 deletions(-) create mode 100644 engine/path.go create mode 100644 engine/pathfinding.go create mode 100644 game/model/npc.go create mode 100644 game/state/dialog_state.go delete mode 100644 game/ui/border_button.go create mode 100644 game/ui/dialog.go diff --git a/engine/path.go b/engine/path.go new file mode 100644 index 0000000..d21acd1 --- /dev/null +++ b/engine/path.go @@ -0,0 +1,40 @@ +package engine + +type Path struct { + from Position + to Position + + path []Position + currentPos int +} + +func CreatePath(from, to Position, path []Position) *Path { + return &Path{ + from: from, + to: to, + path: path, + currentPos: 0, + } +} + +func (p *Path) From() Position { + return p.from +} + +func (p *Path) To() Position { + return p.to +} + +func (p *Path) CurrentPosition() Position { + return p.path[p.currentPos] +} + +func (p *Path) Next() (current Position, hasNext bool) { + if p.currentPos+1 >= len(p.path) { + return p.CurrentPosition(), false + } + + p.currentPos++ + + return p.CurrentPosition(), true +} diff --git a/engine/pathfinding.go b/engine/pathfinding.go new file mode 100644 index 0000000..2c7674a --- /dev/null +++ b/engine/pathfinding.go @@ -0,0 +1,131 @@ +package engine + +import ( + "math" + "slices" +) + +type pathNode struct { + pos Position + + parent *pathNode + + g float64 // distance between current node and start node + h float64 // heuristic - squared distance from current node to end node + f float64 // total cost of this node +} + +func FindPath(from Position, to Position, maxDistance int, isPassable func(x, y int) bool) *Path { + maxDistanceSquared := math.Pow(float64(maxDistance), 2) + + var openList = make([]*pathNode, 0) + var closedList = make([]*pathNode, 0) + + openList = append(openList, &pathNode{ + pos: from, + parent: nil, + g: 0, + h: 0, + f: 0, + }) + + var lastNode *pathNode + + for { + if len(openList) == 0 { + break + } + + // find node in open list with lowest f value, remove it from open and move it to closed + currentIndex := 0 + currentNode := openList[currentIndex] + + for i, node := range openList { + if node.f < currentNode.f { + currentNode = node + currentIndex = i + } + } + + if currentNode.pos.Equals(to) { + lastNode = currentNode + break // We have reached the goal + } + + openList = slices.Delete(openList, currentIndex, currentIndex+1) + closedList = append(closedList, currentNode) + + // use adjacent nodes as children + children := []*pathNode{ + { + pos: currentNode.pos.WithOffset(1, 0), + parent: currentNode, + }, + { + pos: currentNode.pos.WithOffset(-1, 0), + parent: currentNode, + }, + { + pos: currentNode.pos.WithOffset(0, 1), + parent: currentNode, + }, + { + pos: currentNode.pos.WithOffset(0, -1), + parent: currentNode, + }, + } + + for _, child := range children { + // If the child is impassable, skip it + if !isPassable(child.pos.XY()) { + continue + } + + // If child is already contained in closedList, skip it + if slices.ContainsFunc(closedList, func(el *pathNode) bool { return el.pos.Equals(child.pos) }) { + continue + } + + child.g = currentNode.g + 1 + + if child.g > maxDistanceSquared { + continue + } + + child.h = to.DistanceSquared(child.pos) + child.f = float64(child.g) + child.f + + // If child is already contained in openList, and has lower g + if slices.ContainsFunc(openList, func(el *pathNode) bool { return el.pos.Equals(child.pos) && child.g > el.g }) { + continue + } + + openList = append(openList, child) + } + + } + + if lastNode == nil { + return nil + } + + node := lastNode + path := make([]Position, 0) + + for { + path = append(path, node.pos) + + if node.parent == nil { + break + } + + node = node.parent + } + + slices.Reverse(path) + + return CreatePath( + from, to, + path, + ) +} diff --git a/engine/util.go b/engine/util.go index e3cbac2..e3b6294 100644 --- a/engine/util.go +++ b/engine/util.go @@ -1,6 +1,9 @@ package engine -import "math/rand" +import ( + "math" + "math/rand" +) type Positioned struct { pos Position @@ -16,6 +19,10 @@ func (wp *Positioned) Position() Position { return wp.pos } +func (wp *Positioned) SetPosition(pos Position) { + wp.pos = pos +} + type Position struct { x int y int @@ -37,6 +44,18 @@ func (p Position) XY() (int, int) { return p.x, p.y } +func (p Position) DistanceSquared(pos Position) float64 { + return float64((pos.x-p.x)*(pos.x-p.x) + (pos.y-p.y)*(pos.y-p.y)) +} + +func (p Position) Distance(pos Position) float64 { + return math.Sqrt(p.DistanceSquared(pos)) +} + +func (p Position) Equals(other Position) bool { + return p.x == other.x && p.y == other.y +} + func (p Position) WithOffset(xOffset int, yOffset int) Position { p.x = p.x + xOffset p.y = p.y + yOffset @@ -91,6 +110,10 @@ func (s Size) AsArrayIndex(x, y int) int { return y*s.width + x } +func (s Size) Contains(x, y int) bool { + return 0 <= x && x < s.width && 0 <= y && y < s.height +} + func LimitIncrement(i int, limit int) int { if (i + 1) > limit { return i @@ -110,3 +133,13 @@ func LimitDecrement(i int, limit int) int { func RandInt(min, max int) int { return min + rand.Intn(max-min) } + +func MapSlice[S ~[]E, E any, R any](slice S, mappingFunc func(e E) R) []R { + newSlice := make([]R, 0, len(slice)) + + for _, el := range slice { + newSlice = append(newSlice, mappingFunc(el)) + } + + return newSlice +} diff --git a/game/logic/logic_snippet.go b/game/logic/logic_snippet.go index 7786ee3..c17eab0 100644 --- a/game/logic/logic_snippet.go +++ b/game/logic/logic_snippet.go @@ -1,4 +1,4 @@ -package model +package logic import ( "mvvasilev/last_light/game/model" diff --git a/game/model/npc.go b/game/model/npc.go new file mode 100644 index 0000000..ee3043b --- /dev/null +++ b/game/model/npc.go @@ -0,0 +1,39 @@ +package model + +import ( + "mvvasilev/last_light/engine" + + "github.com/gdamore/tcell/v2" + "github.com/google/uuid" +) + +type NPC struct { + id uuid.UUID + engine.Positioned +} + +func CreateNPC(pos engine.Position) *NPC { + return &NPC{ + id: uuid.New(), + Positioned: engine.WithPosition(pos), + } +} + +func (c *NPC) Position() engine.Position { + return c.Positioned.Position() +} + +func (c *NPC) MoveTo(newPosition engine.Position) { + c.Positioned.SetPosition(newPosition) +} + +func (c *NPC) UniqueId() uuid.UUID { + return c.id +} + +func (c *NPC) Input(e *tcell.EventKey) { + +} + +func (c *NPC) Tick(dt int64) { +} diff --git a/game/state/dialog_state.go b/game/state/dialog_state.go new file mode 100644 index 0000000..4dce277 --- /dev/null +++ b/game/state/dialog_state.go @@ -0,0 +1,52 @@ +package state + +import ( + "mvvasilev/last_light/engine" + "mvvasilev/last_light/game/ui" + + "github.com/gdamore/tcell/v2" +) + +type DialogState struct { + prevState GameState + + dialog *ui.UIDialog + + selectDialog bool + returnToPreviousState bool +} + +func CreateDialogState(dialog *ui.UIDialog, prevState GameState) *DialogState { + return &DialogState{ + prevState: prevState, + dialog: dialog, + returnToPreviousState: false, + } +} + +func (ds *DialogState) OnInput(e *tcell.EventKey) { + if e.Key() == tcell.KeyEnter { + ds.selectDialog = true + return + } + + ds.dialog.Input(e) +} + +func (ds *DialogState) OnTick(dt int64) GameState { + if ds.selectDialog { + ds.selectDialog = false + ds.returnToPreviousState = true + ds.dialog.Select() + } + + if ds.returnToPreviousState { + return ds.prevState + } + + return ds +} + +func (ds *DialogState) CollectDrawables() []engine.Drawable { + return append(ds.prevState.CollectDrawables(), ds.dialog) +} diff --git a/game/state/playing_state.go b/game/state/playing_state.go index 4ae61e4..9485926 100644 --- a/game/state/playing_state.go +++ b/game/state/playing_state.go @@ -3,6 +3,7 @@ package state import ( "mvvasilev/last_light/engine" "mvvasilev/last_light/game/model" + "mvvasilev/last_light/game/ui" "mvvasilev/last_light/game/world" "github.com/gdamore/tcell/v2" @@ -10,9 +11,10 @@ import ( ) type PlayingState struct { - player *model.Player - entityMap *world.EntityMap - level *world.MultilevelMap + player *model.Player + someNPC *model.NPC + + dungeon *world.Dungeon viewport *engine.Viewport @@ -20,6 +22,10 @@ type PlayingState struct { pauseGame bool openInventory bool pickUpUnderPlayer bool + interact bool + moveEntities bool + + nextGameState GameState } func BeginPlayingState() *PlayingState { @@ -27,46 +33,24 @@ func BeginPlayingState() *PlayingState { mapSize := engine.SizeOf(128, 128) - dungeonLevel := world.CreateBSPDungeonMap(mapSize.Width(), mapSize.Height(), 4) + s.dungeon = world.CreateDungeon(mapSize.Width(), mapSize.Height(), 1) - genTable := make(map[float32]*model.ItemType, 0) + s.player = model.CreatePlayer(s.dungeon.CurrentLevel().PlayerSpawnPoint().XY()) - genTable[0.2] = model.ItemTypeFish() - genTable[0.05] = model.ItemTypeBow() - genTable[0.051] = model.ItemTypeLongsword() - genTable[0.052] = model.ItemTypeKey() + s.someNPC = model.CreateNPC(s.dungeon.CurrentLevel().NextLevelStaircase()) - itemTiles := world.SpawnItems(dungeonLevel.Rooms(), 0.01, genTable) - - itemLevel := world.CreateEmptyDungeonLevel(mapSize.Width(), mapSize.Height()) - - for _, it := range itemTiles { - if !dungeonLevel.TileAt(it.Position().XY()).Passable() { - continue - } - - itemLevel.SetTileAt(it.Position().X(), it.Position().Y(), it) - } - - s.player = model.CreatePlayer(dungeonLevel.PlayerSpawnPoint().XY()) - - s.entityMap = world.CreateEntityMap(mapSize.WH()) - - s.level = world.CreateMultilevelMap( - dungeonLevel, - itemLevel, - s.entityMap, - ) - - s.entityMap.AddEntity(s.player, '@', tcell.StyleDefault) + s.dungeon.CurrentLevel().AddEntity(s.player, '@', tcell.StyleDefault) + s.dungeon.CurrentLevel().AddEntity(s.someNPC, 'N', tcell.StyleDefault) s.viewport = engine.CreateViewport( engine.PositionAt(0, 0), - dungeonLevel.PlayerSpawnPoint(), + s.dungeon.CurrentLevel().PlayerSpawnPoint(), engine.SizeOf(80, 24), tcell.StyleDefault, ) + s.nextGameState = s + return s } @@ -88,36 +72,143 @@ func (ps *PlayingState) MovePlayer() { } newPlayerPos := ps.player.Position().WithOffset(model.MovementDirectionOffset(ps.movePlayerDirection)) - tileAtMovePos := ps.level.TileAt(newPlayerPos.XY()) - if tileAtMovePos.Passable() { + if ps.dungeon.CurrentLevel().IsTilePassable(newPlayerPos.XY()) { dx, dy := model.MovementDirectionOffset(ps.movePlayerDirection) - ps.entityMap.MoveEntity(ps.player.UniqueId(), dx, dy) + ps.dungeon.CurrentLevel().MoveEntity(ps.player.UniqueId(), dx, dy) ps.viewport.SetCenter(ps.player.Position()) } ps.movePlayerDirection = model.DirectionNone } +func (ps *PlayingState) InteractBelowPlayer() { + playerPos := ps.player.Position() + + if playerPos == ps.dungeon.CurrentLevel().NextLevelStaircase() { + ps.SwitchToNextLevel() + return + } + + if playerPos == ps.dungeon.CurrentLevel().PreviousLevelStaircase() { + ps.SwitchToPreviousLevel() + return + } +} + +func (ps *PlayingState) SwitchToNextLevel() { + if !ps.dungeon.HasNextLevel() { + ps.nextGameState = CreateDialogState( + ui.CreateOkDialog( + "The Unknown Depths", + "The staircases descent down to the lower levels is seemingly blocked by multiple large boulders. They appear immovable.", + "Continue", + 40, + func() { + ps.nextGameState = ps + }, + ), + ps, + ) + + return + } + + ps.dungeon.CurrentLevel().DropEntity(ps.player.UniqueId()) + + ps.dungeon.MoveToNextLevel() + + ps.player.MoveTo(ps.dungeon.CurrentLevel().PlayerSpawnPoint()) + + ps.viewport = engine.CreateViewport( + engine.PositionAt(0, 0), + ps.dungeon.CurrentLevel().PlayerSpawnPoint(), + engine.SizeOf(80, 24), + tcell.StyleDefault, + ) + + ps.dungeon.CurrentLevel().AddEntity(ps.player, '@', tcell.StyleDefault) +} + +func (ps *PlayingState) SwitchToPreviousLevel() { + if !ps.dungeon.HasPreviousLevel() { + ps.nextGameState = CreateDialogState( + ui.CreateOkDialog( + "The Surface", + "You feel the gentle, yet chilling breeze of the surface make its way through the weaving cavern tunnels, the very same you had to make your way through to get where you are. There is nothing above that you need. Find the last light, or die trying.", + "Continue", + 40, + func() { + ps.nextGameState = ps + }, + ), + ps, + ) + + return + } + + ps.dungeon.CurrentLevel().DropEntity(ps.player.UniqueId()) + + ps.dungeon.MoveToPreviousLevel() + + ps.player.MoveTo(ps.dungeon.CurrentLevel().NextLevelStaircase()) + + ps.viewport = engine.CreateViewport( + engine.PositionAt(0, 0), + ps.dungeon.CurrentLevel().NextLevelStaircase(), + engine.SizeOf(80, 24), + tcell.StyleDefault, + ) + + ps.dungeon.CurrentLevel().AddEntity(ps.player, '@', tcell.StyleDefault) +} + func (ps *PlayingState) PickUpItemUnderPlayer() { pos := ps.player.Position() - tile := ps.level.TileAtHeight(pos.X(), pos.Y(), 1) + item := ps.dungeon.CurrentLevel().RemoveItemAt(pos.XY()) - itemTile, ok := tile.(*world.ItemTile) - - if !ok { + if item == nil { return } - item := model.CreateItem(itemTile.Type(), itemTile.Quantity()) - - success := ps.player.Inventory().Push(item) + success := ps.player.Inventory().Push(*item) if !success { + ps.dungeon.CurrentLevel().SetItemAt(pos.X(), pos.Y(), *item) + } +} + +func (ps *PlayingState) CalcPathToPlayerAndMove() { + distanceToPlayer := ps.someNPC.Position().Distance(ps.player.Position()) + if distanceToPlayer > 16 { return } - ps.level.SetTileAtHeight(pos.X(), pos.Y(), 1, nil) + pathToPlayer := engine.FindPath( + ps.someNPC.Position(), + ps.player.Position(), + 16, + func(x, y int) bool { + if x == ps.player.Position().X() && y == ps.player.Position().Y() { + return true + } + + return ps.dungeon.CurrentLevel().IsTilePassable(x, y) + }, + ) + + nextPos, hasNext := pathToPlayer.Next() + + if !hasNext { + return + } + + if nextPos.Equals(ps.player.Position()) { + return + } + + ps.dungeon.CurrentLevel().MoveEntityTo(ps.someNPC.UniqueId(), nextPos.X(), nextPos.Y()) } func (ps *PlayingState) OnInput(e *tcell.EventKey) { @@ -138,15 +229,24 @@ func (ps *PlayingState) OnInput(e *tcell.EventKey) { return } + if e.Key() == tcell.KeyRune && e.Rune() == 'e' { + ps.interact = true + return + } + switch e.Key() { case tcell.KeyUp: ps.movePlayerDirection = model.DirectionUp + ps.moveEntities = true case tcell.KeyDown: ps.movePlayerDirection = model.DirectionDown + ps.moveEntities = true case tcell.KeyLeft: ps.movePlayerDirection = model.DirectionLeft + ps.moveEntities = true case tcell.KeyRight: ps.movePlayerDirection = model.DirectionRight + ps.moveEntities = true } } @@ -171,13 +271,23 @@ func (ps *PlayingState) OnTick(dt int64) GameState { ps.PickUpItemUnderPlayer() } - return ps + if ps.interact { + ps.interact = false + ps.InteractBelowPlayer() + } + + if ps.moveEntities { + ps.moveEntities = false + ps.CalcPathToPlayerAndMove() + } + + return ps.nextGameState } func (ps *PlayingState) CollectDrawables() []engine.Drawable { return engine.Multidraw(engine.CreateDrawingInstructions(func(v views.View) { ps.viewport.DrawFromProvider(v, func(x, y int) (rune, tcell.Style) { - tile := ps.level.TileAt(x, y) + tile := ps.dungeon.CurrentLevel().TileAt(x, y) if tile != nil { return tile.Presentation() diff --git a/game/ui/border_button.go b/game/ui/border_button.go deleted file mode 100644 index 93a2e8f..0000000 --- a/game/ui/border_button.go +++ /dev/null @@ -1,61 +0,0 @@ -package ui - -import ( - "mvvasilev/last_light/engine" - - "github.com/gdamore/tcell/v2" - "github.com/gdamore/tcell/v2/views" - "github.com/google/uuid" -) - -type UIBorderedButton struct { - id uuid.UUID - - text engine.Text - border engine.Rectangle - - isSelected bool - - unselectedStyle tcell.Style - selectedStyle tcell.Style -} - -func (b *UIBorderedButton) IsSelected() bool { - return b.isSelected -} - -func (b *UIBorderedButton) Select() { - b.isSelected = true -} - -func (b *UIBorderedButton) Deselect() { - b.isSelected = false -} - -func (b *UIBorderedButton) SetSelected(selected bool) { - b.isSelected = selected -} - -func (b *UIBorderedButton) UniqueId() uuid.UUID { - return b.id -} - -func (b *UIBorderedButton) MoveTo(x int, y int) { - panic("not implemented") // TODO: Implement -} - -func (b *UIBorderedButton) Position() engine.Position { - panic("not implemented") // TODO: Implement -} - -func (b *UIBorderedButton) Size() engine.Size { - panic("not implemented") // TODO: Implement -} - -func (b *UIBorderedButton) Draw(v views.View) { - panic("not implemented") // TODO: Implement -} - -func (b *UIBorderedButton) Input(e *tcell.EventKey) { - panic("not implemented") // TODO: Implement -} diff --git a/game/ui/dialog.go b/game/ui/dialog.go new file mode 100644 index 0000000..733dba9 --- /dev/null +++ b/game/ui/dialog.go @@ -0,0 +1,123 @@ +package ui + +import ( + "mvvasilev/last_light/engine" + + "github.com/gdamore/tcell/v2" + "github.com/gdamore/tcell/v2/views" + "github.com/google/uuid" +) + +type UIDialog struct { + window *UIWindow + prompt *UILabel + + yesBtn *UISimpleButton + noBtn *UISimpleButton +} + +func CreateYesNoDialog(title string, prompt string, yesText string, noText string, lineWidth int, yesAction func(), noAction func()) *UIDialog { + d := new(UIDialog) + + numLines := len(prompt) / lineWidth + winWidth := lineWidth + 2 + winHeight := numLines + 2 + winX := engine.TERMINAL_SIZE_WIDTH - winWidth/2 + winY := engine.TERMINAL_SIZE_HEIGHT - winHeight/2 + + d.window = CreateWindow(winX, winY, winWidth, winHeight, title, tcell.StyleDefault) + d.prompt = CreateUILabel(winX+1, winY+1, lineWidth, numLines, prompt, tcell.StyleDefault) + + yesBtnLength := len(yesText) + 4 + noBtnLength := len(noText) + 4 + + yesBtnPosX := winX + winWidth/4 - yesBtnLength/2 + + d.yesBtn = CreateSimpleButton(yesBtnPosX, winY+winHeight-1, yesText, tcell.StyleDefault, tcell.StyleDefault.Attributes(tcell.AttrBold), yesAction) + + noBtnPosX := winX + 3*winWidth/4 - noBtnLength/2 + + d.noBtn = CreateSimpleButton(noBtnPosX, winY+winHeight-2, noText, tcell.StyleDefault, tcell.StyleDefault.Attributes(tcell.AttrBold), noAction) + + d.yesBtn.Highlight() + + return d +} + +func CreateOkDialog(title string, prompt string, okText string, lineWidth int, okAction func()) *UIDialog { + d := new(UIDialog) + + numLines := len(prompt) / lineWidth + winWidth := lineWidth + 2 + winHeight := numLines + 5 + winX := engine.TERMINAL_SIZE_WIDTH/2 - winWidth/2 + winY := engine.TERMINAL_SIZE_HEIGHT/2 - winHeight/2 + + d.window = CreateWindow(winX, winY, winWidth, winHeight, title, tcell.StyleDefault) + d.prompt = CreateUILabel(winX+1, winY+1, lineWidth, numLines, prompt, tcell.StyleDefault) + + yesBtnLength := len(okText) + 4 + + yesBtnPosX := winX + winWidth/2 - yesBtnLength/2 + + d.yesBtn = CreateSimpleButton(yesBtnPosX, winY+winHeight-2, okText, tcell.StyleDefault, tcell.StyleDefault.Attributes(tcell.AttrBold), okAction) + d.yesBtn.Highlight() + + return d +} + +func (d *UIDialog) Select() { + if d.yesBtn.IsHighlighted() { + d.yesBtn.Select() + } else if d.noBtn != nil && d.noBtn.IsHighlighted() { + d.noBtn.Select() + } +} + +func (d *UIDialog) OnSelect(f func()) { + d.yesBtn.OnSelect(f) +} + +func (d *UIDialog) MoveTo(x int, y int) { + +} + +func (d *UIDialog) Position() engine.Position { + return d.window.Position() +} + +func (d *UIDialog) Size() engine.Size { + return d.window.Size() +} + +func (d *UIDialog) Input(e *tcell.EventKey) { + if e.Key() == tcell.KeyLeft { + if !d.yesBtn.IsHighlighted() { + d.noBtn.Unhighlight() + d.yesBtn.Highlight() + } + } else if e.Key() == tcell.KeyRight { + if d.noBtn == nil { + return + } + + if !d.noBtn.IsHighlighted() { + d.noBtn.Highlight() + d.yesBtn.Unhighlight() + } + } +} + +func (d *UIDialog) UniqueId() uuid.UUID { + return d.window.UniqueId() +} + +func (d *UIDialog) Draw(v views.View) { + d.window.Draw(v) + d.prompt.Draw(v) + d.yesBtn.Draw(v) + + if d.noBtn != nil { + d.noBtn.Draw(v) + } +} diff --git a/game/ui/ui.go b/game/ui/ui.go index da769da..adbb5d4 100644 --- a/game/ui/ui.go +++ b/game/ui/ui.go @@ -26,5 +26,5 @@ type UIHighlightableElement interface { type UISelectableElement interface { Select() OnSelect(func()) - UIHighlightableElement + UIElement } diff --git a/game/world/bsp_map.go b/game/world/bsp_map.go index e38b591..beeae6a 100644 --- a/game/world/bsp_map.go +++ b/game/world/bsp_map.go @@ -7,20 +7,25 @@ import ( type BSPDungeonMap struct { level *BasicMap - playerSpawnPoint engine.Position - rooms []engine.BoundingBox + playerSpawnPoint engine.Position + nextLevelStaircase engine.Position + rooms []engine.BoundingBox } func (bsp *BSPDungeonMap) PlayerSpawnPoint() engine.Position { return bsp.playerSpawnPoint } +func (bsp *BSPDungeonMap) NextLevelStaircasePosition() engine.Position { + return bsp.nextLevelStaircase +} + func (bsp *BSPDungeonMap) Size() engine.Size { return bsp.level.Size() } -func (bsp *BSPDungeonMap) SetTileAt(x int, y int, t Tile) { - bsp.level.SetTileAt(x, y, t) +func (bsp *BSPDungeonMap) SetTileAt(x int, y int, t Tile) Tile { + return bsp.level.SetTileAt(x, y, t) } func (bsp *BSPDungeonMap) TileAt(x int, y int) Tile { @@ -33,3 +38,7 @@ func (bsp *BSPDungeonMap) Tick(dt int64) { func (bsp *BSPDungeonMap) Rooms() []engine.BoundingBox { return bsp.rooms } + +func (bsp *BSPDungeonMap) PreviousLevelStaircasePosition() engine.Position { + return bsp.playerSpawnPoint +} diff --git a/game/world/dungeon.go b/game/world/dungeon.go index b7ff090..2e699ad 100644 --- a/game/world/dungeon.go +++ b/game/world/dungeon.go @@ -1,11 +1,231 @@ package world -type dungeonLevel struct { - groundLevel *Map - entityLevel *EntityMap - itemLevel *Map +import ( + "math/rand" + "mvvasilev/last_light/engine" + "mvvasilev/last_light/game/model" + + "github.com/gdamore/tcell/v2" + "github.com/google/uuid" +) + +type DungeonType int + +const ( + DungeonTypeBSP DungeonType = iota + DungeonTypeCaverns + DungeonTypeMine + DungeonTypeUndercity +) + +func randomDungeonType() DungeonType { + return DungeonType(rand.Intn(4)) } type Dungeon struct { - levels []*dungeonLevel + levels []*DungeonLevel + + current int +} + +func CreateDungeon(width, height int, depth int) *Dungeon { + levels := make([]*DungeonLevel, 0, depth) + + for range depth { + levels = append(levels, CreateDungeonLevel(width, height, randomDungeonType())) + } + + return &Dungeon{ + levels: levels, + current: 0, + } +} + +func (d *Dungeon) CurrentLevel() *DungeonLevel { + return d.levels[d.current] +} + +func (d *Dungeon) MoveToNextLevel() (moved bool) { + if !d.HasNextLevel() { + return false + } + + d.current++ + + return true +} + +func (d *Dungeon) MoveToPreviousLevel() (moved bool) { + if !d.HasPreviousLevel() { + return false + } + + d.current-- + + return true +} + +func (d *Dungeon) NextLevel() *DungeonLevel { + if !d.HasNextLevel() { + return nil + } + + return d.levels[d.current+1] +} + +func (d *Dungeon) PreviousLevel() *DungeonLevel { + if !d.HasPreviousLevel() { + return nil + } + + return d.levels[d.current-1] +} + +func (d *Dungeon) HasPreviousLevel() bool { + return d.current-1 >= 0 +} + +func (d *Dungeon) HasNextLevel() bool { + return d.current+1 < len(d.levels) +} + +type DungeonLevel struct { + groundLevel interface { + Map + WithPlayerSpawnPoint + WithNextLevelStaircasePosition + WithPreviousLevelStaircasePosition + } + entityLevel *EntityMap + itemLevel Map + + multilevel Map +} + +func CreateDungeonLevel(width, height int, dungeonType DungeonType) *DungeonLevel { + + genTable := make(map[float32]*model.ItemType, 0) + + genTable[0.2] = model.ItemTypeFish() + genTable[0.05] = model.ItemTypeBow() + genTable[0.051] = model.ItemTypeLongsword() + genTable[0.052] = model.ItemTypeKey() + + var groundLevel interface { + Map + WithRooms + WithPlayerSpawnPoint + WithNextLevelStaircasePosition + WithPreviousLevelStaircasePosition + } + + switch dungeonType { + case DungeonTypeBSP: + groundLevel = CreateBSPDungeonMap(width, height, 4) + default: + groundLevel = CreateBSPDungeonMap(width, height, 4) + } + + items := SpawnItems(groundLevel.Rooms(), 0.01, genTable, []engine.Position{ + groundLevel.NextLevelStaircasePosition(), + groundLevel.PlayerSpawnPoint(), + groundLevel.PreviousLevelStaircasePosition(), + }) + + itemLevel := CreateEmptyDungeonLevel(width, height) + + for _, it := range items { + if !groundLevel.TileAt(it.Position().XY()).Passable() { + continue + } + + itemLevel.SetTileAt(it.Position().X(), it.Position().Y(), it) + } + + d := &DungeonLevel{ + groundLevel: groundLevel, + entityLevel: CreateEntityMap(width, height), + itemLevel: itemLevel, + } + + d.multilevel = CreateMultilevelMap( + d.groundLevel, + d.itemLevel, + d.entityLevel, + ) + + return d +} + +func (d *DungeonLevel) PlayerSpawnPoint() engine.Position { + return d.groundLevel.PlayerSpawnPoint() +} + +func (d *DungeonLevel) NextLevelStaircase() engine.Position { + return d.groundLevel.NextLevelStaircasePosition() +} + +func (d *DungeonLevel) PreviousLevelStaircase() engine.Position { + return d.groundLevel.PreviousLevelStaircasePosition() +} + +func (d *DungeonLevel) DropEntity(uuid uuid.UUID) { + d.entityLevel.DropEntity(uuid) +} + +func (d *DungeonLevel) AddEntity(entity model.MovableEntity, presentation rune, style tcell.Style) { + d.entityLevel.AddEntity(entity, presentation, style) +} + +func (d *DungeonLevel) MoveEntity(uuid uuid.UUID, dx, dy int) { + d.entityLevel.MoveEntity(uuid, dx, dy) +} + +func (d *DungeonLevel) MoveEntityTo(uuid uuid.UUID, x, y int) { + d.entityLevel.MoveEntityTo(uuid, x, y) +} + +func (d *DungeonLevel) RemoveItemAt(x, y int) *model.Item { + if !d.groundLevel.Size().Contains(x, y) { + return nil + } + + tile := d.itemLevel.TileAt(x, y) + itemTile, ok := tile.(*ItemTile) + + if !ok { + return nil + } + + d.itemLevel.SetTileAt(x, y, nil) + + item := model.CreateItem(itemTile.Type(), itemTile.Quantity()) + + return &item +} + +func (d *DungeonLevel) SetItemAt(x, y int, it model.Item) (success bool) { + if !d.TileAt(x, y).Passable() { + return false + } + + d.itemLevel.SetTileAt(x, y, CreateItemTile(engine.PositionAt(x, y), it.Type(), it.Quantity())) + + return true +} + +func (d *DungeonLevel) TileAt(x, y int) Tile { + return d.multilevel.TileAt(x, y) +} + +func (d *DungeonLevel) IsTilePassable(x, y int) bool { + if !d.groundLevel.Size().Contains(x, y) { + return false + } + + return d.TileAt(x, y).Passable() +} + +func (d *DungeonLevel) Flatten() Map { + return d.multilevel } diff --git a/game/world/empty_map.go b/game/world/empty_map.go index 16f7c8c..bbaeef4 100644 --- a/game/world/empty_map.go +++ b/game/world/empty_map.go @@ -10,8 +10,8 @@ func (edl *EmptyDungeonMap) Size() engine.Size { return edl.level.Size() } -func (edl *EmptyDungeonMap) SetTileAt(x int, y int, t Tile) { - edl.level.SetTileAt(x, y, t) +func (edl *EmptyDungeonMap) SetTileAt(x int, y int, t Tile) Tile { + return edl.level.SetTileAt(x, y, t) } func (edl *EmptyDungeonMap) TileAt(x int, y int) Tile { @@ -21,3 +21,26 @@ func (edl *EmptyDungeonMap) TileAt(x int, y int) Tile { func (edl *EmptyDungeonMap) Tick(dt int64) { } + +func (edl *EmptyDungeonMap) Rooms() []engine.BoundingBox { + rooms := make([]engine.BoundingBox, 1) + + rooms = append(rooms, engine.BoundingBox{ + Sized: engine.WithSize(edl.Size()), + Positioned: engine.WithPosition(engine.PositionAt(0, 0)), + }) + + return rooms +} + +func (edl *EmptyDungeonMap) PlayerSpawnPoint() engine.Position { + return engine.PositionAt(edl.Size().Width()/2, edl.Size().Height()/2) +} + +func (edl *EmptyDungeonMap) NextLevelStaircasePosition() engine.Position { + return engine.PositionAt(edl.Size().Width()/3, edl.Size().Height()/3) +} + +func (bsp *EmptyDungeonMap) PreviousLevelStaircasePosition() engine.Position { + return bsp.PlayerSpawnPoint() +} diff --git a/game/world/entity_map.go b/game/world/entity_map.go index e62093c..aa0a1b7 100644 --- a/game/world/entity_map.go +++ b/game/world/entity_map.go @@ -22,7 +22,8 @@ func CreateEntityMap(width, height int) *EntityMap { } } -func (em *EntityMap) SetTileAt(x int, y int, t Tile) { +func (em *EntityMap) SetTileAt(x int, y int, t Tile) Tile { + return nil // if !em.FitsWithin(x, y) { // return // } @@ -80,6 +81,26 @@ func (em *EntityMap) MoveEntity(uuid uuid.UUID, dx, dy int) { em.entities[newKey] = e } +func (em *EntityMap) MoveEntityTo(uuid uuid.UUID, x, y int) { + oldKey, e := em.FindEntityByUuid(uuid) + + if e == nil { + return + } + + if !em.FitsWithin(x, y) { + return + } + + delete(em.entities, oldKey) + + e.Entity().MoveTo(engine.PositionAt(x, y)) + + newKey := em.Size().AsArrayIndex(e.Entity().Position().XY()) + + em.entities[newKey] = e +} + func (em *EntityMap) TileAt(x int, y int) Tile { if !em.FitsWithin(x, y) { return CreateStaticTile(x, y, TileTypeVoid()) diff --git a/game/world/generate_bsp_map.go b/game/world/generate_bsp_map.go index ba51cbc..db34293 100644 --- a/game/world/generate_bsp_map.go +++ b/game/world/generate_bsp_map.go @@ -39,7 +39,7 @@ func CreateBSPDungeonMap(width, height int, numSplits int) *BSPDungeonMap { tiles[h] = make([]Tile, width) } - rooms := make([]engine.BoundingBox, 0, 2^numSplits) + rooms := make([]engine.BoundingBox, 0, numSplits*numSplits) iterateBspLeaves(root, func(leaf *bspNode) { x := engine.RandInt(leaf.origin.X(), leaf.origin.X()+leaf.size.Width()/4) @@ -89,14 +89,24 @@ func CreateBSPDungeonMap(width, height int, numSplits int) *BSPDungeonMap { bsp := new(BSPDungeonMap) spawnRoom := findRoom(root.left) + staircaseRoom := findRoom(root.right) bsp.rooms = rooms bsp.level = CreateBasicMap(tiles) + bsp.playerSpawnPoint = engine.PositionAt( spawnRoom.Position().X()+spawnRoom.Size().Width()/2, spawnRoom.Position().Y()+spawnRoom.Size().Height()/2, ) + bsp.nextLevelStaircase = engine.PositionAt( + staircaseRoom.Position().X()+staircaseRoom.Size().Width()/2, + staircaseRoom.Position().Y()+staircaseRoom.Size().Height()/2, + ) + + bsp.level.SetTileAt(bsp.nextLevelStaircase.X(), bsp.nextLevelStaircase.Y(), CreateStaticTile(bsp.nextLevelStaircase.X(), bsp.nextLevelStaircase.Y(), TileTypeStaircaseDown())) + bsp.level.SetTileAt(bsp.playerSpawnPoint.X(), bsp.playerSpawnPoint.Y(), CreateStaticTile(bsp.playerSpawnPoint.X(), bsp.playerSpawnPoint.Y(), TileTypeStaircaseUp())) + return bsp } diff --git a/game/world/generate_empty_map.go b/game/world/generate_empty_map.go index 79cd137..e7e817f 100644 --- a/game/world/generate_empty_map.go +++ b/game/world/generate_empty_map.go @@ -11,5 +11,8 @@ func CreateEmptyDungeonLevel(width, height int) *EmptyDungeonMap { m.level = CreateBasicMap(tiles) + //m.level.SetTileAt(width/2, height/2, CreateStaticTile(width/2, height/2, TileTypeStaircaseDown())) + //m.level.SetTileAt(width/3, height/3, CreateStaticTile(width/3, height/3, TileTypeStaircaseUp())) + return m } diff --git a/game/world/generate_items.go b/game/world/generate_items.go index eca0352..2f23a92 100644 --- a/game/world/generate_items.go +++ b/game/world/generate_items.go @@ -4,9 +4,10 @@ import ( "math/rand" "mvvasilev/last_light/engine" "mvvasilev/last_light/game/model" + "slices" ) -func SpawnItems(spawnableAreas []engine.BoundingBox, maxItemRatio float32, genTable map[float32]*model.ItemType) []Tile { +func SpawnItems(spawnableAreas []engine.BoundingBox, maxItemRatio float32, genTable map[float32]*model.ItemType, forbiddenPositions []engine.Position) []Tile { rooms := spawnableAreas itemTiles := make([]Tile, 0, 10) @@ -32,6 +33,10 @@ func SpawnItems(spawnableAreas []engine.BoundingBox, maxItemRatio float32, genTa engine.RandInt(r.Position().Y()+1, r.Position().Y()+r.Size().Height()-1), ) + if slices.Contains(forbiddenPositions, pos) { + continue + } + itemTiles = append(itemTiles, CreateItemTile(pos, itemType, 1)) } } diff --git a/game/world/map.go b/game/world/map.go index 35873d4..e4876b3 100644 --- a/game/world/map.go +++ b/game/world/map.go @@ -6,19 +6,25 @@ import ( type Map interface { Size() engine.Size - SetTileAt(x, y int, t Tile) + SetTileAt(x, y int, t Tile) Tile TileAt(x, y int) Tile Tick(dt int64) } type WithPlayerSpawnPoint interface { PlayerSpawnPoint() engine.Position - Map } type WithRooms interface { Rooms() []engine.BoundingBox - Map +} + +type WithNextLevelStaircasePosition interface { + NextLevelStaircasePosition() engine.Position +} + +type WithPreviousLevelStaircasePosition interface { + PreviousLevelStaircasePosition() engine.Position } type BasicMap struct { @@ -40,16 +46,18 @@ func (bm *BasicMap) Size() engine.Size { return engine.SizeOf(len(bm.tiles[0]), len(bm.tiles)) } -func (bm *BasicMap) SetTileAt(x int, y int, t Tile) { +func (bm *BasicMap) SetTileAt(x int, y int, t Tile) Tile { if len(bm.tiles) <= y || len(bm.tiles[0]) <= x { - return + return CreateStaticTile(x, y, TileTypeVoid()) } if x < 0 || y < 0 { - return + return CreateStaticTile(x, y, TileTypeVoid()) } bm.tiles[y][x] = t + + return bm.tiles[y][x] } func (bm *BasicMap) TileAt(x int, y int) Tile { diff --git a/game/world/multilevel_map.go b/game/world/multilevel_map.go index e10fd30..ff46b21 100644 --- a/game/world/multilevel_map.go +++ b/game/world/multilevel_map.go @@ -22,8 +22,8 @@ func (mm *MultilevelMap) Size() engine.Size { return mm.layers[0].Size() } -func (mm *MultilevelMap) SetTileAt(x, y int, t Tile) { - mm.layers[0].SetTileAt(x, y, t) +func (mm *MultilevelMap) SetTileAt(x, y int, t Tile) Tile { + return mm.layers[0].SetTileAt(x, y, t) } func (mm *MultilevelMap) UnsetTileAtHeight(x, y, height int) { diff --git a/game/world/tile.go b/game/world/tile.go index 3be6e0f..04f8812 100644 --- a/game/world/tile.go +++ b/game/world/tile.go @@ -17,6 +17,8 @@ const ( MaterialVoid MaterialClosedDoor MaterialOpenDoor + MaterialStaircaseDown + MaterialStaircaseUp ) type TileType struct { @@ -97,6 +99,26 @@ func TileTypeOpenDoor() TileType { } } +func TileTypeStaircaseDown() TileType { + return TileType{ + Material: MaterialStaircaseDown, + Passable: true, + Transparent: false, + Presentation: '≡', + Style: tcell.StyleDefault.Foreground(tcell.ColorDarkSlateGray).Attributes(tcell.AttrBold), + } +} + +func TileTypeStaircaseUp() TileType { + return TileType{ + Material: MaterialStaircaseUp, + Passable: true, + Transparent: false, + Presentation: '^', + Style: tcell.StyleDefault.Foreground(tcell.ColorDarkSlateGray).Attributes(tcell.AttrBold), + } +} + type Tile interface { Position() engine.Position Presentation() (rune, tcell.Style)