From b30dc8dec38ff0d0d6f1592fee012dc87aa0120d Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Tue, 21 May 2024 23:08:51 +0300 Subject: [PATCH] Add fov, add rpg system, add rpg items --- DESIGN.md | 6 +- engine/fov.go | 87 +++++++ .../equipped_inventory.go} | 54 ++-- game/{model => item}/inventory.go | 42 +-- game/item/item.go | 87 +++++++ game/item/item_type.go | 121 +++++++++ game/model/item.go | 68 ----- game/model/item_type.go | 105 -------- game/model/npc.go | 14 +- game/{model => player}/player.go | 13 +- game/rpg/generate_items.go | 143 +++++++++++ game/rpg/rpg_entity.go | 90 +++++++ game/rpg/rpg_items.go | 241 ++++++++++++++++++ game/rpg/rpg_system.go | 211 +++++++++++++++ game/state/inventory_screen_state.go | 5 +- game/state/playing_state.go | 31 ++- game/ui/menu/player_inventory_menu.go | 19 +- game/world/bsp_map.go | 12 + game/world/dungeon.go | 89 ++++++- game/world/empty_map.go | 4 + game/world/entity_map.go | 12 + game/world/generate_bsp_map.go | 4 +- game/world/generate_empty_map.go | 11 +- game/world/generate_items.go | 57 ----- game/world/map.go | 54 +++- game/world/multilevel_map.go | 42 ++- game/world/tile.go | 64 ++++- 27 files changed, 1325 insertions(+), 361 deletions(-) create mode 100644 engine/fov.go rename game/{model/player_inventory.go => item/equipped_inventory.go} (51%) rename game/{model => item}/inventory.go (55%) create mode 100644 game/item/item.go create mode 100644 game/item/item_type.go delete mode 100644 game/model/item.go delete mode 100644 game/model/item_type.go rename game/{model => player}/player.go (73%) create mode 100644 game/rpg/generate_items.go create mode 100644 game/rpg/rpg_entity.go create mode 100644 game/rpg/rpg_items.go create mode 100644 game/rpg/rpg_system.go delete mode 100644 game/world/generate_items.go diff --git a/DESIGN.md b/DESIGN.md index 3f3eb66..045301f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,7 +1,8 @@ # Last Light - Roguelike RPG -- Inventory System +- ~~FOV with explored tiles greyed out~~ +- ~~Inventory System~~ - Weapons & Armor - Head - Chest @@ -11,6 +12,7 @@ - Right Hand - Damage Types - Physical + - Unarmed - Slashing - Piercing - Bludgeoning @@ -24,7 +26,7 @@ - 9-level Dungeon - 4 types of dungeon levels: - Caverns ( Cellular Automata ) - - Dungeon ( Maze w/ Rooms ) + - ~~Dungeon ( Maze w/ Rooms )~~ - Mine ( Broguelike ) - Underground City ( Caverns + Dungeon combo ) - Objective: Pick up the Last Light and bring it to its Altar ( Altar of the Last Light ) diff --git a/engine/fov.go b/engine/fov.go new file mode 100644 index 0000000..c6e4288 --- /dev/null +++ b/engine/fov.go @@ -0,0 +1,87 @@ +package engine + +import "math" + +/* Stolen and modified from the go-fov package */ + +// Compute takes a GridMap implementation along with the x and y coordinates representing a player's current +// position and will internally update the visibile set of tiles within the provided radius `r` +func ComputeFOV[T any](transform func(x, y int) T, isInBounds, isOpaque func(x, y int) bool, px, py, radius int) (visibilityMap map[Position]T) { + visibilityMap = make(map[Position]T) + + visibilityMap[PositionAt(px, py)] = transform(px, py) + + for i := 1; i <= 8; i++ { + fov(visibilityMap, transform, isInBounds, isOpaque, px, py, 1, 0, 1, i, radius) + } + + return visibilityMap +} + +// fov does the actual work of detecting the visible tiles based on the recursive shadowcasting algorithm +// annotations provided inline below for (hopefully) easier learning +func fov[T any](visibilityMap map[Position]T, transform func(x, y int) T, isInBounds, isOpaque func(x, y int) bool, px, py, dist int, lowSlope, highSlope float64, oct, rad int) { + // If the current distance is greater than the radius provided, then this is the end of the iteration + if dist > rad { + return + } + + // Convert our slope into integers that will represent the "height" from the player position + // "height" will alternately apply to x OR y coordinates as we move around the octants + low := math.Floor(lowSlope*float64(dist) + 0.5) + high := math.Floor(highSlope*float64(dist) + 0.5) + + // inGap refers to whether we are currently scanning non-blocked tiles consecutively + // inGap = true means that the previous tile examined was empty + inGap := false + + for height := low; height <= high; height++ { + // Given the player coords and a distance, height and octant, determine which tile is being visited + mapx, mapy := distHeightXY(px, py, dist, int(height), oct) + if isInBounds(mapx, mapy) && distTo(px, py, mapx, mapy) < rad { + // As long as a tile is within the bounds of the map, if we visit it at all, it is considered visible + // That's the efficiency of shadowcasting, you just dont visit tiles that aren't visible + visibilityMap[PositionAt(mapx, mapy)] = transform(mapx, mapy) + } + + if isInBounds(mapx, mapy) && isOpaque(mapx, mapy) { + if inGap { + // An opaque tile was discovered, so begin a recursive call + fov(visibilityMap, transform, isInBounds, isOpaque, px, py, dist+1, lowSlope, (height-0.5)/float64(dist), oct, rad) + } + // Any time a recursive call is made, adjust the minimum slope for all future calls within this octant + lowSlope = (height + 0.5) / float64(dist) + inGap = false + } else { + inGap = true + // We've reached the end of the scan and, since the last tile in the scan was empty, begin + // another on the next depth up + if height == high { + fov(visibilityMap, transform, isInBounds, isOpaque, px, py, dist+1, lowSlope, highSlope, oct, rad) + } + } + } +} + +// distHeightXY performs some bitwise and operations to handle the transposition of the depth and height values +// since the concept of "depth" and "height" is relative to whichever octant is currently being scanned +func distHeightXY(px, py, d, h, oct int) (int, int) { + if oct&0x1 > 0 { + d = -d + } + if oct&0x2 > 0 { + h = -h + } + if oct&0x4 > 0 { + return px + h, py + d + } + return px + d, py + h +} + +// distTo is simply a helper function to determine the distance between two points, for checking visibility of a tile +// within a provided radius +func distTo(x1, y1, x2, y2 int) int { + vx := math.Pow(float64(x1-x2), 2) + vy := math.Pow(float64(y1-y2), 2) + return int(math.Sqrt(vx + vy)) +} diff --git a/game/model/player_inventory.go b/game/item/equipped_inventory.go similarity index 51% rename from game/model/player_inventory.go rename to game/item/equipped_inventory.go index 26541dd..673707e 100644 --- a/game/model/player_inventory.go +++ b/game/item/equipped_inventory.go @@ -1,37 +1,41 @@ -package model +package item -import "mvvasilev/last_light/engine" +import ( + "mvvasilev/last_light/engine" +) type EquippedSlot int const ( - EquippedSlotOffhand EquippedSlot = iota - EquippedSlotDominantHand - EquippedSlotHead - EquippedSlotChestplate - EquippedSlotLeggings - EquippedSlotShoes + EquippedSlotNone EquippedSlot = 0 + + EquippedSlotOffhand EquippedSlot = 1 + EquippedSlotDominantHand EquippedSlot = 2 + EquippedSlotHead EquippedSlot = 3 + EquippedSlotChestplate EquippedSlot = 4 + EquippedSlotLeggings EquippedSlot = 5 + EquippedSlotShoes EquippedSlot = 6 ) type EquippedInventory struct { - offHand *Item - dominantHand *Item + offHand Item + dominantHand Item - head *Item - chestplate *Item - leggings *Item - shoes *Item + head Item + chestplate Item + leggings Item + shoes Item *BasicInventory } -func CreatePlayerInventory() *EquippedInventory { +func CreateEquippedInventory() *EquippedInventory { return &EquippedInventory{ BasicInventory: CreateInventory(engine.SizeOf(8, 4)), } } -func (ei *EquippedInventory) AtSlot(slot EquippedSlot) *Item { +func (ei *EquippedInventory) AtSlot(slot EquippedSlot) Item { switch slot { case EquippedSlotOffhand: return ei.offHand @@ -50,23 +54,21 @@ func (ei *EquippedInventory) AtSlot(slot EquippedSlot) *Item { } } -func (ei *EquippedInventory) Equip(item Item, slot EquippedSlot) *Item { - ref := &item - +func (ei *EquippedInventory) Equip(item Item, slot EquippedSlot) Item { switch slot { case EquippedSlotOffhand: - ei.offHand = ref + ei.offHand = item case EquippedSlotDominantHand: - ei.dominantHand = ref + ei.dominantHand = item case EquippedSlotHead: - ei.head = ref + ei.head = item case EquippedSlotChestplate: - ei.chestplate = ref + ei.chestplate = item case EquippedSlotLeggings: - ei.leggings = ref + ei.leggings = item case EquippedSlotShoes: - ei.shoes = ref + ei.shoes = item } - return ref + return item } diff --git a/game/model/inventory.go b/game/item/inventory.go similarity index 55% rename from game/model/inventory.go rename to game/item/inventory.go index 3ceb23d..2c5ac04 100644 --- a/game/model/inventory.go +++ b/game/item/inventory.go @@ -1,30 +1,32 @@ -package model +package item -import "mvvasilev/last_light/engine" +import ( + "mvvasilev/last_light/engine" +) type Inventory interface { - Items() []*Item + Items() []Item Shape() engine.Size Push(item Item) bool - Drop(x, y int) *Item - ItemAt(x, y int) *Item + Drop(x, y int) Item + ItemAt(x, y int) Item } type BasicInventory struct { - contents []*Item + contents []Item shape engine.Size } func CreateInventory(shape engine.Size) *BasicInventory { inv := new(BasicInventory) - inv.contents = make([]*Item, 0, shape.Height()*shape.Width()) + inv.contents = make([]Item, 0, shape.Height()*shape.Width()) inv.shape = shape return inv } -func (i *BasicInventory) Items() (items []*Item) { +func (i *BasicInventory) Items() (items []Item) { return i.contents } @@ -32,43 +34,43 @@ func (i *BasicInventory) Shape() engine.Size { return i.shape } -func (i *BasicInventory) Push(item Item) (success bool) { - if len(i.contents) == i.shape.Area() { +func (inv *BasicInventory) Push(i Item) (success bool) { + if len(inv.contents) == inv.shape.Area() { return false } - itemType := item.Type() + itemType := i.Type() // Try to first find a matching item with capacity - for index, existingItem := range i.contents { - if existingItem != nil && existingItem.itemType == itemType { + for index, existingItem := range inv.contents { + if existingItem != nil && existingItem.Type() == itemType { if existingItem.Quantity()+1 > existingItem.Type().MaxStack() { continue } - it := CreateItem(itemType, existingItem.Quantity()+1) - i.contents[index] = &it + it := CreateBasicItem(itemType, existingItem.Quantity()+1) + inv.contents[index] = &it return true } } // Next, try to find an intermediate empty slot to fit this item into - for index, existingItem := range i.contents { + for index, existingItem := range inv.contents { if existingItem == nil { - i.contents[index] = &item + inv.contents[index] = i return true } } // Finally, just append the new item at the end - i.contents = append(i.contents, &item) + inv.contents = append(inv.contents, i) return true } -func (i *BasicInventory) Drop(x, y int) *Item { +func (i *BasicInventory) Drop(x, y int) Item { index := y*i.shape.Width() + x if index > len(i.contents)-1 { @@ -82,7 +84,7 @@ func (i *BasicInventory) Drop(x, y int) *Item { return item } -func (i *BasicInventory) ItemAt(x, y int) (item *Item) { +func (i *BasicInventory) ItemAt(x, y int) (item Item) { index := y*i.shape.Width() + x if index > len(i.contents)-1 { diff --git a/game/item/item.go b/game/item/item.go new file mode 100644 index 0000000..30ebf4f --- /dev/null +++ b/game/item/item.go @@ -0,0 +1,87 @@ +package item + +import ( + "github.com/gdamore/tcell/v2" +) + +type Item interface { + Name() (string, tcell.Style) + Description() string + Type() ItemType + Quantity() int +} + +type BasicItem struct { + name string + nameStyle tcell.Style + description string + itemType ItemType + quantity int +} + +func EmptyItem() BasicItem { + return BasicItem{ + nameStyle: tcell.StyleDefault, + itemType: &BasicItemType{ + name: "", + description: "", + tileIcon: ' ', + itemIcon: " ", + style: tcell.StyleDefault, + maxStack: 0, + }, + } +} + +func CreateBasicItem(itemType ItemType, quantity int) BasicItem { + return BasicItem{ + itemType: itemType, + quantity: quantity, + } +} + +func CreateBasicItemWithName(name string, style tcell.Style, itemType ItemType, quantity int) BasicItem { + return BasicItem{ + name: name, + nameStyle: style, + itemType: itemType, + quantity: quantity, + } +} + +func (i BasicItem) WithName(name string, style tcell.Style) BasicItem { + i.name = name + i.nameStyle = style + + return i +} + +func (i BasicItem) Name() (string, tcell.Style) { + if i.name == "" { + return i.itemType.Name(), i.nameStyle + } + + return i.name, i.nameStyle +} + +func (i BasicItem) Description() string { + if i.description == "" { + return i.itemType.Description() + } + + return i.description +} + +func (i BasicItem) WithDescription(description string) BasicItem { + i.description = description + + return i +} + +func (i BasicItem) Type() ItemType { + return i.itemType +} + +func (i BasicItem) Quantity() int { + return i.quantity +} diff --git a/game/item/item_type.go b/game/item/item_type.go new file mode 100644 index 0000000..d3a175a --- /dev/null +++ b/game/item/item_type.go @@ -0,0 +1,121 @@ +package item + +import ( + "github.com/gdamore/tcell/v2" +) + +type ItemType interface { + Name() string + Description() string + TileIcon() rune + Icon() string + Style() tcell.Style + MaxStack() int + EquippableSlot() EquippedSlot +} + +type BasicItemType struct { + name string + description string + tileIcon rune + itemIcon string + maxStack int + equippableSlot EquippedSlot + + style tcell.Style +} + +func CreateBasicItemType( + name, description string, + tileIcon rune, + icon string, + maxStack int, + equippableSlot EquippedSlot, + style tcell.Style, +) *BasicItemType { + return &BasicItemType{ + name: name, + description: description, + tileIcon: tileIcon, + itemIcon: icon, + style: style, + maxStack: maxStack, + equippableSlot: equippableSlot, + } +} + +func (it *BasicItemType) Name() string { + return it.name +} + +func (it *BasicItemType) Description() string { + return it.description +} + +func (it *BasicItemType) TileIcon() rune { + return it.tileIcon +} + +func (it *BasicItemType) Icon() string { + return it.itemIcon +} + +func (it *BasicItemType) Style() tcell.Style { + return it.style +} + +func (it *BasicItemType) MaxStack() int { + return it.maxStack +} + +func (it *BasicItemType) EquippableSlot() EquippedSlot { + return it.equippableSlot +} + +func ItemTypeFish() ItemType { + return &BasicItemType{ + name: "Fish", + description: "What's a fish doing down here?", + tileIcon: '>', + itemIcon: "»o>", + style: tcell.StyleDefault.Foreground(tcell.ColorDarkCyan), + equippableSlot: EquippedSlotNone, + maxStack: 16, + } +} + +func ItemTypeGold() ItemType { + return &BasicItemType{ + name: "Gold", + description: "Not all those who wander are lost", + tileIcon: '¤', + itemIcon: " ¤ ", + equippableSlot: EquippedSlotNone, + style: tcell.StyleDefault.Foreground(tcell.ColorGoldenrod), + maxStack: 255, + } +} + +func ItemTypeArrow() ItemType { + return &BasicItemType{ + name: "Arrow", + description: "Ammunition for a bow", + tileIcon: '-', + itemIcon: "»->", + equippableSlot: EquippedSlotNone, + style: tcell.StyleDefault.Foreground(tcell.ColorGoldenrod), + maxStack: 32, + } +} + +func ItemTypeKey() ItemType { + return &BasicItemType{ + name: "Key", + description: "Indispensable for unlocking things", + tileIcon: '¬', + itemIcon: " o╖", + equippableSlot: EquippedSlotNone, + style: tcell.StyleDefault.Foreground(tcell.ColorDarkGoldenrod), + maxStack: 1, + } +} diff --git a/game/model/item.go b/game/model/item.go deleted file mode 100644 index ca66f5a..0000000 --- a/game/model/item.go +++ /dev/null @@ -1,68 +0,0 @@ -package model - -import ( - "github.com/gdamore/tcell/v2" -) - -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/item_type.go b/game/model/item_type.go deleted file mode 100644 index 5bab2ff..0000000 --- a/game/model/item_type.go +++ /dev/null @@ -1,105 +0,0 @@ -package model - -import ( - "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, - } -} diff --git a/game/model/npc.go b/game/model/npc.go index f2aed8f..b4b4a1e 100644 --- a/game/model/npc.go +++ b/game/model/npc.go @@ -7,28 +7,28 @@ import ( "github.com/google/uuid" ) -type NPC struct { +type BasicNPC struct { id uuid.UUID engine.Positioned } -func CreateNPC(pos engine.Position) *NPC { - return &NPC{ +func CreateNPC(pos engine.Position) *BasicNPC { + return &BasicNPC{ id: uuid.New(), Positioned: engine.WithPosition(pos), } } -func (c *NPC) MoveTo(newPosition engine.Position) { +func (c *BasicNPC) MoveTo(newPosition engine.Position) { c.Positioned.SetPosition(newPosition) } -func (c *NPC) UniqueId() uuid.UUID { +func (c *BasicNPC) UniqueId() uuid.UUID { return c.id } -func (c *NPC) Input(e *tcell.EventKey) { +func (c *BasicNPC) Input(e *tcell.EventKey) { } -func (c *NPC) Tick(dt int64) { +func (c *BasicNPC) Tick(dt int64) { } diff --git a/game/model/player.go b/game/player/player.go similarity index 73% rename from game/model/player.go rename to game/player/player.go index 822dea8..6488fa3 100644 --- a/game/model/player.go +++ b/game/player/player.go @@ -1,7 +1,9 @@ -package model +package player import ( "mvvasilev/last_light/engine" + "mvvasilev/last_light/game/item" + "mvvasilev/last_light/game/rpg" "github.com/gdamore/tcell/v2" "github.com/google/uuid" @@ -11,7 +13,9 @@ type Player struct { id uuid.UUID position engine.Position - inventory *EquippedInventory + inventory *item.EquippedInventory + + *rpg.BasicRPGEntity } func CreatePlayer(x, y int) *Player { @@ -19,7 +23,8 @@ func CreatePlayer(x, y int) *Player { p.id = uuid.New() p.position = engine.PositionAt(x, y) - p.inventory = CreatePlayerInventory() + p.inventory = item.CreateEquippedInventory() + p.BasicRPGEntity = rpg.CreateBasicRPGEntity() return p } @@ -48,7 +53,7 @@ func (p *Player) Transparent() bool { return false } -func (p *Player) Inventory() *EquippedInventory { +func (p *Player) Inventory() *item.EquippedInventory { return p.inventory } diff --git a/game/rpg/generate_items.go b/game/rpg/generate_items.go new file mode 100644 index 0000000..babf506 --- /dev/null +++ b/game/rpg/generate_items.go @@ -0,0 +1,143 @@ +package rpg + +import ( + "math/rand" + "mvvasilev/last_light/engine" + "mvvasilev/last_light/game/item" + + "github.com/gdamore/tcell/v2" + "github.com/google/uuid" +) + +type ItemSupplier func() item.Item + +type LootTable struct { + table []ItemSupplier +} + +func CreateLootTable() *LootTable { + return &LootTable{ + table: make([]ItemSupplier, 0), + } +} + +func (igt *LootTable) Add(weight int, createItemFunction ItemSupplier) { + for range weight { + igt.table = append(igt.table, createItemFunction) + } +} + +func (igt *LootTable) Generate() item.Item { + return igt.table[rand.Intn(len(igt.table))]() +} + +type ItemRarity int + +const ( + ItemRarity_Common ItemRarity = 0 + ItemRarity_Uncommon ItemRarity = 1 + ItemRarity_Rare ItemRarity = 2 + ItemRarity_Epic ItemRarity = 3 + ItemRarity_Legendary ItemRarity = 4 +) + +func pointPerRarity(rarity ItemRarity) int { + switch rarity { + case ItemRarity_Common: + return 0 + case ItemRarity_Uncommon: + return 3 + case ItemRarity_Rare: + return 5 + case ItemRarity_Epic: + return 8 + case ItemRarity_Legendary: + return 13 + default: + return 0 + } +} + +func generateItemName(itemType RPGItemType, rarity ItemRarity) (string, tcell.Style) { + switch rarity { + case ItemRarity_Common: + return itemType.Name(), tcell.StyleDefault + case ItemRarity_Uncommon: + return itemType.Name(), tcell.StyleDefault.Foreground(tcell.ColorLime) + case ItemRarity_Rare: + return itemType.Name(), tcell.StyleDefault.Foreground(tcell.ColorBlue) + case ItemRarity_Epic: + return itemType.Name(), tcell.StyleDefault.Foreground(tcell.ColorPurple) + case ItemRarity_Legendary: + return itemType.Name(), tcell.StyleDefault.Foreground(tcell.ColorOrange).Attributes(tcell.AttrBold) + default: + return itemType.Name(), tcell.StyleDefault + } +} + +func randomStat() Stat { + stats := []Stat{ + Stat_Attributes_Strength, + Stat_Attributes_Dexterity, + Stat_Attributes_Intelligence, + Stat_Attributes_Constitution, + Stat_PhysicalPrecisionBonus, + Stat_EvasionBonus, + Stat_MagicPrecisionBonus, + Stat_TotalPrecisionBonus, + Stat_DamageBonus_Physical_Unarmed, + Stat_DamageBonus_Physical_Slashing, + Stat_DamageBonus_Physical_Piercing, + Stat_DamageBonus_Physical_Bludgeoning, + Stat_DamageBonus_Magic_Fire, + Stat_DamageBonus_Magic_Cold, + Stat_DamageBonus_Magic_Necrotic, + Stat_DamageBonus_Magic_Thunder, + Stat_DamageBonus_Magic_Acid, + Stat_DamageBonus_Magic_Poison, + Stat_MaxHealthBonus, + } + + return stats[rand.Intn(len(stats))] +} + +func generateItemStatModifiers(rarity ItemRarity) []StatModifier { + points := pointPerRarity(rarity) + modifiers := []StatModifier{} + + for { + if points <= 0 { + break + } + + modAmount := engine.RandInt(-points/2, points) + + if modAmount == 0 { + continue + } + + modifiers = append(modifiers, StatModifier{ + Id: StatModifierId(uuid.New().String()), + Stat: randomStat(), + Bonus: modAmount, + }) + + points -= modAmount + } + + return modifiers +} + +// Each rarity gets an amount of generation points, the higher the rarity, the more points +// Each stat modifier consumes points. The higher the stat bonus, the more points it consumes. +func GenerateItemOfTypeAndRarity(itemType RPGItemType, rarity ItemRarity) RPGItem { + // points := pointPerRarity(rarity) + name, style := generateItemName(itemType, rarity) + + return CreateRPGItem( + name, + style, + itemType, + generateItemStatModifiers(rarity), + ) +} diff --git a/game/rpg/rpg_entity.go b/game/rpg/rpg_entity.go new file mode 100644 index 0000000..4f5c98a --- /dev/null +++ b/game/rpg/rpg_entity.go @@ -0,0 +1,90 @@ +package rpg + +type RPGEntity interface { + BaseStat(stat Stat) int + SetBaseStat(stat Stat, value int) + + CollectModifiersForStat(stat Stat) []StatModifier + AddStatModifier(modifier StatModifier) + RemoveStatModifier(id StatModifierId) + + CurrentHealth() int + Heal(health int) + Damage(damage int) +} + +type BasicRPGEntity struct { + stats map[Stat]int + + statModifiers map[Stat][]StatModifier + + currentHealth int +} + +func CreateBasicRPGEntity() *BasicRPGEntity { + return &BasicRPGEntity{ + stats: make(map[Stat]int, 0), + statModifiers: make(map[Stat][]StatModifier, 0), + currentHealth: 0, + } +} + +func (brpg *BasicRPGEntity) BaseStat(stat Stat) int { + return brpg.stats[stat] +} + +func (brpg *BasicRPGEntity) SetBaseStat(stat Stat, value int) { + brpg.stats[stat] = value +} + +func (brpg *BasicRPGEntity) CollectModifiersForStat(stat Stat) []StatModifier { + modifiers := brpg.statModifiers[stat] + + if modifiers == nil { + return []StatModifier{} + } + + return modifiers +} + +func (brpg *BasicRPGEntity) AddStatModifier(modifier StatModifier) { + existing := brpg.statModifiers[modifier.Stat] + + if existing == nil { + existing = make([]StatModifier, 0) + } + + existing = append(existing, modifier) + + brpg.statModifiers[modifier.Stat] = existing +} + +func (brpg *BasicRPGEntity) RemoveStatModifier(id StatModifierId) { + +} + +func (brpg *BasicRPGEntity) CurrentHealth() int { + return brpg.currentHealth +} + +func (brpg *BasicRPGEntity) Heal(health int) { + maxHealth := BaseMaxHealth(brpg) + + if brpg.currentHealth+health > maxHealth { + brpg.currentHealth = maxHealth + + return + } + + brpg.currentHealth += health +} + +func (brpg *BasicRPGEntity) Damage(damage int) { + if brpg.currentHealth-damage < 0 { + brpg.currentHealth = 0 + + return + } + + brpg.currentHealth -= damage +} diff --git a/game/rpg/rpg_items.go b/game/rpg/rpg_items.go new file mode 100644 index 0000000..0d53740 --- /dev/null +++ b/game/rpg/rpg_items.go @@ -0,0 +1,241 @@ +package rpg + +import ( + "mvvasilev/last_light/game/item" + + "github.com/gdamore/tcell/v2" +) + +type RPGItemType interface { + RollDamage() func(victim, attacker RPGEntity) (damage int, dmgType DamageType) + + item.ItemType +} + +type RPGItem interface { + Modifiers() []StatModifier + + item.Item +} + +type BasicRPGItemType struct { + damageRollFunc func(victim, attacker RPGEntity) (damage int, dmgType DamageType) + + *item.BasicItemType +} + +func (it *BasicRPGItemType) RollDamage() func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return it.damageRollFunc +} + +func ItemTypeBow() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + // TODO: Ranged + return RollD8(1), DamageType_Physical_Piercing + }, + BasicItemType: item.CreateBasicItemType( + "Bow", + "To shoot arrows with", + ')', + " |)", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorBrown), + ), + } +} + +func ItemTypeLongsword() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD8(1), DamageType_Physical_Slashing + }, + BasicItemType: item.CreateBasicItemType( + "Longsword", + "You know nothing.", + '/', + "╪══", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +func ItemTypeClub() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD8(1), DamageType_Physical_Bludgeoning + }, + BasicItemType: item.CreateBasicItemType( + "Club", + "Bonk", + '!', + "-══", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSaddleBrown), + ), + } +} + +func ItemTypeDagger() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD6(1), DamageType_Physical_Piercing + }, + BasicItemType: item.CreateBasicItemType( + "Dagger", + "Stabby, stabby", + '-', + " +─", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +func ItemTypeHandaxe() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD6(1), DamageType_Physical_Slashing + }, + BasicItemType: item.CreateBasicItemType( + "Handaxe", + "Choppy, choppy", + '¶', + " ─╗", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +func ItemTypeJavelin() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + // TODO: Ranged + return RollD6(1), DamageType_Physical_Piercing + }, + BasicItemType: item.CreateBasicItemType( + "Javelin", + "Ranged pokey, pokey", + 'Î', + " ─>", + 20, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +func ItemTypeLightHammer() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD6(1), DamageType_Physical_Bludgeoning + }, + BasicItemType: item.CreateBasicItemType( + "Handaxe", + "Choppy, choppy", + '¶', + " ─╗", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +func ItemTypeMace() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD6(1), DamageType_Physical_Bludgeoning + }, + BasicItemType: item.CreateBasicItemType( + "Mace", + "Smashey, smashey", + 'i', + " ─¤", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +func ItemTypeQuarterstaff() RPGItemType { + + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD6(1), DamageType_Physical_Bludgeoning + }, + BasicItemType: item.CreateBasicItemType( + "Quarterstaff", + "Whacky, whacky", + '|', + "───", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSaddleBrown), + ), + } +} + +func ItemTypeSickle() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD6(1), DamageType_Physical_Slashing + }, + BasicItemType: item.CreateBasicItemType( + "Sickle", + "Slicey, slicey?", + '?', + " ─U", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +func ItemTypeSpear() RPGItemType { + return &BasicRPGItemType{ + damageRollFunc: func(victim, attacker RPGEntity) (damage int, dmgType DamageType) { + return RollD8(1), DamageType_Physical_Piercing + }, + BasicItemType: item.CreateBasicItemType( + "Spear", + "Pokey, pokey", + 'Î', + "──>", + 1, + item.EquippedSlotDominantHand, + tcell.StyleDefault.Foreground(tcell.ColorSilver), + ), + } +} + +type BasicRPGItem struct { + modifiers []StatModifier + + item.BasicItem +} + +func (i *BasicRPGItem) Modifiers() []StatModifier { + return i.modifiers +} + +func CreateRPGItem(name string, style tcell.Style, itemType RPGItemType, modifiers []StatModifier) RPGItem { + return &BasicRPGItem{ + modifiers: modifiers, + BasicItem: item.CreateBasicItemWithName( + name, + style, + itemType, + 1, + ), + } +} diff --git a/game/rpg/rpg_system.go b/game/rpg/rpg_system.go new file mode 100644 index 0000000..fabea95 --- /dev/null +++ b/game/rpg/rpg_system.go @@ -0,0 +1,211 @@ +package rpg + +import ( + "math/rand" +) + +type Stat int + +const ( + // Used as a default value in cases where no other stat could be determined. + // Should never be used except in cases of error handling + Stat_NonExtant Stat = -1 + + Stat_Attributes_Strength Stat = 0 + Stat_Attributes_Dexterity Stat = 10 + Stat_Attributes_Intelligence Stat = 20 + Stat_Attributes_Constitution Stat = 30 + + Stat_PhysicalPrecisionBonus Stat = 5 + Stat_EvasionBonus Stat = 15 + Stat_MagicPrecisionBonus Stat = 25 + Stat_TotalPrecisionBonus Stat = 35 + + Stat_DamageBonus_Physical_Unarmed Stat = 40 + Stat_DamageBonus_Physical_Slashing Stat = 50 + Stat_DamageBonus_Physical_Piercing Stat = 60 + Stat_DamageBonus_Physical_Bludgeoning Stat = 70 + + Stat_DamageBonus_Magic_Fire Stat = 80 + Stat_DamageBonus_Magic_Cold Stat = 90 + Stat_DamageBonus_Magic_Necrotic Stat = 100 + Stat_DamageBonus_Magic_Thunder Stat = 110 + Stat_DamageBonus_Magic_Acid Stat = 120 + Stat_DamageBonus_Magic_Poison Stat = 130 + + Stat_MaxHealthBonus Stat = 140 +) + +type StatModifierId string + +type StatModifier struct { + Id StatModifierId + Stat Stat + Bonus int +} + +// RPG system is based off of dice rolls + +func rollDice(times, sides int) int { + acc := 0 + + for range times { + acc += 1 + rand.Intn(sides+1) + } + + return acc +} + +func RollD100(times int) int { + return rollDice(times, 100) +} + +func RollD20(times int) int { + return rollDice(times, 20) +} + +func RollD12(times int) int { + return rollDice(times, 12) +} + +func RollD10(times int) int { + return rollDice(times, 10) +} + +func RollD8(times int) int { + return rollDice(times, 8) +} + +func RollD6(times int) int { + return rollDice(times, 6) +} + +func RollD4(times int) int { + return rollDice(times, 4) +} + +// Contests are "meets it, beats it" +// +// Luck roll = 1d10 +// +// 2 rolls per attack: +// +// BASIC ATTACKS ( spells and abilities can have special rules, or in leu of special rules, these are used ): +// +// 1. Attack roll ( determines if the attack lands ). Contest between Evasion and Precision. +// Evasion = Dexterity + Luck roll. +// Precision = ( Strength | Intelligence ) + Luck roll ( intelligence for magic, strength for melee ). +// +// 2. Damage roll ( only if the previous was successful ). Each spell, ability and weapon has its own damage calculation. + +type DamageType int + +const ( + DamageType_Physical_Unarmed DamageType = 0 + DamageType_Physical_Slashing DamageType = 1 + DamageType_Physical_Piercing DamageType = 2 + DamageType_Physical_Bludgeoning DamageType = 3 + + DamageType_Magic_Fire DamageType = 4 + DamageType_Magic_Cold DamageType = 5 + DamageType_Magic_Necrotic DamageType = 6 + DamageType_Magic_Thunder DamageType = 7 + DamageType_Magic_Acid DamageType = 8 + DamageType_Magic_Poison DamageType = 9 +) + +func DamageTypeToBonusStat(dmgType DamageType) Stat { + switch dmgType { + case DamageType_Physical_Unarmed: + return Stat_DamageBonus_Physical_Unarmed + case DamageType_Physical_Slashing: + return Stat_DamageBonus_Physical_Slashing + case DamageType_Physical_Piercing: + return Stat_DamageBonus_Physical_Piercing + case DamageType_Physical_Bludgeoning: + return Stat_DamageBonus_Physical_Bludgeoning + case DamageType_Magic_Fire: + return Stat_DamageBonus_Magic_Fire + case DamageType_Magic_Cold: + return Stat_DamageBonus_Magic_Fire + case DamageType_Magic_Necrotic: + return Stat_DamageBonus_Magic_Necrotic + case DamageType_Magic_Thunder: + return Stat_DamageBonus_Magic_Thunder + case DamageType_Magic_Acid: + return Stat_DamageBonus_Magic_Acid + case DamageType_Magic_Poison: + return Stat_DamageBonus_Magic_Poison + default: + return Stat_NonExtant + } +} + +func LuckRoll() int { + return RollD10(1) +} + +func TotalModifierForStat(entity RPGEntity, stat Stat) int { + agg := 0 + + for _, m := range entity.CollectModifiersForStat(stat) { + agg += m.Bonus + } + + return agg +} + +func StatValue(entity RPGEntity, stat Stat) int { + return entity.BaseStat(stat) + TotalModifierForStat(entity, stat) +} + +// Base Max Health is determined from constitution: +// Constitution + Max Health Bonus + 10 +func BaseMaxHealth(entity RPGEntity) int { + return StatValue(entity, Stat_Attributes_Constitution) + StatValue(entity, Stat_MaxHealthBonus) + 10 +} + +// Dexterity + Evasion bonus + luck roll +func EvasionRoll(victim RPGEntity) int { + return StatValue(victim, Stat_Attributes_Dexterity) + StatValue(victim, Stat_EvasionBonus) + LuckRoll() +} + +// Strength + Precision bonus ( melee + total ) + luck roll +func PhysicalPrecisionRoll(attacker RPGEntity) int { + return StatValue(attacker, Stat_Attributes_Strength) + StatValue(attacker, Stat_PhysicalPrecisionBonus) + StatValue(attacker, Stat_TotalPrecisionBonus) + LuckRoll() +} + +// Intelligence + Precision bonus ( magic + total ) + luck roll +func MagicPrecisionRoll(attacker RPGEntity) int { + return StatValue(attacker, Stat_Attributes_Intelligence) + StatValue(attacker, Stat_MagicPrecisionBonus) + StatValue(attacker, Stat_TotalPrecisionBonus) + LuckRoll() +} + +// true = hit lands, false = hit does not land +func MagicHitRoll(attacker RPGEntity, victim RPGEntity) bool { + return hitRoll(EvasionRoll(victim), MagicPrecisionRoll(attacker)) +} + +// true = hit lands, false = hit does not land +func PhysicalHitRoll(attacker RPGEntity, victim RPGEntity) bool { + return hitRoll(EvasionRoll(victim), PhysicalPrecisionRoll(attacker)) +} + +func hitRoll(evasionRoll, precisionRoll int) bool { + if evasionRoll == 20 && precisionRoll == 20 { + return true + } + + if evasionRoll == 20 { + return false + } + + if precisionRoll == 20 { + return true + } + + return evasionRoll < precisionRoll +} + +func UnarmedDamage(attacker RPGEntity) int { + return RollD4(1) + StatValue(attacker, Stat_DamageBonus_Physical_Unarmed) +} diff --git a/game/state/inventory_screen_state.go b/game/state/inventory_screen_state.go index 4a80cce..4ff6fde 100644 --- a/game/state/inventory_screen_state.go +++ b/game/state/inventory_screen_state.go @@ -3,6 +3,7 @@ package state import ( "mvvasilev/last_light/engine" "mvvasilev/last_light/game/model" + "mvvasilev/last_light/game/player" "mvvasilev/last_light/game/ui/menu" "github.com/gdamore/tcell/v2" @@ -15,13 +16,13 @@ type InventoryScreenState struct { inventoryMenu *menu.PlayerInventoryMenu selectedInventorySlot engine.Position - player *model.Player + player *player.Player moveInventorySlotDirection model.Direction dropSelectedInventorySlot bool } -func CreateInventoryScreenState(player *model.Player, prevState PausableState) *InventoryScreenState { +func CreateInventoryScreenState(player *player.Player, prevState PausableState) *InventoryScreenState { iss := new(InventoryScreenState) iss.prevState = prevState diff --git a/game/state/playing_state.go b/game/state/playing_state.go index 7c76fd6..86fdcf1 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/player" "mvvasilev/last_light/game/ui" "mvvasilev/last_light/game/world" @@ -11,8 +12,8 @@ import ( ) type PlayingState struct { - player *model.Player - someNPC *model.NPC + player *player.Player + someNPC *model.BasicNPC dungeon *world.Dungeon @@ -35,7 +36,7 @@ func BeginPlayingState() *PlayingState { s.dungeon = world.CreateDungeon(mapSize.Width(), mapSize.Height(), 1) - s.player = model.CreatePlayer(s.dungeon.CurrentLevel().PlayerSpawnPoint().XY()) + s.player = player.CreatePlayer(s.dungeon.CurrentLevel().PlayerSpawnPoint().XY()) s.someNPC = model.CreateNPC(s.dungeon.CurrentLevel().NextLevelStaircase()) @@ -172,10 +173,10 @@ func (ps *PlayingState) PickUpItemUnderPlayer() { return } - success := ps.player.Inventory().Push(*item) + success := ps.player.Inventory().Push(item) if !success { - ps.dungeon.CurrentLevel().SetItemAt(pos.X(), pos.Y(), *item) + ps.dungeon.CurrentLevel().SetItemAt(pos.X(), pos.Y(), item) } } @@ -286,13 +287,31 @@ func (ps *PlayingState) OnTick(dt int64) GameState { func (ps *PlayingState) CollectDrawables() []engine.Drawable { return engine.Multidraw(engine.CreateDrawingInstructions(func(v views.View) { + visibilityMap := engine.ComputeFOV( + func(x, y int) world.Tile { + ps.dungeon.CurrentLevel().Flatten().MarkExplored(x, y) + + return ps.dungeon.CurrentLevel().TileAt(x, y) + }, + func(x, y int) bool { return ps.dungeon.CurrentLevel().Flatten().IsInBounds(x, y) }, + func(x, y int) bool { return ps.dungeon.CurrentLevel().Flatten().TileAt(x, y).Opaque() }, + ps.player.Position().X(), ps.player.Position().Y(), + 13, + ) + ps.viewport.DrawFromProvider(v, func(x, y int) (rune, tcell.Style) { - tile := ps.dungeon.CurrentLevel().TileAt(x, y) + tile := visibilityMap[engine.PositionAt(x, y)] if tile != nil { return tile.Presentation() } + explored := ps.dungeon.CurrentLevel().Flatten().ExploredTileAt(x, y) + + if explored != nil { + return explored.Presentation() + } + return ' ', tcell.StyleDefault }) })) diff --git a/game/ui/menu/player_inventory_menu.go b/game/ui/menu/player_inventory_menu.go index 440b6f5..8585977 100644 --- a/game/ui/menu/player_inventory_menu.go +++ b/game/ui/menu/player_inventory_menu.go @@ -3,7 +3,7 @@ package menu import ( "fmt" "mvvasilev/last_light/engine" - "mvvasilev/last_light/game/model" + "mvvasilev/last_light/game/item" "mvvasilev/last_light/game/ui" "github.com/gdamore/tcell/v2" @@ -12,7 +12,7 @@ import ( ) type PlayerInventoryMenu struct { - inventory *model.EquippedInventory + inventory *item.EquippedInventory inventoryMenu *ui.UIWindow armourLabel *ui.UILabel @@ -29,7 +29,7 @@ type PlayerInventoryMenu struct { selectedInventorySlot engine.Position } -func CreatePlayerInventoryMenu(x, y int, playerInventory *model.EquippedInventory, style tcell.Style, highlightStyle tcell.Style) *PlayerInventoryMenu { +func CreatePlayerInventoryMenu(x, y int, playerInventory *item.EquippedInventory, style tcell.Style, highlightStyle tcell.Style) *PlayerInventoryMenu { menu := new(PlayerInventoryMenu) menu.inventory = playerInventory @@ -124,8 +124,17 @@ func CreatePlayerInventoryMenu(x, y int, playerInventory *model.EquippedInventor return } - ui.CreateSingleLineUILabel(x+3, y+15, fmt.Sprintf("Name: %v", item.Name()), style).Draw(v) - ui.CreateSingleLineUILabel(x+3, y+16, fmt.Sprintf("Desc: %v", item.Description()), style).Draw(v) + name, nameStyle := item.Name() + + ui.CreateSingleLineUILabel(x+3, y+15, name, nameStyle).Draw(v) + + // |Stt:+00|Stt:+00|Stt:+00|Stt:+00| + // switch it := item.(type) { + // case rpg.RPGItem: + // //statModifiers := it.Modifiers() + + // default: + // } }) menu.help = ui.CreateSingleLineUILabel(x+2, y+22, "hjkl - move, x - drop, e - equip", style) diff --git a/game/world/bsp_map.go b/game/world/bsp_map.go index beeae6a..63c9280 100644 --- a/game/world/bsp_map.go +++ b/game/world/bsp_map.go @@ -32,6 +32,18 @@ func (bsp *BSPDungeonMap) TileAt(x int, y int) Tile { return bsp.level.TileAt(x, y) } +func (bsp *BSPDungeonMap) IsInBounds(x, y int) bool { + return bsp.level.IsInBounds(x, y) +} + +func (bsp *BSPDungeonMap) ExploredTileAt(x, y int) Tile { + return bsp.level.ExploredTileAt(x, y) +} + +func (bsp *BSPDungeonMap) MarkExplored(x, y int) { + bsp.level.MarkExplored(x, y) +} + func (bsp *BSPDungeonMap) Tick(dt int64) { } diff --git a/game/world/dungeon.go b/game/world/dungeon.go index 2e699ad..15d89d4 100644 --- a/game/world/dungeon.go +++ b/game/world/dungeon.go @@ -3,7 +3,10 @@ package world import ( "math/rand" "mvvasilev/last_light/engine" + "mvvasilev/last_light/game/item" "mvvasilev/last_light/game/model" + "mvvasilev/last_light/game/rpg" + "slices" "github.com/gdamore/tcell/v2" "github.com/google/uuid" @@ -104,12 +107,39 @@ type DungeonLevel struct { func CreateDungeonLevel(width, height int, dungeonType DungeonType) *DungeonLevel { - genTable := make(map[float32]*model.ItemType, 0) + genTable := rpg.CreateLootTable() - genTable[0.2] = model.ItemTypeFish() - genTable[0.05] = model.ItemTypeBow() - genTable[0.051] = model.ItemTypeLongsword() - genTable[0.052] = model.ItemTypeKey() + genTable.Add(10, func() item.Item { + return item.CreateBasicItem(item.ItemTypeFish(), 1) + }) + + genTable.Add(1, func() item.Item { + itemTypes := []rpg.RPGItemType{ + rpg.ItemTypeBow(), + rpg.ItemTypeLongsword(), + rpg.ItemTypeClub(), + rpg.ItemTypeDagger(), + rpg.ItemTypeHandaxe(), + rpg.ItemTypeJavelin(), + rpg.ItemTypeLightHammer(), + rpg.ItemTypeMace(), + rpg.ItemTypeQuarterstaff(), + rpg.ItemTypeSickle(), + rpg.ItemTypeSpear(), + } + + itemType := itemTypes[rand.Intn(len(itemTypes))] + + rarities := []rpg.ItemRarity{ + rpg.ItemRarity_Common, + rpg.ItemRarity_Uncommon, + rpg.ItemRarity_Rare, + rpg.ItemRarity_Epic, + rpg.ItemRarity_Legendary, + } + + return rpg.GenerateItemOfTypeAndRarity(itemType, rarities[rand.Intn(len(rarities))]) + }) var groundLevel interface { Map @@ -126,7 +156,7 @@ func CreateDungeonLevel(width, height int, dungeonType DungeonType) *DungeonLeve groundLevel = CreateBSPDungeonMap(width, height, 4) } - items := SpawnItems(groundLevel.Rooms(), 0.01, genTable, []engine.Position{ + items := SpawnItems(groundLevel.Rooms(), 0.02, genTable, []engine.Position{ groundLevel.NextLevelStaircasePosition(), groundLevel.PlayerSpawnPoint(), groundLevel.PreviousLevelStaircasePosition(), @@ -157,6 +187,43 @@ func CreateDungeonLevel(width, height int, dungeonType DungeonType) *DungeonLeve return d } +func SpawnItems(spawnableAreas []engine.BoundingBox, maxItemRatio float32, genTable *rpg.LootTable, forbiddenPositions []engine.Position) []Tile { + rooms := spawnableAreas + + itemTiles := make([]Tile, 0, 10) + + for _, r := range rooms { + maxItems := int(maxItemRatio * float32(r.Size().Area())) + + if maxItems < 1 { + continue + } + + numItems := rand.Intn(maxItems) + + for range numItems { + itemType := genTable.Generate() + + if itemType == nil { + continue + } + + pos := engine.PositionAt( + engine.RandInt(r.Position().X()+1, r.Position().X()+r.Size().Width()-1), + 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)) + } + } + + return itemTiles +} + func (d *DungeonLevel) PlayerSpawnPoint() engine.Position { return d.groundLevel.PlayerSpawnPoint() } @@ -185,7 +252,7 @@ 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 { +func (d *DungeonLevel) RemoveItemAt(x, y int) item.Item { if !d.groundLevel.Size().Contains(x, y) { return nil } @@ -199,17 +266,15 @@ func (d *DungeonLevel) RemoveItemAt(x, y int) *model.Item { d.itemLevel.SetTileAt(x, y, nil) - item := model.CreateItem(itemTile.Type(), itemTile.Quantity()) - - return &item + return itemTile.Item() } -func (d *DungeonLevel) SetItemAt(x, y int, it model.Item) (success bool) { +func (d *DungeonLevel) SetItemAt(x, y int, it item.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())) + d.itemLevel.SetTileAt(x, y, CreateItemTile(engine.PositionAt(x, y), it)) return true } diff --git a/game/world/empty_map.go b/game/world/empty_map.go index bbaeef4..160ae73 100644 --- a/game/world/empty_map.go +++ b/game/world/empty_map.go @@ -18,6 +18,10 @@ func (edl *EmptyDungeonMap) TileAt(x int, y int) Tile { return edl.level.TileAt(x, y) } +func (edl *EmptyDungeonMap) IsInBounds(x, y int) bool { + return edl.level.IsInBounds(x, y) +} + func (edl *EmptyDungeonMap) Tick(dt int64) { } diff --git a/game/world/entity_map.go b/game/world/entity_map.go index aa0a1b7..9a21891 100644 --- a/game/world/entity_map.go +++ b/game/world/entity_map.go @@ -111,6 +111,18 @@ func (em *EntityMap) TileAt(x int, y int) Tile { return em.entities[key] } +func (em *EntityMap) IsInBounds(x, y int) bool { + return em.FitsWithin(x, y) +} + +func (em *EntityMap) MarkExplored(x, y int) { + +} + +func (em *EntityMap) ExploredTileAt(x, y int) Tile { + return CreateStaticTile(x, y, TileTypeVoid()) +} + func (em *EntityMap) Tick(dt int64) { for _, e := range em.entities { e.Entity().Tick(dt) diff --git a/game/world/generate_bsp_map.go b/game/world/generate_bsp_map.go index db34293..5e4b02f 100644 --- a/game/world/generate_bsp_map.go +++ b/game/world/generate_bsp_map.go @@ -3,6 +3,8 @@ package world import ( "math/rand" "mvvasilev/last_light/engine" + + "github.com/gdamore/tcell/v2" ) type splitDirection bool @@ -92,7 +94,7 @@ func CreateBSPDungeonMap(width, height int, numSplits int) *BSPDungeonMap { staircaseRoom := findRoom(root.right) bsp.rooms = rooms - bsp.level = CreateBasicMap(tiles) + bsp.level = CreateBasicMap(tiles, tcell.StyleDefault.Foreground(tcell.ColorLightSlateGrey)) bsp.playerSpawnPoint = engine.PositionAt( spawnRoom.Position().X()+spawnRoom.Size().Width()/2, diff --git a/game/world/generate_empty_map.go b/game/world/generate_empty_map.go index e7e817f..61fe5ef 100644 --- a/game/world/generate_empty_map.go +++ b/game/world/generate_empty_map.go @@ -1,18 +1,13 @@ package world -func CreateEmptyDungeonLevel(width, height int) *EmptyDungeonMap { - m := new(EmptyDungeonMap) +import "github.com/gdamore/tcell/v2" +func CreateEmptyDungeonLevel(width, height int) *BasicMap { tiles := make([][]Tile, height) for h := range height { tiles[h] = make([]Tile, width) } - 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 + return CreateBasicMap(tiles, tcell.StyleDefault.Foreground(tcell.ColorLightSlateGrey)) } diff --git a/game/world/generate_items.go b/game/world/generate_items.go deleted file mode 100644 index 2f23a92..0000000 --- a/game/world/generate_items.go +++ /dev/null @@ -1,57 +0,0 @@ -package world - -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, forbiddenPositions []engine.Position) []Tile { - rooms := spawnableAreas - - itemTiles := make([]Tile, 0, 10) - - for _, r := range rooms { - maxItems := int(maxItemRatio * float32(r.Size().Area())) - - if maxItems < 1 { - continue - } - - numItems := rand.Intn(maxItems) - - for range numItems { - itemType := GenerateItemType(genTable) - - if itemType == nil { - continue - } - - pos := engine.PositionAt( - engine.RandInt(r.Position().X()+1, r.Position().X()+r.Size().Width()-1), - 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)) - } - } - - return itemTiles -} - -func GenerateItemType(genTable map[float32]*model.ItemType) *model.ItemType { - num := rand.Float32() - - for k, v := range genTable { - if num > k { - return v - } - } - - return nil -} diff --git a/game/world/map.go b/game/world/map.go index e4876b3..c2b7c9d 100644 --- a/game/world/map.go +++ b/game/world/map.go @@ -2,12 +2,17 @@ package world import ( "mvvasilev/last_light/engine" + + "github.com/gdamore/tcell/v2" ) type Map interface { Size() engine.Size SetTileAt(x, y int, t Tile) Tile TileAt(x, y int) Tile + IsInBounds(x, y int) bool + ExploredTileAt(x, y int) Tile + MarkExplored(x, y int) Tick(dt int64) } @@ -28,18 +33,23 @@ type WithPreviousLevelStaircasePosition interface { } type BasicMap struct { - tiles [][]Tile + tiles [][]Tile + exploredTiles map[engine.Position]Tile + + exploredStyle tcell.Style } -func CreateBasicMap(tiles [][]Tile) *BasicMap { +func CreateBasicMap(tiles [][]Tile, exploredStyle tcell.Style) *BasicMap { bm := new(BasicMap) bm.tiles = tiles + bm.exploredTiles = make(map[engine.Position]Tile, 0) + bm.exploredStyle = exploredStyle return bm } -func (bm *BasicMap) Tick() { +func (bm *BasicMap) Tick(dt int64) { } func (bm *BasicMap) Size() engine.Size { @@ -47,11 +57,7 @@ func (bm *BasicMap) Size() engine.Size { } func (bm *BasicMap) SetTileAt(x int, y int, t Tile) Tile { - if len(bm.tiles) <= y || len(bm.tiles[0]) <= x { - return CreateStaticTile(x, y, TileTypeVoid()) - } - - if x < 0 || y < 0 { + if !bm.IsInBounds(x, y) { return CreateStaticTile(x, y, TileTypeVoid()) } @@ -61,11 +67,7 @@ func (bm *BasicMap) SetTileAt(x int, y int, t Tile) Tile { } 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() { + if !bm.IsInBounds(x, y) { return CreateStaticTile(x, y, TileTypeVoid()) } @@ -77,3 +79,29 @@ func (bm *BasicMap) TileAt(x int, y int) Tile { return tile } + +func (bm *BasicMap) IsInBounds(x, y int) bool { + if x < 0 || y < 0 { + return false + } + + if x >= bm.Size().Width() || y >= bm.Size().Height() { + return false + } + + return true +} + +func (bm *BasicMap) ExploredTileAt(x, y int) Tile { + return bm.exploredTiles[engine.PositionAt(x, y)] +} + +func (bm *BasicMap) MarkExplored(x, y int) { + if !bm.IsInBounds(x, y) { + return + } + + tile := bm.TileAt(x, y) + + bm.exploredTiles[engine.PositionAt(x, y)] = CreateStaticTileWithStyleOverride(tile.Position().X(), tile.Position().Y(), tile.Type(), bm.exploredStyle) +} diff --git a/game/world/multilevel_map.go b/game/world/multilevel_map.go index ff46b21..f4ddafc 100644 --- a/game/world/multilevel_map.go +++ b/game/world/multilevel_map.go @@ -45,11 +45,7 @@ func (mm *MultilevelMap) SetTileAtHeight(x, y, height int, t 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() { + if !mm.IsInBounds(x, y) { return tiles } @@ -66,11 +62,7 @@ func (mm *MultilevelMap) CollectTilesAt(x, y int, filter func(t Tile) bool) []Ti } 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() { + if !mm.IsInBounds(x, y) { return CreateStaticTile(x, y, TileTypeVoid()) } @@ -86,12 +78,38 @@ func (mm *MultilevelMap) TileAt(x int, y int) Tile { return CreateStaticTile(x, y, TileTypeVoid()) } -func (mm *MultilevelMap) TileAtHeight(x, y, height int) Tile { +func (mm *MultilevelMap) IsInBounds(x, y int) bool { if x < 0 || y < 0 { - return CreateStaticTile(x, y, TileTypeVoid()) + return false } if x >= mm.Size().Width() || y >= mm.Size().Height() { + return false + } + + return true +} + +func (mm *MultilevelMap) MarkExplored(x, y int) { + for _, m := range mm.layers { + m.MarkExplored(x, y) + } +} + +func (mm *MultilevelMap) ExploredTileAt(x, y int) Tile { + for i := len(mm.layers) - 1; i >= 0; i-- { + tile := mm.layers[i].ExploredTileAt(x, y) + + if tile != nil && !tile.Transparent() { + return tile + } + } + + return CreateStaticTile(x, y, TileTypeVoid()) +} + +func (mm *MultilevelMap) TileAtHeight(x, y, height int) Tile { + if !mm.IsInBounds(x, y) { return CreateStaticTile(x, y, TileTypeVoid()) } diff --git a/game/world/tile.go b/game/world/tile.go index 04f8812..d4a9e09 100644 --- a/game/world/tile.go +++ b/game/world/tile.go @@ -2,6 +2,7 @@ package world import ( "mvvasilev/last_light/engine" + "mvvasilev/last_light/game/item" "mvvasilev/last_light/game/model" "github.com/gdamore/tcell/v2" @@ -26,6 +27,7 @@ type TileType struct { Passable bool Presentation rune Transparent bool + Opaque bool Style tcell.Style } @@ -35,6 +37,7 @@ func TileTypeGround() TileType { Passable: true, Presentation: '.', Transparent: false, + Opaque: false, Style: tcell.StyleDefault, } } @@ -45,6 +48,7 @@ func TileTypeRock() TileType { Passable: false, Presentation: '█', Transparent: false, + Opaque: true, Style: tcell.StyleDefault, } } @@ -55,6 +59,7 @@ func TileTypeGrass() TileType { Passable: true, Presentation: ',', Transparent: false, + Opaque: false, Style: tcell.StyleDefault, } } @@ -65,6 +70,7 @@ func TileTypeVoid() TileType { Passable: false, Presentation: ' ', Transparent: true, + Opaque: true, Style: tcell.StyleDefault, } } @@ -75,6 +81,7 @@ func TileTypeWall() TileType { Passable: false, Presentation: '#', Transparent: false, + Opaque: true, Style: tcell.StyleDefault.Background(tcell.ColorGray), } } @@ -85,6 +92,7 @@ func TileTypeClosedDoor() TileType { Passable: false, Transparent: false, Presentation: '[', + Opaque: true, Style: tcell.StyleDefault.Foreground(tcell.ColorLightSteelBlue).Background(tcell.ColorSaddleBrown), } } @@ -95,6 +103,7 @@ func TileTypeOpenDoor() TileType { Passable: false, Transparent: false, Presentation: '_', + Opaque: false, Style: tcell.StyleDefault.Foreground(tcell.ColorLightSteelBlue), } } @@ -105,6 +114,7 @@ func TileTypeStaircaseDown() TileType { Passable: true, Transparent: false, Presentation: '≡', + Opaque: false, Style: tcell.StyleDefault.Foreground(tcell.ColorDarkSlateGray).Attributes(tcell.AttrBold), } } @@ -115,6 +125,7 @@ func TileTypeStaircaseUp() TileType { Passable: true, Transparent: false, Presentation: '^', + Opaque: false, Style: tcell.StyleDefault.Foreground(tcell.ColorDarkSlateGray).Attributes(tcell.AttrBold), } } @@ -124,11 +135,15 @@ type Tile interface { Presentation() (rune, tcell.Style) Passable() bool Transparent() bool + Opaque() bool + Type() TileType } type StaticTile struct { position engine.Position t TileType + + style tcell.Style } func CreateStaticTile(x, y int, t TileType) Tile { @@ -136,16 +151,25 @@ func CreateStaticTile(x, y int, t TileType) Tile { st.position = engine.PositionAt(x, y) st.t = t + st.style = t.Style return st } +func CreateStaticTileWithStyleOverride(x, y int, t TileType, style tcell.Style) Tile { + return &StaticTile{ + position: engine.PositionAt(x, y), + t: t, + style: style, + } +} + func (st *StaticTile) Position() engine.Position { return st.position } func (st *StaticTile) Presentation() (rune, tcell.Style) { - return st.t.Presentation, st.t.Style + return st.t.Presentation, st.style } func (st *StaticTile) Passable() bool { @@ -156,32 +180,30 @@ func (st *StaticTile) Transparent() bool { return st.t.Transparent } +func (st *StaticTile) Opaque() bool { + return st.t.Opaque +} + func (st *StaticTile) Type() TileType { return st.t } type ItemTile struct { position engine.Position - itemType *model.ItemType - quantity int + item item.Item } -func CreateItemTile(position engine.Position, itemType *model.ItemType, quantity int) *ItemTile { +func CreateItemTile(position engine.Position, item item.Item) *ItemTile { it := new(ItemTile) it.position = position - it.itemType = itemType - it.quantity = quantity + it.item = item return it } -func (it *ItemTile) Type() *model.ItemType { - return it.itemType -} - -func (it *ItemTile) Quantity() int { - return it.quantity +func (it *ItemTile) Item() item.Item { + return it.item } func (it *ItemTile) Position() engine.Position { @@ -189,7 +211,7 @@ func (it *ItemTile) Position() engine.Position { } func (it *ItemTile) Presentation() (rune, tcell.Style) { - return it.itemType.TileIcon(), it.itemType.Style() + return it.item.Type().TileIcon(), it.item.Type().Style() } func (it *ItemTile) Passable() bool { @@ -200,6 +222,14 @@ func (it *ItemTile) Transparent() bool { return false } +func (it *ItemTile) Opaque() bool { + return false +} + +func (it *ItemTile) Type() TileType { + return TileType{} +} + type EntityTile interface { Entity() model.MovableEntity Tile @@ -239,3 +269,11 @@ func (bet *BasicEntityTile) Passable() bool { func (bet *BasicEntityTile) Transparent() bool { return false } + +func (bet *BasicEntityTile) Opaque() bool { + return false +} + +func (bet *BasicEntityTile) Type() TileType { + return TileType{} +}