diff --git a/DESIGN.md b/DESIGN.md index 58b35e3..3f3eb66 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -29,3 +29,9 @@ - Underground City ( Caverns + Dungeon combo ) - Objective: Pick up the Last Light and bring it to its Altar ( Altar of the Last Light ) - The light is always on level 9, but the Altar can be anywhere + +Items: +- `»o>` - fish +- `╪══` - longsword +- `o─╖` +- `█▄` \ No newline at end of file diff --git a/__debug_bin2404317323 b/__debug_bin2404317323 new file mode 100755 index 0000000..a318018 Binary files /dev/null and b/__debug_bin2404317323 differ diff --git a/game/model/bsp_dungeon_level.go b/game/model/bsp_dungeon_level.go new file mode 100644 index 0000000..358a8a2 --- /dev/null +++ b/game/model/bsp_dungeon_level.go @@ -0,0 +1,323 @@ +package model + +import ( + "math/rand" + "mvvasilev/last_light/util" +) + +type splitDirection bool + +const ( + splitDirectionVertical splitDirection = true + splitDirectionHorizontal splitDirection = false +) + +type bspNode struct { + origin util.Position + size util.Size + + room Room + hasRoom bool + + left *bspNode + right *bspNode + + splitDir splitDirection +} + +type Room struct { + position util.Position + size util.Size +} + +func (r Room) Size() util.Size { + return r.size +} + +func (r Room) Position() util.Position { + return r.position +} + +type BSPDungeonLevel struct { + level *BasicMap + + playerSpawnPoint util.Position + rooms []Room +} + +func CreateBSPDungeonLevel(width, height int, numSplits int) *BSPDungeonLevel { + root := new(bspNode) + + root.origin = util.PositionAt(0, 0) + root.size = util.SizeOf(width, height) + + split(root, numSplits) + + tiles := make([][]Tile, height) + + for h := range height { + tiles[h] = make([]Tile, width) + } + + rooms := make([]Room, 0, 2^numSplits) + + iterateBspLeaves(root, func(leaf *bspNode) { + x := util.RandInt(leaf.origin.X(), leaf.origin.X()+leaf.size.Width()/4) + y := util.RandInt(leaf.origin.Y(), leaf.origin.Y()+leaf.size.Height()/4) + w := util.RandInt(3, leaf.size.Width()-1) + h := util.RandInt(3, leaf.size.Height()-1) + + if x+w >= width { + w = w - (x + w - width) - 1 + } + + if y+h >= height { + h = h - (y + h - height) - 1 + } + + room := Room{ + position: util.PositionAt(x, y), + size: util.SizeOf(w, h), + } + + rooms = append(rooms, room) + + makeRoom(tiles, room) + + leaf.room = room + leaf.hasRoom = true + }) + + iterateBspParents(root, func(parent *bspNode) { + roomLeft := findRoom(parent.left) + roomRight := findRoom(parent.right) + + zCorridor( + tiles, + util.PositionAt( + roomLeft.position.X()+roomLeft.size.Width()/2, + roomLeft.position.Y()+roomLeft.size.Height()/2, + ), + util.PositionAt( + roomRight.position.X()+roomRight.size.Width()/2, + roomRight.position.Y()+roomRight.size.Height()/2, + ), + parent.splitDir, + ) + }) + + bsp := new(BSPDungeonLevel) + + spawnRoom := findRoom(root.left) + + bsp.rooms = rooms + bsp.level = CreateBasicMap(tiles) + bsp.playerSpawnPoint = util.PositionAt( + spawnRoom.position.X()+spawnRoom.size.Width()/2, + spawnRoom.position.Y()+spawnRoom.size.Height()/2, + ) + + return bsp +} + +func (bsp *BSPDungeonLevel) PlayerSpawnPoint() util.Position { + return bsp.playerSpawnPoint +} + +func findRoom(parent *bspNode) Room { + if parent.hasRoom { + return parent.room + } + + if rand.Float32() > 0.5 { + return findRoom(parent.left) + } else { + return findRoom(parent.right) + } +} + +func zCorridor(tiles [][]Tile, from util.Position, to util.Position, direction splitDirection) { + switch direction { + case splitDirectionHorizontal: + xMidPoint := (from.X() + to.X()) / 2 + horizontalTunnel(tiles, from.X(), xMidPoint, from.Y()) + horizontalTunnel(tiles, to.X(), xMidPoint, to.Y()) + verticalTunnel(tiles, from.Y(), to.Y(), xMidPoint) + case splitDirectionVertical: + yMidPoint := (from.Y() + to.Y()) / 2 + verticalTunnel(tiles, from.Y(), yMidPoint, from.X()) + verticalTunnel(tiles, to.Y(), yMidPoint, to.X()) + horizontalTunnel(tiles, from.X(), to.X(), yMidPoint) + } +} + +func iterateBspParents(parent *bspNode, iter func(parent *bspNode)) { + if parent.left != nil && parent.right != nil { + iter(parent) + } + + if parent.left != nil { + iterateBspParents(parent.left, iter) + } + + if parent.right != nil { + iterateBspParents(parent.right, iter) + } +} + +func iterateBspLeaves(parent *bspNode, iter func(leaf *bspNode)) { + if parent.left == nil && parent.right == nil { + iter(parent) + return + } + + if parent.left != nil { + iterateBspLeaves(parent.left, iter) + } + + if parent.right != nil { + iterateBspLeaves(parent.right, iter) + } +} + +func split(parent *bspNode, numSplits int) { + if numSplits <= 0 { + return + } + + // split vertically + if parent.size.Width() > parent.size.Height() { + // New splits will be between 45% and 65% of the parent's width + leftSplitWidth := util.RandInt(int(float32(parent.size.Width())*0.45), int(float32(parent.size.Width())*0.65)) + + parent.splitDir = splitDirectionVertical + + parent.left = new(bspNode) + parent.left.origin = parent.origin + parent.left.size = util.SizeOf(leftSplitWidth, parent.size.Height()) + + parent.right = new(bspNode) + parent.right.origin = parent.origin.WithOffset(leftSplitWidth, 0) + parent.right.size = util.SizeOf(parent.size.Width()-leftSplitWidth, parent.size.Height()) + } else { // split horizontally + // New splits will be between 45% and 65% of the parent's height + leftSplitHeight := util.RandInt(int(float32(parent.size.Height())*0.45), int(float32(parent.size.Height())*0.65)) + + parent.splitDir = splitDirectionHorizontal + + parent.left = new(bspNode) + parent.left.origin = parent.origin + parent.left.size = util.SizeOf(parent.size.Width(), leftSplitHeight) + + parent.right = new(bspNode) + parent.right.origin = parent.origin.WithOffset(0, leftSplitHeight) + parent.right.size = util.SizeOf(parent.size.Width(), parent.size.Height()-leftSplitHeight) + } + + split(parent.left, numSplits-1) + split(parent.right, numSplits-1) +} + +func horizontalTunnel(tiles [][]Tile, x1, x2, y int) { + if x1 > x2 { + tx := x2 + x2 = x1 + x1 = tx + } + + placeWallAtIfNotPassable(tiles, x1, y-1) + placeWallAtIfNotPassable(tiles, x1, y) + placeWallAtIfNotPassable(tiles, x1, y+1) + + for x := x1; x <= x2; x++ { + if tiles[y][x] != nil && tiles[y][x].Passable() { + continue + } + + tiles[y][x] = CreateStaticTile(x, y, TileTypeGround()) + + placeWallAtIfNotPassable(tiles, x, y-1) + placeWallAtIfNotPassable(tiles, x, y+1) + } + + placeWallAtIfNotPassable(tiles, x2, y-1) + placeWallAtIfNotPassable(tiles, x2, y) + placeWallAtIfNotPassable(tiles, x2, y+1) +} + +func verticalTunnel(tiles [][]Tile, y1, y2, x int) { + if y1 > y2 { + ty := y2 + y2 = y1 + y1 = ty + } + + placeWallAtIfNotPassable(tiles, x-1, y1) + placeWallAtIfNotPassable(tiles, x, y1) + placeWallAtIfNotPassable(tiles, x+1, y1) + + for y := y1; y <= y2; y++ { + if tiles[y][x] != nil && tiles[y][x].Passable() { + continue + } + + tiles[y][x] = CreateStaticTile(x, y, TileTypeGround()) + + placeWallAtIfNotPassable(tiles, x-1, y) + placeWallAtIfNotPassable(tiles, x+1, y) + } + + placeWallAtIfNotPassable(tiles, x-1, y2) + placeWallAtIfNotPassable(tiles, x, y2) + placeWallAtIfNotPassable(tiles, x+1, y2) +} + +func placeWallAtIfNotPassable(tiles [][]Tile, x, y int) { + if tiles[y][x] != nil && tiles[y][x].Passable() { + return + } + + tiles[y][x] = CreateStaticTile(x, y, TileTypeWall()) +} + +func makeRoom(tiles [][]Tile, room Room) { + width := room.size.Width() + height := room.size.Height() + x := room.position.X() + y := room.position.Y() + + for w := x; w < x+width+1; w++ { + tiles[y][w] = CreateStaticTile(w, y, TileTypeWall()) + tiles[y+height][w] = CreateStaticTile(w, y+height, TileTypeWall()) + } + + for h := y; h < y+height+1; h++ { + tiles[h][x] = CreateStaticTile(x, h, TileTypeWall()) + tiles[h][x+width] = CreateStaticTile(x+width, h, TileTypeWall()) + } + + for h := y + 1; h < y+height; h++ { + for w := x + 1; w < x+width; w++ { + tiles[h][w] = CreateStaticTile(w, h, TileTypeGround()) + } + } +} + +func (bsp *BSPDungeonLevel) Size() util.Size { + return bsp.level.Size() +} + +func (bsp *BSPDungeonLevel) SetTileAt(x int, y int, t Tile) { + bsp.level.SetTileAt(x, y, t) +} + +func (bsp *BSPDungeonLevel) TileAt(x int, y int) Tile { + return bsp.level.TileAt(x, y) +} + +func (bsp *BSPDungeonLevel) Tick() { +} + +func (bsp *BSPDungeonLevel) Rooms() []Room { + return bsp.rooms +} diff --git a/game/model/dungeon.go b/game/model/dungeon.go index 8b53790..515eef6 100644 --- a/game/model/dungeon.go +++ b/game/model/dungeon.go @@ -1 +1,7 @@ package model + +type Dungeon struct { + player *Player + + levels []Map +} diff --git a/game/model/empty_dungeon_level.go b/game/model/empty_dungeon_level.go index 0f0f6c2..d6f1e66 100644 --- a/game/model/empty_dungeon_level.go +++ b/game/model/empty_dungeon_level.go @@ -3,43 +3,33 @@ package model import "mvvasilev/last_light/util" type EmptyDungeonLevel struct { - tiles [][]Tile + level *BasicMap } func CreateEmptyDungeonLevel(width, height int) *EmptyDungeonLevel { m := new(EmptyDungeonLevel) - m.tiles = make([][]Tile, height) + tiles := make([][]Tile, height) for h := range height { - m.tiles[h] = make([]Tile, width) + tiles[h] = make([]Tile, width) } + m.level = CreateBasicMap(tiles) + return m } func (edl *EmptyDungeonLevel) Size() util.Size { - return util.SizeOf(len(edl.tiles[0]), len(edl.tiles)) + return edl.level.Size() } func (edl *EmptyDungeonLevel) SetTileAt(x int, y int, t Tile) { - if len(edl.tiles) <= y || len(edl.tiles[0]) <= x { - return - } - - edl.tiles[y][x] = t + edl.level.SetTileAt(x, y, t) } func (edl *EmptyDungeonLevel) TileAt(x int, y int) Tile { - if y < 0 || y >= len(edl.tiles) { - return nil - } - - if x < 0 || x >= len(edl.tiles[y]) { - return nil - } - - return edl.tiles[y][x] + return edl.level.TileAt(x, y) } func (edl *EmptyDungeonLevel) Tick() { diff --git a/game/model/flat_ground_dungeon_level.go b/game/model/flat_ground_dungeon_level.go index bf85da9..62e9da7 100644 --- a/game/model/flat_ground_dungeon_level.go +++ b/game/model/flat_ground_dungeon_level.go @@ -21,7 +21,7 @@ func CreateFlatGroundDungeonLevel(width, height int) *FlatGroundDungeonLevel { for w := range width { if w == 0 || h == 0 || w >= width-1 || h >= height-1 { - level.tiles[h][w] = CreateStaticTile(w, h, Rock()) + level.tiles[h][w] = CreateStaticTile(w, h, TileTypeRock()) continue } @@ -35,16 +35,16 @@ func CreateFlatGroundDungeonLevel(width, height int) *FlatGroundDungeonLevel { func genRandomGroundTile(width, height int) Tile { switch rand.Intn(2) { case 0: - return CreateStaticTile(width, height, Ground()) + return CreateStaticTile(width, height, TileTypeGround()) case 1: - return CreateStaticTile(width, height, Grass()) + return CreateStaticTile(width, height, TileTypeGrass()) default: - return CreateStaticTile(width, height, Ground()) + return CreateStaticTile(width, height, TileTypeGround()) } } func (edl *FlatGroundDungeonLevel) Size() util.Size { - return util.SizeOfInt(len(edl.tiles[0]), len(edl.tiles)) + return util.SizeOf(len(edl.tiles[0]), len(edl.tiles)) } func (edl *FlatGroundDungeonLevel) SetTileAt(x int, y int, t Tile) { diff --git a/game/model/inventory.go b/game/model/inventory.go new file mode 100644 index 0000000..f6a4161 --- /dev/null +++ b/game/model/inventory.go @@ -0,0 +1,81 @@ +package model + +import "mvvasilev/last_light/util" + +type Inventory struct { + contents []*Item + shape util.Size +} + +func CreateInventory(shape util.Size) *Inventory { + inv := new(Inventory) + + inv.contents = make([]*Item, 0, shape.Height()*shape.Width()) + inv.shape = shape + + return inv +} + +func (i *Inventory) Items() (items []*Item) { + return i.contents +} + +func (i *Inventory) Shape() util.Size { + return i.shape +} + +func (i *Inventory) Push(item Item) (success bool) { + if len(i.contents) == i.shape.Area() { + return false + } + + itemType := item.Type() + + // Try to first find a matching item with capacity + for index, existingItem := range i.contents { + if existingItem != nil && existingItem.itemType == itemType { + if existingItem.Quantity()+1 > existingItem.Type().MaxStack() { + continue + } + + it := CreateItem(itemType, existingItem.Quantity()+1) + i.contents[index] = &it + + return true + } + } + + // Next, try to find an intermediate empty slot to fit this item into + for index, existingItem := range i.contents { + if existingItem == nil { + i.contents[index] = &item + + return true + } + } + + // Finally, just append the new item at the end + i.contents = append(i.contents, &item) + + return true +} + +func (i *Inventory) Drop(x, y int) { + index := y*i.shape.Width() + x + + if index > len(i.contents)-1 { + return + } + + i.contents[index] = nil +} + +func (i *Inventory) ItemAt(x, y int) (item *Item) { + index := y*i.shape.Width() + x + + if index > len(i.contents)-1 { + return nil + } + + return i.contents[index] +} diff --git a/game/model/item.go b/game/model/item.go new file mode 100644 index 0000000..0131889 --- /dev/null +++ b/game/model/item.go @@ -0,0 +1,185 @@ +package model + +import ( + "math/rand" + + "github.com/gdamore/tcell/v2" +) + +type ItemType struct { + name string + description string + tileIcon rune + itemIcon string + maxStack int + + style tcell.Style +} + +func (it *ItemType) Name() string { + return it.name +} + +func (it *ItemType) Description() string { + return it.description +} + +func (it *ItemType) TileIcon() rune { + return it.tileIcon +} + +func (it *ItemType) Icon() string { + return it.itemIcon +} + +func (it *ItemType) Style() tcell.Style { + return it.style +} + +func (it *ItemType) MaxStack() int { + return it.maxStack +} + +func ItemTypeFish() *ItemType { + return &ItemType{ + name: "Fish", + description: "What's a fish doing down here?", + tileIcon: '>', + itemIcon: "»o>", + style: tcell.StyleDefault.Foreground(tcell.ColorDarkCyan), + maxStack: 16, + } +} + +func ItemTypeGold() *ItemType { + return &ItemType{ + name: "Gold", + description: "Not all those who wander are lost", + tileIcon: '¤', + itemIcon: " ¤ ", + style: tcell.StyleDefault.Foreground(tcell.ColorGoldenrod), + maxStack: 255, + } +} + +func ItemTypeArrow() *ItemType { + return &ItemType{ + name: "Arrow", + description: "Ammunition for a bow", + tileIcon: '-', + itemIcon: "»->", + style: tcell.StyleDefault.Foreground(tcell.ColorGoldenrod), + maxStack: 32, + } +} + +func ItemTypeBow() *ItemType { + return &ItemType{ + name: "Bow", + description: "To shoot arrows with", + tileIcon: ')', + itemIcon: " |)", + style: tcell.StyleDefault.Foreground(tcell.ColorBrown), + maxStack: 1, + } +} + +func ItemTypeLongsword() *ItemType { + return &ItemType{ + name: "Longsword", + description: "You know nothing.", + tileIcon: '/', + itemIcon: "╪══", + style: tcell.StyleDefault.Foreground(tcell.ColorSilver), + maxStack: 1, + } +} + +func ItemTypeKey() *ItemType { + return &ItemType{ + name: "Key", + description: "Indispensable for unlocking things", + tileIcon: '¬', + itemIcon: " o╖", + style: tcell.StyleDefault.Foreground(tcell.ColorDarkGoldenrod), + maxStack: 1, + } +} + +type ItemTypeGenTable struct { +} + +func GenerateItemType(genTable map[float32]*ItemType) *ItemType { + num := rand.Float32() + + for k, v := range genTable { + if num > k { + return v + } + } + + return nil +} + +type Item struct { + name string + description string + itemType *ItemType + quantity int +} + +func EmptyItem() Item { + return Item{ + itemType: &ItemType{ + name: "", + description: "", + tileIcon: ' ', + itemIcon: " ", + style: tcell.StyleDefault, + maxStack: 0, + }, + } +} + +func CreateItem(itemType *ItemType, quantity int) Item { + return Item{ + itemType: itemType, + quantity: quantity, + } +} + +func (i Item) WithName(name string) Item { + i.name = name + + return i +} + +func (i Item) Name() string { + if i.name == "" { + return i.itemType.name + } + + return i.name +} + +func (i Item) Description() string { + if i.description == "" { + return i.itemType.description + } + + return i.description +} + +func (i Item) WithDescription(description string) Item { + i.description = description + + return i +} + +func (i Item) Type() *ItemType { + return i.itemType +} + +func (i Item) Quantity() int { + return i.quantity +} diff --git a/game/model/map.go b/game/model/map.go index 39b1674..d5cfdd1 100644 --- a/game/model/map.go +++ b/game/model/map.go @@ -10,3 +10,52 @@ type Map interface { TileAt(x, y int) Tile Tick() } + +type BasicMap struct { + tiles [][]Tile +} + +func CreateBasicMap(tiles [][]Tile) *BasicMap { + bm := new(BasicMap) + + bm.tiles = tiles + + return bm +} + +func (bm *BasicMap) Tick() { +} + +func (bm *BasicMap) Size() util.Size { + return util.SizeOf(len(bm.tiles[0]), len(bm.tiles)) +} + +func (bm *BasicMap) SetTileAt(x int, y int, t Tile) { + if len(bm.tiles) <= y || len(bm.tiles[0]) <= x { + return + } + + if x < 0 || y < 0 { + return + } + + bm.tiles[y][x] = t +} + +func (bm *BasicMap) TileAt(x int, y int) Tile { + if x < 0 || y < 0 { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + if x >= bm.Size().Width() || y >= bm.Size().Height() { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + tile := bm.tiles[y][x] + + if tile == nil { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + return tile +} diff --git a/game/model/multilevel_map.go b/game/model/multilevel_map.go index ab9777d..95f2e6a 100644 --- a/game/model/multilevel_map.go +++ b/game/model/multilevel_map.go @@ -42,16 +42,64 @@ func (mm *MultilevelMap) SetTileAtHeight(x, y, height int, t Tile) { mm.layers[height].SetTileAt(x, y, t) } -func (mm *MultilevelMap) TileAt(x int, y int) Tile { +func (mm *MultilevelMap) CollectTilesAt(x, y int, filter func(t Tile) bool) []Tile { + tiles := make([]Tile, len(mm.layers)) + + if x < 0 || y < 0 { + return tiles + } + + if x >= mm.Size().Width() || y >= mm.Size().Height() { + return tiles + } + for i := len(mm.layers) - 1; i >= 0; i-- { tile := mm.layers[i].TileAt(x, y) - if tile != nil { - return tile + if tile != nil && !tile.Transparent() && filter(tile) { + tiles = append(tiles, tile) } + } - return nil + return tiles +} + +func (mm *MultilevelMap) TileAt(x int, y int) Tile { + if x < 0 || y < 0 { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + if x >= mm.Size().Width() || y >= mm.Size().Height() { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + for i := len(mm.layers) - 1; i >= 0; i-- { + tile := mm.layers[i].TileAt(x, y) + + if tile != nil && !tile.Transparent() { + return tile + } + + } + + return CreateStaticTile(x, y, TileTypeVoid()) +} + +func (mm *MultilevelMap) TileAtHeight(x, y, height int) Tile { + if x < 0 || y < 0 { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + if x >= mm.Size().Width() || y >= mm.Size().Height() { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + if height > len(mm.layers)-1 { + return CreateStaticTile(x, y, TileTypeVoid()) + } + + return mm.layers[height].TileAt(x, y) } func (mm *MultilevelMap) Tick() { diff --git a/game/model/player.go b/game/model/player.go index 2709020..a45bade 100644 --- a/game/model/player.go +++ b/game/model/player.go @@ -10,6 +10,8 @@ import ( type Player struct { id uuid.UUID position util.Position + + inventory *Inventory } func CreatePlayer(x, y int) *Player { @@ -17,6 +19,7 @@ func CreatePlayer(x, y int) *Player { p.id = uuid.New() p.position = util.PositionAt(x, y) + p.inventory = CreateInventory(util.SizeOf(8, 4)) return p } @@ -33,14 +36,22 @@ func (p *Player) Move(dir Direction) { p.position = p.Position().WithOffset(MovementDirectionOffset(dir)) } -func (p *Player) Presentation() rune { - return '@' +func (p *Player) Presentation() (rune, tcell.Style) { + return '@', tcell.StyleDefault } func (p *Player) Passable() bool { return false } +func (p *Player) Transparent() bool { + return false +} + +func (p *Player) Inventory() *Inventory { + return p.inventory +} + func (p *Player) Input(e *tcell.EventKey) { } diff --git a/game/model/tile.go b/game/model/tile.go index 763c281..2766e83 100644 --- a/game/model/tile.go +++ b/game/model/tile.go @@ -1,12 +1,17 @@ package model -import "mvvasilev/last_light/util" +import ( + "mvvasilev/last_light/util" + + "github.com/gdamore/tcell/v2" +) type Material uint const ( MaterialGround Material = iota MaterialRock + MaterialWall MaterialGrass MaterialVoid ) @@ -15,44 +20,65 @@ type TileType struct { Material Material Passable bool Presentation rune + Transparent bool + Style tcell.Style } -func Ground() TileType { +func TileTypeGround() TileType { return TileType{ Material: MaterialGround, Passable: true, Presentation: '.', + Transparent: false, + Style: tcell.StyleDefault, } } -func Rock() TileType { +func TileTypeRock() TileType { return TileType{ Material: MaterialRock, Passable: false, Presentation: '█', + Transparent: false, + Style: tcell.StyleDefault, } } -func Grass() TileType { +func TileTypeGrass() TileType { return TileType{ Material: MaterialGrass, Passable: true, Presentation: ',', + Transparent: false, + Style: tcell.StyleDefault, } } -func Void() TileType { +func TileTypeVoid() TileType { return TileType{ Material: MaterialVoid, Passable: false, Presentation: ' ', + Transparent: true, + Style: tcell.StyleDefault, + } +} + +func TileTypeWall() TileType { + return TileType{ + Material: MaterialWall, + Passable: false, + Presentation: '#', + Transparent: false, + Style: tcell.StyleDefault.Background(tcell.ColorGray), } } type Tile interface { Position() util.Position - Presentation() rune + Presentation() (rune, tcell.Style) Passable() bool + Transparent() bool } type StaticTile struct { @@ -73,14 +99,58 @@ func (st *StaticTile) Position() util.Position { return st.position } -func (st *StaticTile) Presentation() rune { - return st.t.Presentation +func (st *StaticTile) Presentation() (rune, tcell.Style) { + return st.t.Presentation, st.t.Style } func (st *StaticTile) Passable() bool { return st.t.Passable } +func (st *StaticTile) Transparent() bool { + return st.t.Transparent +} + func (st *StaticTile) Type() TileType { return st.t } + +type ItemTile struct { + position util.Position + itemType *ItemType + quantity int +} + +func CreateItemTile(position util.Position, itemType *ItemType, quantity int) *ItemTile { + it := new(ItemTile) + + it.position = position + it.itemType = itemType + it.quantity = quantity + + return it +} + +func (it *ItemTile) Type() *ItemType { + return it.itemType +} + +func (it *ItemTile) Quantity() int { + return it.quantity +} + +func (it *ItemTile) Position() util.Position { + return it.position +} + +func (it *ItemTile) Presentation() (rune, tcell.Style) { + return it.itemType.tileIcon, it.itemType.style +} + +func (it *ItemTile) Passable() bool { + return true +} + +func (it *ItemTile) Transparent() bool { + return false +} diff --git a/game/state/inventory_screen_state.go b/game/state/inventory_screen_state.go index 13e5b5a..750117b 100644 --- a/game/state/inventory_screen_state.go +++ b/game/state/inventory_screen_state.go @@ -1,30 +1,126 @@ package state import ( + "fmt" "mvvasilev/last_light/game/model" "mvvasilev/last_light/render" "mvvasilev/last_light/ui" + "mvvasilev/last_light/util" "github.com/gdamore/tcell/v2" + "github.com/gdamore/tcell/v2/views" ) type InventoryScreenState struct { - prevState GameState + prevState PausableState exitMenu bool - inventoryMenu *ui.UIWindow + inventoryMenu *ui.UIWindow + armourLabel *ui.UILabel + armourGrid *render.Grid + leftHandLabel *ui.UILabel + leftHandBox render.Rectangle + rightHandLabel *ui.UILabel + rightHandBox render.Rectangle + inventoryGrid *render.Grid + playerItems *render.ArbitraryDrawable + selectedItem *render.ArbitraryDrawable + help *ui.UILabel player *model.Player + + moveInventorySlotDirection model.Direction + selectedInventorySlot util.Position + dropSelectedInventorySlot bool } -func CreateInventoryScreenState(player *model.Player, prevState GameState) *InventoryScreenState { +func CreateInventoryScreenState(player *model.Player, prevState PausableState) *InventoryScreenState { iss := new(InventoryScreenState) iss.prevState = prevState iss.player = player iss.exitMenu = false + iss.selectedInventorySlot = util.PositionAt(0, 0) - iss.inventoryMenu = ui.CreateWindow(40, 0, 40, 24, "INVENTORY", tcell.StyleDefault) + iss.inventoryMenu = ui.CreateWindow(43, 0, 37, 24, "INVENTORY", tcell.StyleDefault) + + iss.armourLabel = ui.CreateSingleLineUILabel(58, 1, "ARMOUR", tcell.StyleDefault) + + iss.armourGrid = render.CreateGrid( + 53, 2, 3, 1, 4, 1, '┌', '─', '┬', '┐', '│', ' ', '│', '│', '├', '─', '┼', '┤', '└', '─', '┴', '┘', tcell.StyleDefault, tcell.StyleDefault.Background(tcell.ColorDarkSlateGray), + ) + + iss.leftHandLabel = ui.CreateUILabel( + 46, 1, 5, 1, "OFF", tcell.StyleDefault, + ) + + iss.leftHandBox = render.CreateRectangle( + 45, 2, 5, 3, + '┌', '─', '┐', + '│', ' ', '│', + '└', '─', '┘', + false, true, + tcell.StyleDefault, + ) + + iss.rightHandLabel = ui.CreateUILabel( + 74, 1, 5, 1, "DOM", tcell.StyleDefault, + ) + + iss.rightHandBox = render.CreateRectangle( + 73, 2, 5, 3, + '┌', '─', '┐', + '│', ' ', '│', + '└', '─', '┘', + false, true, + tcell.StyleDefault, + ) + + iss.inventoryGrid = render.CreateGrid( + 45, 5, 3, 1, 8, 4, '┌', '─', '┬', '┐', '│', ' ', '│', '│', '├', '─', '┼', '┤', '└', '─', '┴', '┘', tcell.StyleDefault, tcell.StyleDefault.Background(tcell.ColorDarkSlateGray), + ) + + iss.playerItems = render.CreateDrawingInstructions(func(v views.View) { + for y := range player.Inventory().Shape().Height() { + for x := range player.Inventory().Shape().Width() { + item := player.Inventory().ItemAt(x, y) + isHighlighted := x == iss.selectedInventorySlot.X() && y == iss.selectedInventorySlot.Y() + + if item == nil { + + if isHighlighted { + ui.CreateSingleLineUILabel(45+1+x*4, 5+1+y*2, " ", tcell.StyleDefault.Background(tcell.ColorDarkSlateGray)).Draw(v) + } + + continue + } + + style := item.Type().Style() + + if isHighlighted { + style = style.Background(tcell.ColorDarkSlateGray) + } + + ui.CreateSingleLineUILabel(45+1+x*4, 5+y*2, fmt.Sprintf("%03d", item.Quantity()), style).Draw(v) + ui.CreateSingleLineUILabel(45+1+x*4, 5+1+y*2, item.Type().Icon(), style).Draw(v) + } + } + }) + + iss.selectedItem = render.CreateDrawingInstructions(func(v views.View) { + ui.CreateWindow(45, 14, 33, 8, "ITEM", tcell.StyleDefault).Draw(v) + + item := player.Inventory().ItemAt(iss.selectedInventorySlot.XY()) + + if item == nil { + return + } + + ui.CreateSingleLineUILabel(46, 15, fmt.Sprintf("Name: %v", item.Name()), tcell.StyleDefault).Draw(v) + ui.CreateSingleLineUILabel(46, 16, fmt.Sprintf("Desc: %v", item.Description()), tcell.StyleDefault).Draw(v) + }) + + iss.help = ui.CreateSingleLineUILabel(45, 22, "hjkl - move, x - drop, e - equip", tcell.StyleDefault) return iss } @@ -33,19 +129,89 @@ func (iss *InventoryScreenState) OnInput(e *tcell.EventKey) { if e.Key() == tcell.KeyEsc || (e.Key() == tcell.KeyRune && e.Rune() == 'i') { iss.exitMenu = true } + + if e.Key() == tcell.KeyRune && e.Rune() == 'x' { + iss.dropSelectedInventorySlot = true + } + + if e.Key() != tcell.KeyRune { + return + } + + switch e.Rune() { + case 'k': + iss.moveInventorySlotDirection = model.DirectionUp + case 'j': + iss.moveInventorySlotDirection = model.DirectionDown + case 'h': + iss.moveInventorySlotDirection = model.DirectionLeft + case 'l': + iss.moveInventorySlotDirection = model.DirectionRight + } } func (iss *InventoryScreenState) OnTick(dt int64) GameState { if iss.exitMenu { + iss.prevState.Unpause() return iss.prevState } + if iss.dropSelectedInventorySlot { + iss.player.Inventory().Drop(iss.selectedInventorySlot.XY()) + iss.dropSelectedInventorySlot = false + } + + if iss.moveInventorySlotDirection != model.DirectionNone { + + switch iss.moveInventorySlotDirection { + case model.DirectionUp: + if iss.selectedInventorySlot.Y() == 0 { + break + } + + iss.selectedInventorySlot = iss.selectedInventorySlot.WithOffset(0, -1) + case model.DirectionDown: + if iss.selectedInventorySlot.Y() == iss.player.Inventory().Shape().Height()-1 { + break + } + + iss.selectedInventorySlot = iss.selectedInventorySlot.WithOffset(0, +1) + case model.DirectionLeft: + if iss.selectedInventorySlot.X() == 0 { + break + } + + iss.selectedInventorySlot = iss.selectedInventorySlot.WithOffset(-1, 0) + case model.DirectionRight: + if iss.selectedInventorySlot.X() == iss.player.Inventory().Shape().Width()-1 { + break + } + + iss.selectedInventorySlot = iss.selectedInventorySlot.WithOffset(+1, 0) + } + + iss.inventoryGrid.Highlight(iss.selectedInventorySlot) + iss.moveInventorySlotDirection = model.DirectionNone + } + return iss } func (iss *InventoryScreenState) CollectDrawables() []render.Drawable { - return append( + drawables := append( iss.prevState.CollectDrawables(), iss.inventoryMenu, + iss.armourLabel, + iss.armourGrid, + iss.leftHandLabel, + iss.leftHandBox, + iss.rightHandLabel, + iss.rightHandBox, + iss.inventoryGrid, + iss.playerItems, + iss.selectedItem, + iss.help, ) + + return drawables } diff --git a/game/state/playing_state.go b/game/state/playing_state.go index 6d64251..6407915 100644 --- a/game/state/playing_state.go +++ b/game/state/playing_state.go @@ -1,6 +1,7 @@ package state import ( + "math/rand" "mvvasilev/last_light/game/model" "mvvasilev/last_light/render" "mvvasilev/last_light/util" @@ -18,6 +19,7 @@ type PlayingState struct { movePlayerDirection model.Direction pauseGame bool openInventory bool + pickUpUnderPlayer bool } func BeginPlayingState() *PlayingState { @@ -25,18 +27,29 @@ func BeginPlayingState() *PlayingState { mapSize := util.SizeOf(128, 128) - s.player = model.CreatePlayer(40, 12) + dungeonLevel := model.CreateBSPDungeonLevel(mapSize.Width(), mapSize.Height(), 4) + + itemTiles := spawnItems(dungeonLevel) + + itemLevel := model.CreateEmptyDungeonLevel(mapSize.Width(), mapSize.Height()) + + for _, it := range itemTiles { + itemLevel.SetTileAt(it.Position().X(), it.Position().Y(), it) + } + + s.player = model.CreatePlayer(dungeonLevel.PlayerSpawnPoint().XY()) s.level = model.CreateMultilevelMap( - model.CreateFlatGroundDungeonLevel(mapSize.WH()), + dungeonLevel, + itemLevel, model.CreateEmptyDungeonLevel(mapSize.WH()), ) - s.level.SetTileAtHeight(40, 12, 1, s.player) + s.level.SetTileAtHeight(dungeonLevel.PlayerSpawnPoint().X(), dungeonLevel.PlayerSpawnPoint().Y(), 2, s.player) s.viewport = render.CreateViewport( util.PositionAt(0, 0), - util.PositionAt(40, 12), + dungeonLevel.PlayerSpawnPoint(), util.SizeOf(80, 24), tcell.StyleDefault, ) @@ -44,6 +57,48 @@ func BeginPlayingState() *PlayingState { return s } +func spawnItems(level *model.BSPDungeonLevel) []model.Tile { + rooms := level.Rooms() + + genTable := make(map[float32]*model.ItemType) + + genTable[0.2] = model.ItemTypeFish() + genTable[0.05] = model.ItemTypeBow() + genTable[0.051] = model.ItemTypeLongsword() + genTable[0.052] = model.ItemTypeKey() + + itemTiles := make([]model.Tile, 0, 10) + + for _, r := range rooms { + maxItems := int(0.10 * float64(r.Size().Area())) + + if maxItems < 1 { + continue + } + + numItems := rand.Intn(maxItems) + + for range numItems { + itemType := model.GenerateItemType(genTable) + + if itemType == nil { + continue + } + + pos := util.PositionAt( + util.RandInt(r.Position().X()+1, r.Position().X()+r.Size().Width()-1), + util.RandInt(r.Position().Y()+1, r.Position().Y()+r.Size().Height()-1), + ) + + itemTiles = append(itemTiles, model.CreateItemTile( + pos, itemType, 1, + )) + } + } + + return itemTiles +} + func (ps *PlayingState) Pause() { ps.pauseGame = true } @@ -66,15 +121,36 @@ func (ps *PlayingState) MovePlayer() { tileAtMovePos := ps.level.TileAt(newPlayerPos.XY()) if tileAtMovePos.Passable() { - ps.level.SetTileAtHeight(ps.player.Position().X(), ps.player.Position().Y(), 1, nil) + ps.level.SetTileAtHeight(ps.player.Position().X(), ps.player.Position().Y(), 2, nil) ps.player.Move(ps.movePlayerDirection) ps.viewport.SetCenter(ps.player.Position()) - ps.level.SetTileAtHeight(ps.player.Position().X(), ps.player.Position().Y(), 1, ps.player) + ps.level.SetTileAtHeight(ps.player.Position().X(), ps.player.Position().Y(), 2, ps.player) } ps.movePlayerDirection = model.DirectionNone } +func (ps *PlayingState) PickUpItemUnderPlayer() { + pos := ps.player.Position() + tile := ps.level.TileAtHeight(pos.X(), pos.Y(), 1) + + itemTile, ok := tile.(*model.ItemTile) + + if !ok { + return + } + + item := model.CreateItem(itemTile.Type(), itemTile.Quantity()) + + success := ps.player.Inventory().Push(item) + + if !success { + return + } + + ps.level.SetTileAtHeight(pos.X(), pos.Y(), 1, nil) +} + func (ps *PlayingState) OnInput(e *tcell.EventKey) { ps.player.Input(e) @@ -88,6 +164,11 @@ func (ps *PlayingState) OnInput(e *tcell.EventKey) { return } + if e.Key() == tcell.KeyRune && e.Rune() == 'p' { + ps.pickUpUnderPlayer = true + return + } + switch e.Key() { case tcell.KeyUp: ps.movePlayerDirection = model.DirectionUp @@ -119,6 +200,7 @@ func (ps *PlayingState) OnTick(dt int64) GameState { } if ps.openInventory { + ps.openInventory = false return CreateInventoryScreenState(ps.player, ps) } @@ -126,19 +208,24 @@ func (ps *PlayingState) OnTick(dt int64) GameState { ps.MovePlayer() } + if ps.pickUpUnderPlayer { + ps.pickUpUnderPlayer = false + ps.PickUpItemUnderPlayer() + } + return ps } func (ps *PlayingState) CollectDrawables() []render.Drawable { return render.Multidraw(render.CreateDrawingInstructions(func(v views.View) { - ps.viewport.DrawFromProvider(v, func(x, y int) rune { + ps.viewport.DrawFromProvider(v, func(x, y int) (rune, tcell.Style) { tile := ps.level.TileAt(x, y) if tile != nil { return tile.Presentation() } - return ' ' + return ' ', tcell.StyleDefault }) })) } diff --git a/render/grid.go b/render/grid.go index c2237cd..4a50672 100644 --- a/render/grid.go +++ b/render/grid.go @@ -16,6 +16,7 @@ type Grid struct { numCellsVertical int position util.Position style tcell.Style + highlightStyle tcell.Style northBorder rune westBorder rune @@ -36,6 +37,9 @@ type Grid struct { horizontalRightTJunction rune crossJunction rune + isHighlighted bool + highlightedGrid util.Position + fillRune rune } @@ -44,15 +48,15 @@ func CreateSimpleGrid( cellWidth, cellHeight int, numCellsHorizontal, numCellsVertical int, borderRune, fillRune rune, - style tcell.Style, -) Grid { + style tcell.Style, highlightStyle tcell.Style, +) *Grid { return CreateGrid( x, y, cellWidth, cellHeight, numCellsHorizontal, numCellsVertical, borderRune, borderRune, borderRune, borderRune, borderRune, fillRune, borderRune, borderRune, borderRune, borderRune, borderRune, borderRune, borderRune, borderRune, borderRune, borderRune, - style, + style, highlightStyle, ) } @@ -71,15 +75,17 @@ func CreateGrid( westBorder, fillRune, internalVerticalBorder, eastBorder, horizontalRightTJunction, internalHorizontalBorder, crossJunction, horizontalLeftTJunction, swCorner, southBorder, verticalUpwardsTJunction, seCorner rune, - style tcell.Style, -) Grid { - return Grid{ + style tcell.Style, highlightStyle tcell.Style, +) *Grid { + return &Grid{ id: uuid.New(), internalCellSize: util.SizeOf(cellWidth, cellHeight), numCellsHorizontal: numCellsHorizontal, numCellsVertical: numCellsVertical, + isHighlighted: false, position: util.PositionAt(x, y), style: style, + highlightStyle: highlightStyle, northBorder: northBorder, eastBorder: eastBorder, southBorder: southBorder, @@ -100,10 +106,19 @@ func CreateGrid( } } -func (g Grid) UniqueId() uuid.UUID { +func (g *Grid) UniqueId() uuid.UUID { return g.id } +func (g *Grid) Highlight(highlightedGrid util.Position) { + g.isHighlighted = true + g.highlightedGrid = highlightedGrid +} + +func (g *Grid) Unhighlight() { + g.isHighlighted = false +} + // C###T###T###C // # # # # // # # # # @@ -117,7 +132,7 @@ func (g Grid) UniqueId() uuid.UUID { // # # # # // # # # # // C###T###T###C -func (g Grid) drawBorders(v views.View) { +func (g *Grid) drawBorders(v views.View) { iCellSizeWidth := g.internalCellSize.Width() iCellSizeHeight := g.internalCellSize.Height() width := 1 + (iCellSizeWidth * int(g.numCellsHorizontal)) + (int(g.numCellsHorizontal)) @@ -125,59 +140,61 @@ func (g Grid) drawBorders(v views.View) { x := g.position.X() y := g.position.Y() - v.SetContent(x, y, g.nwCorner, nil, g.style) - v.SetContent(x+width-1, y, g.neCorner, nil, g.style) - v.SetContent(x, y+height-1, g.swCorner, nil, g.style) - v.SetContent(x+width-1, y+height-1, g.seCorner, nil, g.style) + style := g.style - for w := 1; w < width-1; w++ { - - for iw := 1; iw < int(g.numCellsVertical); iw++ { - if w%(iCellSizeWidth+1) == 0 { - v.SetContent(x+w, y+(iw*iCellSizeHeight+iw), g.crossJunction, nil, g.style) - continue - } - - v.SetContent(x+w, y+(iw*iCellSizeHeight+iw), g.internalHorizontalBorder, nil, g.style) + for w := 0; w < width; w++ { + for iw := 1; iw < g.numCellsVertical; iw++ { + v.SetContent(x+w, y+(iw*iCellSizeHeight+iw), g.internalHorizontalBorder, nil, style) } if w%(iCellSizeWidth+1) == 0 { - v.SetContent(x+w, y, g.verticalDownwardsTJunction, nil, g.style) - v.SetContent(x+w, y+height-1, g.verticalUpwardsTJunction, nil, g.style) + v.SetContent(x+w, y, g.verticalDownwardsTJunction, nil, style) + v.SetContent(x+w, y+height-1, g.verticalUpwardsTJunction, nil, style) continue } - v.SetContent(x+w, y, g.northBorder, nil, g.style) - v.SetContent(x+w, y+height-1, g.southBorder, nil, g.style) + v.SetContent(x+w, y, g.northBorder, nil, style) + v.SetContent(x+w, y+height-1, g.southBorder, nil, style) } - for h := 1; h < height-1; h++ { + for h := 0; h < height; h++ { + if h == 0 { + v.SetContent(x, y, g.nwCorner, nil, style) + v.SetContent(x, y+height-1, g.swCorner, nil, style) + continue + } - for ih := 1; ih < int(g.numCellsHorizontal); ih++ { + if h == height-1 { + v.SetContent(x+width-1, y, g.neCorner, nil, style) + v.SetContent(x+width-1, y+height-1, g.seCorner, nil, style) + continue + } + + for ih := 1; ih < g.numCellsHorizontal; ih++ { if h%(iCellSizeHeight+1) == 0 { - v.SetContent(x+(ih*iCellSizeHeight+ih), y+h, g.crossJunction, nil, g.style) + v.SetContent(x+(ih*iCellSizeWidth+ih), y+h, g.crossJunction, nil, style) continue } - v.SetContent(x+(ih*iCellSizeHeight+ih), y+h, g.internalVerticalBorder, nil, g.style) + v.SetContent(x+(ih*iCellSizeWidth+ih), y+h, g.internalVerticalBorder, nil, style) } if h%(iCellSizeHeight+1) == 0 { - v.SetContent(x, y+h, g.horizontalRightTJunction, nil, g.style) - v.SetContent(x+width-1, y+h, g.horizontalLeftTJunction, nil, g.style) + v.SetContent(x, y+h, g.horizontalRightTJunction, nil, style) + v.SetContent(x+width-1, y+h, g.horizontalLeftTJunction, nil, style) continue } - v.SetContent(x, y+h, g.westBorder, nil, g.style) - v.SetContent(x+width-1, y+h, g.eastBorder, nil, g.style) + v.SetContent(x, y+h, g.westBorder, nil, style) + v.SetContent(x+width-1, y+h, g.eastBorder, nil, style) } } -func (g Grid) drawFill(v views.View) { +func (g *Grid) drawFill(v views.View) { } -func (g Grid) Draw(v views.View) { +func (g *Grid) Draw(v views.View) { g.drawBorders(v) g.drawFill(v) } diff --git a/render/render_context.go b/render/render_context.go index 0e66176..d9c7acc 100644 --- a/render/render_context.go +++ b/render/render_context.go @@ -149,7 +149,9 @@ func (c *RenderContext) Draw(deltaTime int64, drawables []Drawable) { c.view.Clear() - fpsText := CreateText(0, 0, 16, 1, fmt.Sprintf("%v FPS", fps), tcell.StyleDefault) + msPerFrame := float32(fps) / 1000.0 + + fpsText := CreateText(0, 0, 16, 1, fmt.Sprintf("%vms", msPerFrame), tcell.StyleDefault) for _, d := range drawables { d.Draw(c.view) diff --git a/render/text.go b/render/text.go index 4746c27..05e673e 100644 --- a/render/text.go +++ b/render/text.go @@ -69,8 +69,11 @@ func (t *Text) Draw(s views.View) { currentVPos := 0 drawText := func(text string) { - for i, r := range text { - s.SetContent(x+currentHPos+i, y+currentVPos, r, nil, t.style) + lastPos := 0 + + for _, r := range text { + s.SetContent(x+currentHPos+lastPos, y+currentVPos, r, nil, t.style) + lastPos++ } } diff --git a/render/viewport.go b/render/viewport.go index 738df65..2284f4e 100644 --- a/render/viewport.go +++ b/render/viewport.go @@ -50,14 +50,15 @@ func (vp *Viewport) ScreenLocation() util.Position { return vp.screenLocation } -func (vp *Viewport) DrawFromProvider(v views.View, provider func(x, y int) rune) { +func (vp *Viewport) DrawFromProvider(v views.View, provider func(x, y int) (rune, tcell.Style)) { width, height := vp.viewportSize.WH() originX, originY := vp.viewportCenter.WithOffset(-width/2, -height/2).XY() screenX, screenY := vp.screenLocation.XY() for h := originY; h < originY+height; h++ { for w := originX; w < originX+width; w++ { - v.SetContent(screenX, screenY, provider(w, h), nil, vp.style) + r, style := provider(w, h) + v.SetContent(screenX, screenY, r, nil, style) screenX += 1 } diff --git a/util/util.go b/util/util.go index feb68ef..a9cbc7a 100644 --- a/util/util.go +++ b/util/util.go @@ -1,5 +1,7 @@ package util +import "math/rand" + type Position struct { x int y int @@ -36,10 +38,6 @@ func SizeOf(width int, height int) Size { return Size{int(width), int(height)} } -func SizeOfInt(width int, height int) Size { - return Size{width, height} -} - func (s Size) Width() int { return s.width } @@ -52,6 +50,10 @@ func (s Size) WH() (int, int) { return s.width, s.height } +func (s Size) Area() int { + return s.width * s.height +} + func LimitIncrement(i int, limit int) int { if (i + 1) > limit { return i @@ -67,3 +69,7 @@ func LimitDecrement(i int, limit int) int { return i - 1 } + +func RandInt(min, max int) int { + return min + rand.Intn(max-min) +}