diff --git a/engine/engine_path.go b/engine/engine_path.go index d21acd1..4b4f45b 100644 --- a/engine/engine_path.go +++ b/engine/engine_path.go @@ -38,3 +38,20 @@ func (p *Path) Next() (current Position, hasNext bool) { return p.CurrentPosition(), true } + +func LinePath(from, to Position) *Path { + points := make([]Position, 0) + n := float64(from.Distance(to)) + + for step := 0.0; step <= n; step += 1.0 { + t := 0.0 + + if n != 0 { + t = step / n + } + + points = append(points, LerpPositions(from, to, t)) + } + + return CreatePath(from, to, points) +} diff --git a/engine/engine_pathfinding.go b/engine/engine_pathfinding.go index c629609..0103c75 100644 --- a/engine/engine_pathfinding.go +++ b/engine/engine_pathfinding.go @@ -35,7 +35,7 @@ func FindPath(from Position, to Position, maxDistance int, isPassable func(x, y iteration++ if iteration >= maxDistance { - return nil + break } if len(openList) == 0 { diff --git a/engine/engine_util.go b/engine/engine_util.go index 40f704c..03371a3 100644 --- a/engine/engine_util.go +++ b/engine/engine_util.go @@ -173,3 +173,14 @@ func AbsInt(val int) int { } return val } + +func Lerp(start, end float64, t float64) float64 { + return start*(1.0-t) + end*t +} + +func LerpPositions(p0, p1 Position, t float64) Position { + return PositionAt( + int(math.Round(Lerp(float64(p0.x), float64(p1.x), t))), + int(math.Round(Lerp(float64(p0.y), float64(p1.y), t))), + ) +} diff --git a/game/model/entity.go b/game/model/entity.go index 2ebda75..6f4ab73 100644 --- a/game/model/entity.go +++ b/game/model/entity.go @@ -87,6 +87,11 @@ type Entity_DropTableComponent struct { DropTable *LootTable } +type Entity_ProjectileComponent struct { + Source Entity + Path *engine.Path +} + type Entity interface { UniqueId() uuid.UUID @@ -99,6 +104,7 @@ type Entity interface { Stats() *Entity_StatsHolderComponent HealthData() *Entity_HealthComponent DropTable() *Entity_DropTableComponent + Projectile() *Entity_ProjectileComponent } type BaseEntity struct { @@ -113,6 +119,7 @@ type BaseEntity struct { stats *Entity_StatsHolderComponent damageable *Entity_HealthComponent dropTable *Entity_DropTableComponent + projectile *Entity_ProjectileComponent } func (be *BaseEntity) UniqueId() uuid.UUID { @@ -155,6 +162,10 @@ func (be *BaseEntity) DropTable() *Entity_DropTableComponent { return be.dropTable } +func (be *BaseEntity) Projectile() *Entity_ProjectileComponent { + return be.projectile +} + func CreateEntity(components ...func(*BaseEntity)) *BaseEntity { e := &BaseEntity{ id: uuid.New(), @@ -250,3 +261,12 @@ func WithDropTable(table map[int]ItemSupplier) func(e *BaseEntity) { } } } + +func WithProjectileData(source Entity, path *engine.Path) func(e *BaseEntity) { + return func(e *BaseEntity) { + e.projectile = &Entity_ProjectileComponent{ + Source: source, + Path: path, + } + } +} diff --git a/game/model/entity_behavior.go b/game/model/entity_behavior.go index ea14b91..e402013 100644 --- a/game/model/entity_behavior.go +++ b/game/model/entity_behavior.go @@ -5,11 +5,86 @@ import ( "mvvasilev/last_light/engine" ) -// func ProjectileBehavior() func(npc Entity) (complete bool, requeue bool) { -// return func(npc Entity) (complete bool, requeue bool) { +type ProjectileSprite rune -// } -// } +// +// \ | / +// +// ─ + ─ +// +// / | \ + +const ( + ProjectileSprite_NorthSouth ProjectileSprite = '|' + ProjectileSprite_EastWest = '─' + ProjectileSprite_NorthEastSouthWest = '/' + ProjectileSprite_NorthWestSouthEast = '\\' +) + +func ProjectileBehavior(eventLog *engine.GameEventLog, dungeon *Dungeon) func(npc Entity) (complete bool, requeue bool) { + return func(npc Entity) (complete bool, requeue bool) { + hasNext := ProjectileFollowPathNext(npc, eventLog, dungeon) + + return !hasNext, false + } +} + +func ProjectileFollowPathNext(npc Entity, eventLog *engine.GameEventLog, dungeon *Dungeon) (hasNext bool) { + projectileData := npc.Projectile() + positionData := npc.Positioned() + + if projectileData == nil || positionData == nil { + return false + } + + path := projectileData.Path + next, hasNext := path.Next() + + nextTile := dungeon.CurrentLevel().TileAt(next.XY()) + + nextTileEntityData := nextTile.Entity() + + dungeon.CurrentLevel().DropEntity(npc.UniqueId()) + + positionData.Position = next + + // The next tile is impassable ( wall, void, etc. ) and contains no entity to damage + // This is the end of the path + if nextTileEntityData == nil && !nextTile.Passable() { + return false + } + + // Otherwise, if the tile is passible, but also the end of the path, stop here and despawn the projectile + if nextTileEntityData == nil && next == projectileData.Path.To() { + return false + } + + // The next tile contains an entity, do damage to it if we have damage data + if nextTileEntityData != nil { + + // The arrow strikes against its master, but to no avail, for I decree it to be illegal + if nextTileEntityData.Entity == projectileData.Source { + return + } + + // Futher I decree, that should the arrow striketh at thyself, it shall be blocked from doing so + if nextTileEntityData.Entity == npc { + return + } + + if projectileData.Source == nil { + return false + } + + ExecuteAttack(eventLog, projectileData.Source, nextTileEntityData.Entity) + + return false + } + + dungeon.CurrentLevel().AddEntity(npc) + + return +} func HostileNPCBehavior(eventLog *engine.GameEventLog, dungeon *Dungeon, player *Player) func(npc Entity) (complete bool, requeue bool) { return func(npc Entity) (complete bool, requeue bool) { @@ -121,6 +196,10 @@ func WithinHitRange(pos engine.Position, otherPos engine.Position) bool { func ExecuteAttack(eventLog *engine.GameEventLog, attacker, victim Entity) { hit, precision, evasion, dmg, dmgType := CalculateAttack(attacker, victim) + if attacker.Projectile() != nil { + attacker = attacker.Projectile().Source + } + attackerName := "Unknown" if attacker.Named() != nil { @@ -138,6 +217,10 @@ func ExecuteAttack(eventLog *engine.GameEventLog, attacker, victim Entity) { return } + if victim.HealthData() == nil { + return + } + victim.HealthData().Health -= dmg if victim.HealthData().Health <= 0 { diff --git a/game/model/entity_npcs.go b/game/model/entity_npcs.go index 7590b13..528a5ac 100644 --- a/game/model/entity_npcs.go +++ b/game/model/entity_npcs.go @@ -12,10 +12,13 @@ const ( ImpClaws specialItemType = 100_000 + iota ) -func Entity_ArrowProjectile(startX, startY int, targetX, targetY int) Entity { +func Entity_ArrowProjectile(source Entity, path *engine.Path, eventLog *engine.GameEventLog, dungeon *Dungeon) Entity { return CreateEntity( WithName("Arrow"), - WithPosition(engine.PositionAt(startX, startY)), + WithPosition(path.From()), + WithPresentation('?', tcell.StyleDefault), + WithProjectileData(source, path), + WithBehavior(1, ProjectileBehavior(eventLog, dungeon)), ) } diff --git a/game/model/entity_player.go b/game/model/entity_player.go index 609a134..d1976e6 100644 --- a/game/model/entity_player.go +++ b/game/model/entity_player.go @@ -9,6 +9,7 @@ import ( type Player struct { Entity + inLookState bool skipNextTurn bool } @@ -64,3 +65,11 @@ func (p *Player) SkipNextTurn(skip bool) { func (p *Player) IsNextTurnSkipped() bool { return p.skipNextTurn } + +func (p *Player) IsInLookState() bool { + return p.inLookState +} + +func (p *Player) SetInLookState(lookState bool) { + p.inLookState = lookState +} diff --git a/game/model/world_dungeon.go b/game/model/world_dungeon.go index 76aa9d7..37d9611 100644 --- a/game/model/world_dungeon.go +++ b/game/model/world_dungeon.go @@ -90,7 +90,7 @@ func (d *Dungeon) HasNextLevel() bool { type DungeonLevel struct { ground Map - entitiesByPosition map[engine.Position][]Entity + entitiesByPosition map[engine.Position]Entity entities map[uuid.UUID]Entity } @@ -142,7 +142,7 @@ func CreateDungeonLevel(width, height int, dungeonType DungeonType) (dLevel *Dun dLevel = &DungeonLevel{ ground: groundLevel, entities: map[uuid.UUID]Entity{}, - entitiesByPosition: map[engine.Position][]Entity{}, + entitiesByPosition: map[engine.Position]Entity{}, } if groundLevel.Rooms() == nil { @@ -238,11 +238,7 @@ func (d *DungeonLevel) AddEntity(entity Entity) { d.entities[entity.UniqueId()] = entity if entity.Positioned() != nil { - if d.entitiesByPosition[entity.Positioned().Position] == nil { - d.entitiesByPosition[entity.Positioned().Position] = []Entity{entity} - } else { - d.entitiesByPosition[entity.Positioned().Position] = append(d.entitiesByPosition[entity.Positioned().Position], entity) - } + d.entitiesByPosition[entity.Positioned().Position] = entity } } @@ -258,9 +254,7 @@ func (d *DungeonLevel) MoveEntityTo(uuid uuid.UUID, x, y int) { ent.Positioned().Position = engine.PositionAt(x, y) if d.entitiesByPosition[ent.Positioned().Position] == nil { - d.entitiesByPosition[ent.Positioned().Position] = []Entity{ent} - } else { - d.entitiesByPosition[ent.Positioned().Position] = append(d.entitiesByPosition[ent.Positioned().Position], ent) + d.entitiesByPosition[ent.Positioned().Position] = ent } } @@ -303,7 +297,7 @@ func (d *DungeonLevel) TileAt(x, y int) Tile { tile := Map_TileAt(d.ground, x, y) if entity != nil { - return CreateTileFromPrototype(tile, Tile_WithEntities(entity)) + return CreateTileFromPrototype(tile, Tile_WithEntity(entity)) } return tile @@ -316,14 +310,14 @@ func (d *DungeonLevel) IsTilePassable(x, y int) bool { tile := d.TileAt(x, y) - if tile.Entities() != nil { + if tile.Entity() != nil { return false } return tile.Passable() } -func (d *DungeonLevel) EntitiesAt(x, y int) (e []Entity) { +func (d *DungeonLevel) EntityAt(x, y int) (e Entity) { return d.entitiesByPosition[engine.PositionAt(x, y)] } diff --git a/game/model/world_tile.go b/game/model/world_tile.go index ed45716..7cff5ed 100644 --- a/game/model/world_tile.go +++ b/game/model/world_tile.go @@ -1,10 +1,7 @@ package model import ( - "slices" - "github.com/gdamore/tcell/v2" - "github.com/google/uuid" ) type Material uint @@ -26,7 +23,7 @@ type Tile_ItemComponent struct { } type Tile_EntityComponent struct { - Entities []Entity + Entity Entity } type Tile interface { @@ -40,9 +37,9 @@ type Tile interface { RemoveItem() WithItem(item Item) - Entities() *Tile_EntityComponent - RemoveEntity(uuid uuid.UUID) - AddEntity(entity Entity) + Entity() *Tile_EntityComponent + RemoveEntity() + WithEntity(entity Entity) } type BaseTile struct { @@ -52,8 +49,8 @@ type BaseTile struct { material Material passable, opaque, transparent bool - item *Tile_ItemComponent - entities *Tile_EntityComponent + item *Tile_ItemComponent + entity *Tile_EntityComponent } func CreateTileFromPrototype(prototype Tile, components ...func(*BaseTile)) Tile { @@ -121,46 +118,24 @@ func (t *BaseTile) WithItem(item Item) { } } -func (t *BaseTile) Entities() *Tile_EntityComponent { - return t.entities +func (t *BaseTile) Entity() *Tile_EntityComponent { + return t.entity } -func (t *BaseTile) RemoveEntity(uuid uuid.UUID) { - if t.entities == nil { - return +func (t *BaseTile) WithEntity(entity Entity) { + t.entity = &Tile_EntityComponent{ + Entity: entity, } - - t.entities.Entities = slices.DeleteFunc(t.entities.Entities, func(e Entity) bool { return e.UniqueId() == uuid }) } -func (t *BaseTile) AddEntity(entity Entity) { - if t.entities == nil { - t.entities = &Tile_EntityComponent{ - Entities: []Entity{ - entity, - }, - } - - return - } - - t.entities.Entities = append(t.entities.Entities, entity) +func (t *BaseTile) RemoveEntity() { + t.entity = nil } func Tile_WithEntity(entity Entity) func(*BaseTile) { return func(bt *BaseTile) { - bt.entities = &Tile_EntityComponent{ - Entities: []Entity{ - entity, - }, - } - } -} - -func Tile_WithEntities(entities []Entity) func(*BaseTile) { - return func(bt *BaseTile) { - bt.entities = &Tile_EntityComponent{ - Entities: entities, + bt.entity = &Tile_EntityComponent{ + Entity: entity, } } } diff --git a/game/state/look_state.go b/game/state/look_state.go index 7dbe9cf..6e30b9b 100644 --- a/game/state/look_state.go +++ b/game/state/look_state.go @@ -5,6 +5,7 @@ import ( "mvvasilev/last_light/engine" "mvvasilev/last_light/game/model" "mvvasilev/last_light/game/systems" + "mvvasilev/last_light/game/ui" "time" "github.com/gdamore/tcell/v2" @@ -23,9 +24,14 @@ type LookState struct { player *model.Player dungeon *model.Dungeon + showLog bool + uiEventLog *ui.UIEventLog + showCursor bool cursorPos engine.Position lastCursorBlinkTime time.Time + + nextGameState GameState } func CreateLookState(prevState GameState, eventLog *engine.GameEventLog, dungeon *model.Dungeon, inputSystem *systems.InputSystem, turnSystem *systems.TurnSystem, player *model.Player) *LookState { @@ -38,6 +44,8 @@ func CreateLookState(prevState GameState, eventLog *engine.GameEventLog, dungeon eventLog: eventLog, cursorPos: engine.PositionAt(0, 0), lastCursorBlinkTime: time.Now(), + showLog: true, + uiEventLog: ui.CreateUIEventLog(0, 17, 80, 7, eventLog, tcell.StyleDefault), } } @@ -46,6 +54,8 @@ func (ls *LookState) InputContext() systems.InputContext { } func (ls *LookState) OnTick(dt int64) GameState { + ls.nextGameState = ls + switch ls.inputSystem.NextAction() { case systems.InputAction_Move_North: ls.cursorPos = ls.cursorPos.WithOffset(model.MovementDirectionOffset(model.North)) @@ -55,15 +65,17 @@ func (ls *LookState) OnTick(dt int64) GameState { ls.cursorPos = ls.cursorPos.WithOffset(model.MovementDirectionOffset(model.East)) case systems.InputAction_Move_West: ls.cursorPos = ls.cursorPos.WithOffset(model.MovementDirectionOffset(model.West)) + case systems.InputAction_OpenLogs: + ls.showLog = !ls.showLog case systems.InputAction_Describe: ls.Describe() case systems.InputAction_Shoot: ls.ShootEquippedWeapon() case systems.InputAction_Menu_Exit: - return ls.prevState + ls.nextGameState = ls.prevState } - return ls + return ls.nextGameState } func (ls *LookState) ShootEquippedWeapon() { @@ -90,15 +102,49 @@ func (ls *LookState) ShootEquippedWeapon() { } // TODO: Projectiles + dX, dY := ls.lookCursorCoordsToDungeonCoords() + + distance := engine.PositionAt(dX, dY).Distance(ls.player.Position()) + + if distance >= 12 { + ls.eventLog.Log("Can't see in the dark that far") + + return + } + + path := engine.LinePath( + ls.player.Position(), + engine.PositionAt(dX, dY), + ) + + if path == nil { + ls.eventLog.Log("Can't shoot there, something is in the way") + return + } + + projectile := model.Entity_ArrowProjectile(ls.player, path, ls.eventLog, ls.dungeon) + + ls.turnSystem.Schedule( + projectile.Behavior().Speed, + projectile.Behavior().Behavior, + ) ls.player.SkipNextTurn(true) - ls.turnSystem.NextTurn() + ls.nextGameState = ls.prevState } func (ls *LookState) Describe() { dX, dY := ls.lookCursorCoordsToDungeonCoords() + distance := engine.PositionAt(dX, dY).Distance(ls.player.Position()) + + if distance >= 12 { + ls.eventLog.Log("Can't see in the dark that far") + + return + } + isVisibleFromPlayer, lastTile := model.HasLineOfSight(ls.dungeon, ls.player.Position(), engine.PositionAt(dX, dY)) if !isVisibleFromPlayer { @@ -110,10 +156,10 @@ func (ls *LookState) Describe() { tile := ls.dungeon.CurrentLevel().TileAt(dX, dY) - entities := tile.Entities() + entity := tile.Entity() - if entities != nil { - ls.DescribeEntities(entities.Entities) + if entity != nil { + ls.DescribeEntity(entity.Entity) return } @@ -131,27 +177,25 @@ func (ls *LookState) Describe() { ls.eventLog.Log(fmt.Sprintf("%s: %s", materialName, materialDesc)) } -func (ls *LookState) DescribeEntities(entities []model.Entity) { - if entities == nil { +func (ls *LookState) DescribeEntity(entity model.Entity) { + if entity == nil { return } - for _, entity := range entities { - if entity == ls.player { - ls.eventLog.Log("You") + if entity == ls.player { + ls.eventLog.Log("You") - continue - } + return + } - if entity.Named() == nil { - continue - } + if entity.Named() == nil { + return + } - if entity.Described() != nil { - ls.eventLog.Log(fmt.Sprintf("%s: %s", entity.Named().Name, entity.Described().Description)) - } else { - ls.eventLog.Log(entity.Named().Name) - } + if entity.Described() != nil { + ls.eventLog.Log(fmt.Sprintf("%s: %s", entity.Named().Name, entity.Described().Description)) + } else { + ls.eventLog.Log(entity.Named().Name) } } @@ -209,5 +253,9 @@ func (ls *LookState) CollectDrawables() []engine.Drawable { } })) + if ls.showLog { + drawables = append(drawables, ls.uiEventLog) + } + return drawables } diff --git a/game/state/playing_state.go b/game/state/playing_state.go index 2b501c5..e2534b7 100644 --- a/game/state/playing_state.go +++ b/game/state/playing_state.go @@ -64,7 +64,7 @@ func CreatePlayingState(turnSystem *systems.TurnSystem, inputSystem *systems.Inp case systems.InputAction_OpenInventory: s.nextGameState = CreateInventoryScreenState(s.eventLog, s.dungeon, s.inputSystem, s.turnSystem, s.player, s) case systems.InputAction_EnterLookMode: - s.viewShortLogs = !s.viewShortLogs + s.viewShortLogs = false s.nextGameState = CreateLookState(s, s.eventLog, s.dungeon, s.inputSystem, s.turnSystem, s.player) case systems.InputAction_PickUpItem: complete = PickUpItemUnderPlayer(s.eventLog, s.dungeon, s.player) @@ -162,7 +162,7 @@ func (ps *PlayingState) MovePlayer(direction model.Direction) (success bool) { newPlayerPos := ps.player.Position().WithOffset(model.MovementDirectionOffset(direction)) - ent := ps.dungeon.CurrentLevel().EntitiesAt(newPlayerPos.XY())[0] + ent := ps.dungeon.CurrentLevel().EntityAt(newPlayerPos.XY()) // We are moving into an entity with health data. Attack it. if ent != nil && ent.HealthData() != nil { @@ -334,8 +334,8 @@ func (ps *PlayingState) CollectDrawables() []engine.Drawable { if tile != nil { - if tile.Entities() != nil { - return tile.Entities().Entities[0].Presentable().Rune, tile.Entities().Entities[0].Presentable().Style + if tile.Entity() != nil { + return tile.Entity().Entity.Presentable().Rune, tile.Entity().Entity.Presentable().Style } if tile.Item() != nil { diff --git a/game/systems/input_system.go b/game/systems/input_system.go index 255bf5f..fc8859a 100644 --- a/game/systems/input_system.go +++ b/game/systems/input_system.go @@ -93,6 +93,7 @@ func CreateInputSystemWithDefaultBindings() *InputSystem { InputKeyOf(InputContext_Look, 0, tcell.KeyRight, 0): InputAction_Move_East, InputKeyOf(InputContext_Look, 0, tcell.KeyRune, 'd'): InputAction_Describe, InputKeyOf(InputContext_Look, 0, tcell.KeyRune, 'a'): InputAction_Shoot, + InputKeyOf(InputContext_Look, 0, tcell.KeyRune, 'l'): InputAction_OpenLogs, InputKeyOf(InputContext_Look, 0, tcell.KeyESC, 0): InputAction_Menu_Exit, }, }