Damaging NPCs

This commit is contained in:
Miroslav Vasilev 2024-06-01 11:20:51 +03:00
parent 9fdf117c24
commit 6bcb59867e
12 changed files with 318 additions and 72 deletions

View file

@ -2,6 +2,7 @@ package npc
import (
"mvvasilev/last_light/engine"
"mvvasilev/last_light/game/item"
"github.com/gdamore/tcell/v2"
"github.com/google/uuid"
@ -49,8 +50,7 @@ func MovementDirectionOffset(dir Direction) (int, int) {
type Entity interface {
UniqueId() uuid.UUID
Input(e *tcell.EventKey)
Tick(dt int64)
Presentation() (rune, tcell.Style)
}
type MovableEntity interface {
@ -59,3 +59,9 @@ type MovableEntity interface {
Entity
}
type EquippedEntity interface {
Inventory() *item.EquippedInventory
Entity
}

View file

@ -7,18 +7,34 @@ import (
"github.com/google/uuid"
)
type NPC interface {
Name() string
MovableEntity
}
type BasicNPC struct {
id uuid.UUID
id uuid.UUID
name string
presentation rune
style tcell.Style
engine.Positioned
}
func CreateNPC(pos engine.Position) *BasicNPC {
func CreateNPC(pos engine.Position, name string, presentation rune, style tcell.Style) *BasicNPC {
return &BasicNPC{
id: uuid.New(),
Positioned: engine.WithPosition(pos),
id: uuid.New(),
name: name,
presentation: presentation,
style: style,
Positioned: engine.WithPosition(pos),
}
}
func (c *BasicNPC) Name() string {
return c.name
}
func (c *BasicNPC) MoveTo(newPosition engine.Position) {
c.Positioned.SetPosition(newPosition)
}
@ -27,8 +43,6 @@ func (c *BasicNPC) UniqueId() uuid.UUID {
return c.id
}
func (c *BasicNPC) Input(e *tcell.EventKey) {
}
func (c *BasicNPC) Tick(dt int64) {
func (c *BasicNPC) Presentation() (rune, tcell.Style) {
return c.presentation, c.style
}

58
game/npc/rpg_npc.go Normal file
View file

@ -0,0 +1,58 @@
package npc
import (
"mvvasilev/last_light/engine"
"mvvasilev/last_light/game/item"
"mvvasilev/last_light/game/rpg"
"github.com/gdamore/tcell/v2"
)
type RPGNPC interface {
NPC
rpg.RPGEntity
EquippedEntity
}
type BasicRPGNPC struct {
inventory *item.EquippedInventory
*BasicNPC
*rpg.BasicRPGEntity
}
func CreateRPGNPC(x, y int, name string, representation rune, style tcell.Style, stats map[rpg.Stat]int) *BasicRPGNPC {
rpgnpc := &BasicRPGNPC{
inventory: item.CreateEquippedInventory(),
BasicNPC: CreateNPC(
engine.PositionAt(x, y),
name,
representation,
style,
),
BasicRPGEntity: rpg.CreateBasicRPGEntity(
0,
stats,
map[rpg.Stat][]rpg.StatModifier{},
),
}
rpgnpc.Heal(rpg.BaseMaxHealth(rpgnpc))
return rpgnpc
}
func (rnpc *BasicRPGNPC) Inventory() *item.EquippedInventory {
return rnpc.inventory
}
func (p *BasicRPGNPC) CalculateAttack(other rpg.RPGEntity) (hit bool, precisionRoll, evasionRoll int, damage int, damageType rpg.DamageType) {
mainHand := p.inventory.AtSlot(item.EquippedSlotDominantHand)
switch mh := mainHand.(type) {
case rpg.RPGItem:
return rpg.PhysicalWeaponAttack(p, mh, other)
default:
return rpg.UnarmedAttack(p, other)
}
}

View file

@ -25,10 +25,13 @@ func CreatePlayer(x, y int, playerStats map[rpg.Stat]int) *Player {
p.position = engine.PositionAt(x, y)
p.inventory = item.CreateEquippedInventory()
p.BasicRPGEntity = rpg.CreateBasicRPGEntity(
0,
playerStats,
map[rpg.Stat][]rpg.StatModifier{},
)
p.Heal(rpg.BaseMaxHealth(p))
return p
}
@ -48,21 +51,17 @@ 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() *item.EquippedInventory {
return p.inventory
}
func (p *Player) Input(e *tcell.EventKey) {
}
func (p *Player) Tick(dt int64) {
func (p *Player) CalculateAttack(other rpg.RPGEntity) (hit bool, precisionRoll, evasionRoll int, damage int, damageType rpg.DamageType) {
mainHand := p.inventory.AtSlot(item.EquippedSlotDominantHand)
switch mh := mainHand.(type) {
case rpg.RPGItem:
return rpg.PhysicalWeaponAttack(p, mh, other)
default:
return rpg.UnarmedAttack(p, other)
}
}

View file

@ -1,5 +1,7 @@
package rpg
import "slices"
type RPGEntity interface {
BaseStat(stat Stat) int
SetBaseStat(stat Stat, value int)
@ -11,6 +13,8 @@ type RPGEntity interface {
CurrentHealth() int
Heal(health int)
Damage(damage int)
CalculateAttack(other RPGEntity) (hit bool, precisionRoll, evasionRoll int, damage int, damageType DamageType)
}
type BasicRPGEntity struct {
@ -21,11 +25,11 @@ type BasicRPGEntity struct {
currentHealth int
}
func CreateBasicRPGEntity(baseStats map[Stat]int, statModifiers map[Stat][]StatModifier) *BasicRPGEntity {
func CreateBasicRPGEntity(health int, baseStats map[Stat]int, statModifiers map[Stat][]StatModifier) *BasicRPGEntity {
return &BasicRPGEntity{
stats: baseStats,
statModifiers: statModifiers,
currentHealth: 0,
currentHealth: health,
}
}
@ -60,7 +64,13 @@ func (brpg *BasicRPGEntity) AddStatModifier(modifier StatModifier) {
}
func (brpg *BasicRPGEntity) RemoveStatModifier(id StatModifierId) {
// TODO
for k, v := range brpg.statModifiers {
for i, sm := range v {
if sm.Id == id {
brpg.statModifiers[k] = slices.Delete(v, i, i+1)
}
}
}
}
func (brpg *BasicRPGEntity) CurrentHealth() int {
@ -88,3 +98,7 @@ func (brpg *BasicRPGEntity) Damage(damage int) {
brpg.currentHealth -= damage
}
func (brpg *BasicRPGEntity) CalculateAttack(other RPGEntity) (hit bool, precisionRoll, evasionRoll int, damage int, damageType DamageType) {
return UnarmedAttack(brpg, other)
}

View file

@ -24,12 +24,6 @@ type RPGItemType interface {
item.ItemType
}
type RPGItem interface {
Modifiers() []StatModifier
item.Item
}
type BasicRPGItemType struct {
damageRollFunc func(victim, attacker RPGEntity) (damage int, dmgType DamageType)
@ -258,8 +252,16 @@ func ItemTypeSpear() RPGItemType {
}
}
type RPGItem interface {
Modifiers() []StatModifier
RPGType() RPGItemType
item.Item
}
type BasicRPGItem struct {
modifiers []StatModifier
rpgType RPGItemType
item.BasicItem
}
@ -268,9 +270,14 @@ func (i *BasicRPGItem) Modifiers() []StatModifier {
return i.modifiers
}
func (i *BasicRPGItem) RPGType() RPGItemType {
return i.rpgType
}
func CreateRPGItem(name string, style tcell.Style, itemType RPGItemType, modifiers []StatModifier) RPGItem {
return &BasicRPGItem{
modifiers: modifiers,
rpgType: itemType,
BasicItem: item.CreateBasicItemWithName(
name,
style,

View file

@ -2,6 +2,7 @@ package rpg
import (
"math/rand"
"mvvasilev/last_light/engine"
)
type Stat int
@ -51,6 +52,36 @@ func StatLongName(stat Stat) string {
}
}
func RandomStats(pointsAvailable int, min, max int, stats []Stat) (result map[Stat]int) {
result = map[Stat]int{}
for {
if pointsAvailable == 0 {
break
}
for _, s := range stats {
if pointsAvailable == 0 {
break
}
limit := pointsAvailable
if limit > max {
limit = max
}
value := engine.RandInt(min+1, limit+min)
result[s] += value
pointsAvailable -= value - min
}
}
return
}
type StatModifierId string
type StatModifier struct {
@ -129,6 +160,33 @@ const (
DamageType_Magic_Poison DamageType = 9
)
func DamageTypeName(dmgType DamageType) string {
switch dmgType {
case DamageType_Physical_Unarmed:
return "Unarmed"
case DamageType_Physical_Slashing:
return "Slashing"
case DamageType_Physical_Piercing:
return "Piercing"
case DamageType_Physical_Bludgeoning:
return "Bludgeoning"
case DamageType_Magic_Fire:
return "Fire"
case DamageType_Magic_Cold:
return "Cold"
case DamageType_Magic_Necrotic:
return "Necrotic"
case DamageType_Magic_Thunder:
return "Thunder"
case DamageType_Magic_Acid:
return "Acid"
case DamageType_Magic_Poison:
return "Poison"
default:
return "Unknown"
}
}
func DamageTypeToBonusStat(dmgType DamageType) Stat {
switch dmgType {
case DamageType_Physical_Unarmed:
@ -142,7 +200,7 @@ func DamageTypeToBonusStat(dmgType DamageType) Stat {
case DamageType_Magic_Fire:
return Stat_DamageBonus_Magic_Fire
case DamageType_Magic_Cold:
return Stat_DamageBonus_Magic_Fire
return Stat_DamageBonus_Magic_Cold
case DamageType_Magic_Necrotic:
return Stat_DamageBonus_Magic_Necrotic
case DamageType_Magic_Thunder:
@ -201,8 +259,12 @@ func MagicHitRoll(attacker RPGEntity, victim RPGEntity) bool {
}
// true = hit lands, false = hit does not land
func PhysicalHitRoll(attacker RPGEntity, victim RPGEntity) bool {
return hitRoll(EvasionRoll(victim), PhysicalPrecisionRoll(attacker))
func PhysicalHitRoll(attacker RPGEntity, victim RPGEntity) (hit bool, evasion, precision int) {
evasion = EvasionRoll(victim)
precision = PhysicalPrecisionRoll(attacker)
hit = hitRoll(evasion, precision)
return
}
func hitRoll(evasionRoll, precisionRoll int) bool {
@ -212,3 +274,38 @@ func hitRoll(evasionRoll, precisionRoll int) bool {
func UnarmedDamage(attacker RPGEntity) int {
return RollD4(1) + StatValue(attacker, Stat_DamageBonus_Physical_Unarmed)
}
func PhysicalWeaponDamange(attacker RPGEntity, weapon RPGItem, victim RPGEntity) (totalDamage int, dmgType DamageType) {
totalDamage, dmgType = weapon.RPGType().RollDamage()(victim, attacker)
bonusDmgStat := DamageTypeToBonusStat(dmgType)
totalDamage = totalDamage + StatValue(attacker, bonusDmgStat)
return
}
func UnarmedAttack(attacker RPGEntity, victim RPGEntity) (hit bool, precisionRoll, evasionRoll int, damage int, damageType DamageType) {
hit, evasionRoll, precisionRoll = PhysicalHitRoll(attacker, victim)
if !hit {
return
}
damage = UnarmedDamage(attacker)
damageType = DamageType_Physical_Unarmed
return
}
func PhysicalWeaponAttack(attacker RPGEntity, weapon RPGItem, victim RPGEntity) (hit bool, precisionRoll, evasionRoll int, damage int, damageType DamageType) {
hit, evasionRoll, precisionRoll = PhysicalHitRoll(attacker, victim)
if !hit {
return
}
damage, damageType = PhysicalWeaponDamange(attacker, weapon, victim)
return
}

View file

@ -58,21 +58,16 @@ func CreateCharacterCreationState(turnSystem *turns.TurnSystem, inputSystem *inp
}
ccs.menuState.RandomizeCharacter = func() {
ccs.menuState.AvailablePoints = 21
stats := rpg.RandomStats(21, 1, 20, []rpg.Stat{rpg.Stat_Attributes_Strength, rpg.Stat_Attributes_Constitution, rpg.Stat_Attributes_Intelligence, rpg.Stat_Attributes_Dexterity})
for _, s := range ccs.menuState.Stats {
if ccs.menuState.AvailablePoints == 0 {
break
}
ccs.menuState.AvailablePoints = 0
ccs.menuState.Stats = []*menu.StatState{}
limit := ccs.menuState.AvailablePoints
if limit > 20 {
limit = 20
}
s.Value = engine.RandInt(1, limit+1)
ccs.menuState.AvailablePoints -= s.Value - 1
for k, v := range stats {
ccs.menuState.Stats = append(ccs.menuState.Stats, &menu.StatState{
Stat: k,
Value: v,
})
}
ccs.ccMenu.UpdateState(ccs.menuState)
@ -147,8 +142,16 @@ func (ccs *CharacterCreationState) OnTick(dt int64) GameState {
case input.InputAction_Menu_HighlightLeft:
ccs.DecreaseStatValue()
case input.InputAction_Menu_HighlightDown:
if ccs.menuState.CurrentHighlight > len(ccs.menuState.Stats) {
break
}
ccs.menuState.CurrentHighlight++
case input.InputAction_Menu_HighlightUp:
if ccs.menuState.CurrentHighlight == 0 {
break
}
ccs.menuState.CurrentHighlight--
case input.InputAction_Menu_Select:
ccs.ccMenu.SelectHighlight()

View file

@ -1,6 +1,7 @@
package state
import (
"fmt"
"mvvasilev/last_light/engine"
"mvvasilev/last_light/game/input"
"mvvasilev/last_light/game/npc"
@ -19,7 +20,7 @@ type PlayingState struct {
inputSystem *input.InputSystem
player *player.Player
someNPC *npc.BasicNPC
someNPC npc.RPGNPC
eventLog *engine.GameEventLog
uiEventLog *ui.UIEventLog
@ -52,7 +53,6 @@ func CreatePlayingState(turnSystem *turns.TurnSystem, inputSystem *input.InputSy
s.dungeon.CurrentLevel().PlayerSpawnPoint().Y(),
playerStats,
)
s.player.Heal(rpg.BaseMaxHealth(s.player))
s.turnSystem.Schedule(10, func() (complete bool, requeue bool) {
requeue = true
@ -89,7 +89,14 @@ func CreatePlayingState(turnSystem *turns.TurnSystem, inputSystem *input.InputSy
return
})
s.someNPC = npc.CreateNPC(s.dungeon.CurrentLevel().NextLevelStaircase())
s.someNPC = npc.CreateRPGNPC(
s.dungeon.CurrentLevel().NextLevelStaircase().X(),
s.dungeon.CurrentLevel().NextLevelStaircase().Y(),
"NPC",
'n',
tcell.StyleDefault,
rpg.RandomStats(21, 1, 20, []rpg.Stat{rpg.Stat_Attributes_Strength, rpg.Stat_Attributes_Constitution, rpg.Stat_Attributes_Intelligence, rpg.Stat_Attributes_Dexterity}),
)
s.turnSystem.Schedule(20, func() (complete bool, requeue bool) {
s.CalcPathToPlayerAndMove()
@ -102,8 +109,8 @@ func CreatePlayingState(turnSystem *turns.TurnSystem, inputSystem *input.InputSy
s.uiEventLog = ui.CreateUIEventLog(0, 17, 80, 7, s.eventLog, tcell.StyleDefault)
s.healthBar = ui.CreateHealthBar(68, 0, 12, 3, s.player.CurrentHealth(), rpg.BaseMaxHealth(s.player), tcell.StyleDefault)
s.dungeon.CurrentLevel().AddEntity(s.player, '@', tcell.StyleDefault)
s.dungeon.CurrentLevel().AddEntity(s.someNPC, 'N', tcell.StyleDefault)
s.dungeon.CurrentLevel().AddEntity(s.player)
s.dungeon.CurrentLevel().AddEntity(s.someNPC)
s.viewport = engine.CreateViewport(
engine.PositionAt(0, 0),
@ -132,9 +139,27 @@ func (ps *PlayingState) MovePlayer(direction npc.Direction) {
dx, dy := npc.MovementDirectionOffset(direction)
ps.dungeon.CurrentLevel().MoveEntity(ps.player.UniqueId(), dx, dy)
ps.viewport.SetCenter(ps.player.Position())
ps.eventLog.Log("You moved " + npc.DirectionName(direction))
}
ps.eventLog.Log("You moved " + npc.DirectionName(direction))
ent := ps.dungeon.CurrentLevel().EntityAt(newPlayerPos.XY())
// We are moving into an entity. Attack it.
if ent != nil {
switch rpge := ent.(type) {
case npc.RPGNPC:
hit, precision, evasion, dmg, dmgType := ps.player.CalculateAttack(rpge)
if !hit {
ps.eventLog.Log(fmt.Sprintf("You attacked %v, but missed ( %v Evasion vs %v Precision)", rpge.Name(), evasion, precision))
return
}
rpge.Damage(dmg)
ps.eventLog.Log(fmt.Sprintf("You attacked %v, and hit for %v %v damage", rpge.Name(), dmg, rpg.DamageTypeName(dmgType)))
}
}
}
func (ps *PlayingState) InteractBelowPlayer() {
@ -184,7 +209,7 @@ func (ps *PlayingState) SwitchToNextLevel() {
tcell.StyleDefault,
)
ps.dungeon.CurrentLevel().AddEntity(ps.player, '@', tcell.StyleDefault)
ps.dungeon.CurrentLevel().AddEntity(ps.player)
}
func (ps *PlayingState) SwitchToPreviousLevel() {
@ -220,7 +245,7 @@ func (ps *PlayingState) SwitchToPreviousLevel() {
tcell.StyleDefault,
)
ps.dungeon.CurrentLevel().AddEntity(ps.player, '@', tcell.StyleDefault)
ps.dungeon.CurrentLevel().AddEntity(ps.player)
}
func (ps *PlayingState) PickUpItemUnderPlayer() {
@ -255,6 +280,10 @@ func (ps *PlayingState) HasLineOfSight(start, end engine.Position) bool {
return true
}
func (ps *PlayingState) PlayerWithinHitRange(pos engine.Position) bool {
return pos.WithOffset(-1, 0) == ps.player.Position() || pos.WithOffset(+1, 0) == ps.player.Position() || pos.WithOffset(0, -1) == ps.player.Position() || pos.WithOffset(0, +1) == ps.player.Position()
}
func (ps *PlayingState) CalcPathToPlayerAndMove() {
playerVisibleAndInRange := false
@ -291,6 +320,21 @@ func (ps *PlayingState) CalcPathToPlayerAndMove() {
return
}
if ps.PlayerWithinHitRange(ps.someNPC.Position()) {
hit, precision, evasion, dmg, dmgType := ps.player.CalculateAttack(ps.player)
if !hit {
ps.eventLog.Log(fmt.Sprintf("%v attacked you, but missed ( %v Evasion vs %v Precision)", ps.someNPC.Name(), evasion, precision))
return
}
ps.player.Damage(dmg)
ps.healthBar.SetHealth(ps.player.CurrentHealth())
ps.eventLog.Log(fmt.Sprintf("%v attacked you, and hit for %v %v damage", ps.someNPC.Name(), dmg, rpg.DamageTypeName(dmgType)))
return
}
pathToPlayer := engine.FindPath(
ps.someNPC.Position(),
ps.player.Position(),

View file

@ -8,7 +8,6 @@ import (
"mvvasilev/last_light/game/rpg"
"slices"
"github.com/gdamore/tcell/v2"
"github.com/google/uuid"
)
@ -240,8 +239,8 @@ func (d *DungeonLevel) DropEntity(uuid uuid.UUID) {
d.entityLevel.DropEntity(uuid)
}
func (d *DungeonLevel) AddEntity(entity npc.MovableEntity, presentation rune, style tcell.Style) {
d.entityLevel.AddEntity(entity, presentation, style)
func (d *DungeonLevel) AddEntity(entity npc.MovableEntity) {
d.entityLevel.AddEntity(entity)
}
func (d *DungeonLevel) MoveEntity(uuid uuid.UUID, dx, dy int) {
@ -291,6 +290,10 @@ func (d *DungeonLevel) IsTilePassable(x, y int) bool {
return d.TileAt(x, y).Passable()
}
func (d *DungeonLevel) EntityAt(x, y int) (e npc.MovableEntity) {
return d.entityLevel.EntityAt(x, y)
}
func (d *DungeonLevel) IsGroundTileOpaque(x, y int) bool {
if !d.groundLevel.Size().Contains(x, y) {
return false

View file

@ -5,7 +5,6 @@ import (
"mvvasilev/last_light/engine"
"mvvasilev/last_light/game/npc"
"github.com/gdamore/tcell/v2"
"github.com/google/uuid"
)
@ -43,13 +42,13 @@ func (em *EntityMap) FindEntityByUuid(uuid uuid.UUID) (key int, entity EntityTil
return -1, nil
}
func (em *EntityMap) AddEntity(entity npc.MovableEntity, presentation rune, style tcell.Style) {
func (em *EntityMap) AddEntity(entity npc.MovableEntity) {
if !em.FitsWithin(entity.Position().XY()) {
return
}
key := em.Size().AsArrayIndex(entity.Position().XY())
et := CreateBasicEntityTile(entity, presentation, style)
et := CreateBasicEntityTile(entity)
em.entities[key] = et
}
@ -111,6 +110,16 @@ func (em *EntityMap) TileAt(x int, y int) Tile {
return em.entities[key]
}
func (em *EntityMap) EntityAt(x, y int) (ent npc.MovableEntity) {
tile := em.TileAt(x, y)
if tile == nil {
return nil
}
return tile.(EntityTile).Entity()
}
func (em *EntityMap) IsInBounds(x, y int) bool {
return em.FitsWithin(x, y)
}
@ -124,7 +133,4 @@ func (em *EntityMap) ExploredTileAt(x, y int) Tile {
}
func (em *EntityMap) Tick(dt int64) {
for _, e := range em.entities {
e.Entity().Tick(dt)
}
}

View file

@ -237,16 +237,11 @@ type EntityTile interface {
type BasicEntityTile struct {
entity npc.MovableEntity
presentation rune
style tcell.Style
}
func CreateBasicEntityTile(entity npc.MovableEntity, presentation rune, style tcell.Style) *BasicEntityTile {
func CreateBasicEntityTile(entity npc.MovableEntity) *BasicEntityTile {
return &BasicEntityTile{
entity: entity,
presentation: presentation,
style: style,
entity: entity,
}
}
@ -259,7 +254,7 @@ func (bet *BasicEntityTile) Position() engine.Position {
}
func (bet *BasicEntityTile) Presentation() (rune, tcell.Style) {
return bet.presentation, bet.style
return bet.entity.Presentation()
}
func (bet *BasicEntityTile) Passable() bool {