Skip to content

Commit

Permalink
Fix Teleport in circle and Line of sight improvement (#634)
Browse files Browse the repository at this point in the history
  • Loading branch information
elobo91 authored Jan 25, 2025
1 parent 28b2675 commit b3d095c
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 66 deletions.
28 changes: 1 addition & 27 deletions internal/action/item_pickup.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"slices"
"time"

Expand Down Expand Up @@ -138,7 +137,7 @@ func ItemPickup(maxDistance int) error {
slog.String("item", itemToPickup.Desc().Name))

// Try moving beyond the item for better line of sight
beyondPos := moveBeyondItem(itemToPickup.Position, 2+attempt)
beyondPos := ctx.PathFinder.BeyondPosition(ctx.Data.PlayerUnit.Position, itemToPickup.Position, 2+attempt)
if mvErr := MoveToCoords(beyondPos); mvErr == nil {
err = step.PickupItem(itemToPickup)
if err == nil {
Expand Down Expand Up @@ -293,28 +292,3 @@ func shouldBePickedUp(i data.Item) bool {
}
return !doesExceedQuantity(matchedRule)
}

// TODO refactor this since its similar to the one in attack.go(ensureenemyisinrange) and put in move package.
func moveBeyondItem(itemPos data.Position, distance int) data.Position {
ctx := context.Get()
playerPos := ctx.Data.PlayerUnit.Position

// Calculate direction vector
dx := float64(itemPos.X - playerPos.X)
dy := float64(itemPos.Y - playerPos.Y)

// Normalize
length := math.Sqrt(dx*dx + dy*dy)
if length == 0 {
return itemPos
}

dx = dx / length
dy = dy / length

// Extend beyond item position
return data.Position{
X: itemPos.X + int(dx*float64(distance)),
Y: itemPos.Y + int(dy*float64(distance)),
}
}
126 changes: 89 additions & 37 deletions internal/action/step/attack.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package step
import (
"errors"
"fmt"
"math"
"time"

"github.com/hectorgimenez/d2go/pkg/data"
Expand Down Expand Up @@ -35,6 +34,15 @@ type attackSettings struct {
// AttackOption defines a function type for configuring attack settings
type AttackOption func(step *attackSettings)

type attackState struct {
lastHealth int
lastHealthCheckTime time.Time
failedAttemptStartTime time.Time
position data.Position
}

var monsterAttackStates = make(map[data.UnitID]*attackState)

// Distance configures attack to follow enemy within specified range
func Distance(minimum, maximum int) AttackOption {
return func(step *attackSettings) {
Expand Down Expand Up @@ -148,8 +156,8 @@ func attack(settings attackSettings) error {
defer keyCleanup(ctx) // cleanup possible pressed keys/buttons

numOfAttacksRemaining := settings.numOfAttacks

lastRunAt := time.Time{}

for {
ctx.PauseIfNotPriority()

Expand All @@ -167,8 +175,13 @@ func attack(settings attackSettings) error {
return nil // Enemy is out of range and followEnemy is disabled, we cannot attack
}

// Check if we need to reposition if we aren't doing any damage (prevent attacking through doors etc.)
_, state := checkMonsterDamage(monster)
needsRepositioning := !state.failedAttemptStartTime.IsZero() &&
time.Since(state.failedAttemptStartTime) > 3*time.Second

// Be sure we stay in range of the enemy
err := ensureEnemyIsInRange(monster, settings.maxDistance, settings.minDistance)
err := ensureEnemyIsInRange(monster, settings.maxDistance, settings.minDistance, needsRepositioning)
if err != nil {
return fmt.Errorf("enemy is out of range and cannot be reached: %w", err)
}
Expand Down Expand Up @@ -201,12 +214,12 @@ func burstAttack(settings attackSettings) error {
}

// Initially we try to move to the enemy, later we will check for closer enemies to keep attacking
err := ensureEnemyIsInRange(monster, settings.maxDistance, settings.minDistance)
err := ensureEnemyIsInRange(monster, settings.maxDistance, settings.minDistance, false)
if err != nil {
return fmt.Errorf("enemy is out of range and cannot be reached: %w", err)
}

startedAt := time.Time{}
startedAt := time.Now()
for {
ctx.PauseIfNotPriority()

Expand All @@ -227,19 +240,30 @@ func burstAttack(settings attackSettings) error {
return nil // We have no valid targets in range, finish attack sequence
}

// Check if we need to reposition if we aren't doing any damage (prevent attacking through doors etc.
_, state := checkMonsterDamage(target)
needsRepositioning := !state.failedAttemptStartTime.IsZero() &&
time.Since(state.failedAttemptStartTime) > 3*time.Second

// If we don't have LoS we will need to interrupt and move :(
if !ctx.PathFinder.LineOfSight(ctx.Data.PlayerUnit.Position, target.Position) {
err = ensureEnemyIsInRange(target, settings.maxDistance, settings.minDistance)
if !ctx.PathFinder.LineOfSight(ctx.Data.PlayerUnit.Position, target.Position) || needsRepositioning {
err = ensureEnemyIsInRange(target, settings.maxDistance, settings.minDistance, needsRepositioning)
if err != nil {
return fmt.Errorf("enemy is out of range and cannot be reached: %w", err)
}
continue
}

performAttack(ctx, settings, target.Position.X, target.Position.Y)
}
}

func performAttack(ctx *context.Status, settings attackSettings, x, y int) {
monsterPos := data.Position{X: x, Y: y}
if !ctx.PathFinder.LineOfSight(ctx.Data.PlayerUnit.Position, monsterPos) {
return // Skip attack if no line of sight
}

// Ensure we have the skill selected
if settings.skill != 0 && ctx.Data.PlayerUnit.RightSkill != settings.skill {
ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.MustKBForSkill(settings.skill))
Expand All @@ -262,19 +286,34 @@ func performAttack(ctx *context.Status, settings attackSettings, x, y int) {
}
}

func ensureEnemyIsInRange(monster data.Monster, maxDistance, minDistance int) error {
func ensureEnemyIsInRange(monster data.Monster, maxDistance, minDistance int, needsRepositioning bool) error {
ctx := context.Get()
ctx.SetLastStep("ensureEnemyIsInRange")

// TODO: Add an option for telestomp based on the char configuration
// TODO: Add an option for telestomp based on the char configuration and kite
currentPos := ctx.Data.PlayerUnit.Position
distanceToMonster := ctx.PathFinder.DistanceFromMe(monster.Position)
hasLoS := ctx.PathFinder.LineOfSight(currentPos, monster.Position)

// We have line of sight, and we are inside the attack range, we can skip
if hasLoS && distanceToMonster <= maxDistance && distanceToMonster >= minDistance {
if hasLoS && distanceToMonster <= maxDistance && distanceToMonster >= minDistance && !needsRepositioning {
return nil
}
// Handle repositioning if needed
if needsRepositioning {
ctx.Logger.Info(fmt.Sprintf(
"No damage taken by target monster [%d] in area [%s] for more than 3 seconds. Trying to re-position",
monster.Name, // No mapped string value for npc names in d2go, only id
ctx.Data.PlayerUnit.Area.Area().Name,
))
dest := ctx.PathFinder.BeyondPosition(currentPos, monster.Position, 4)
return MoveTo(dest)
}

// Any close-range combat (mosaic,barb...) should move directly to target
if maxDistance <= 3 {
return MoveTo(monster.Position)
}

// Get path to monster
path, _, found := ctx.PathFinder.GetPath(monster.Position)
Expand All @@ -283,11 +322,6 @@ func ensureEnemyIsInRange(monster data.Monster, maxDistance, minDistance int) er
return errors.New("path could not be calculated")
}

// Any close-range combat (mosaic,barb...) should move directly to target
if maxDistance <= 3 {
return MoveTo(monster.Position)
}

// Look for suitable position along path
for _, pos := range path {
monsterDistance := utils.DistanceFromPoint(ctx.Data.AreaData.RelativePosition(monster.Position), pos)
Expand All @@ -300,29 +334,10 @@ func ensureEnemyIsInRange(monster data.Monster, maxDistance, minDistance int) er
Y: pos.Y + ctx.Data.AreaData.OffsetY,
}

// Calculate how far we need to move to reach this position
// Handle overshooting for short distances (Nova distances)
distanceToMove := ctx.PathFinder.DistanceFromMe(dest)

// If we need to move less than 7 units, we need to overshoot
if distanceToMove <= 7 {
// Calculate vector from current pos to destination
dx := float64(dest.X - currentPos.X)
dy := float64(dest.Y - currentPos.Y)

// Normalize and extend to 9 units (beyond the 7 unit minimum)
length := math.Sqrt(dx*dx + dy*dy)
if length == 0 {
dx = 1
length = 1
}
dx = dx / length * 9
dy = dy / length * 9

// Create new overshooting destination
dest = data.Position{
X: currentPos.X + int(dx),
Y: currentPos.Y + int(dy),
}
if distanceToMove <= DistanceToFinishMoving {
dest = ctx.PathFinder.BeyondPosition(currentPos, dest, 9)
}

if ctx.PathFinder.LineOfSight(dest, monster.Position) {
Expand All @@ -332,3 +347,40 @@ func ensureEnemyIsInRange(monster data.Monster, maxDistance, minDistance int) er

return nil
}

func checkMonsterDamage(monster data.Monster) (bool, *attackState) {
state, exists := monsterAttackStates[monster.UnitID]
if !exists {
state = &attackState{
lastHealth: monster.Stats[stat.Life],
lastHealthCheckTime: time.Now(),
position: monster.Position,
}
monsterAttackStates[monster.UnitID] = state
}

didDamage := false
currentHealth := monster.Stats[stat.Life]

// Only update health check if some time has passed
if time.Since(state.lastHealthCheckTime) > 100*time.Millisecond {
if currentHealth < state.lastHealth {
didDamage = true
state.failedAttemptStartTime = time.Time{}
} else if state.failedAttemptStartTime.IsZero() &&
monster.Position == state.position { // only start failing if monster hasn't moved
state.failedAttemptStartTime = time.Now()
}

state.lastHealth = currentHealth
state.lastHealthCheckTime = time.Now()
state.position = monster.Position
}

// Clean up state map occasionally
if len(monsterAttackStates) > 100 {
monsterAttackStates = make(map[data.UnitID]*attackState)
}

return didDamage, state
}
3 changes: 2 additions & 1 deletion internal/action/step/move.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ func MoveTo(dest data.Position, options ...MoveOption) error {
previousDistance := 0

for {

// Pause the execution if the priority is not the same as the execution priority
ctx.PauseIfNotPriority()
// is needed to prevent bot teleporting in circle when it reached destination (lower end cpu) cost is minimal.
ctx.RefreshGameData()

// Check for idle state outside town
if ctx.Data.PlayerUnit.Mode == mode.StandingOutsideTown {
Expand Down
57 changes: 56 additions & 1 deletion internal/pather/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,29 @@ func DistanceFromPoint(from data.Position, to data.Position) int {
}

func (pf *PathFinder) LineOfSight(origin data.Position, destination data.Position) bool {
// Pre-calculate door collision boxes
var doorBoxes []struct {
minX, maxX, minY, maxY int
}
for _, obj := range pf.data.Objects {
if obj.IsDoor() && obj.Selectable {
desc := obj.Desc()
halfSizeX := desc.SizeX / 2
halfSizeY := desc.SizeY / 2
doorX := obj.Position.X + desc.Xoffset
doorY := obj.Position.Y + desc.Yoffset

doorBoxes = append(doorBoxes, struct {
minX, maxX, minY, maxY int
}{
minX: doorX - halfSizeX,
maxX: doorX + halfSizeX,
minY: doorY - halfSizeY,
maxY: doorY + halfSizeY,
})
}
}

dx := int(math.Abs(float64(destination.X - origin.X)))
dy := int(math.Abs(float64(destination.Y - origin.Y)))
sx, sy := 1, 1
Expand All @@ -157,7 +180,6 @@ func (pf *PathFinder) LineOfSight(origin data.Position, destination data.Positio
}

err := dx - dy

x, y := origin.X, origin.Y

for {
Expand All @@ -167,6 +189,15 @@ func (pf *PathFinder) LineOfSight(origin data.Position, destination data.Positio
if x == destination.X && y == destination.Y {
break
}

// Check pre-calculated door boxes
for _, box := range doorBoxes {
if x >= box.minX && x <= box.maxX &&
y >= box.minY && y <= box.maxY {
return false
}
}

e2 := 2 * err
if e2 > -dy {
err -= dy
Expand All @@ -180,3 +211,27 @@ func (pf *PathFinder) LineOfSight(origin data.Position, destination data.Positio

return true
}

// BeyondPosition calculates a new position that is a specified distance beyond the target position when viewed from the start position
func (pf *PathFinder) BeyondPosition(start, target data.Position, distance int) data.Position {
// Calculate direction vector
dx := float64(target.X - start.X)
dy := float64(target.Y - start.Y)

// Normalize
length := math.Sqrt(dx*dx + dy*dy)
if length == 0 {
// If positions are identical, pick arbitrary direction
dx = 1
dy = 0
} else {
dx = dx / length
dy = dy / length
}

// Return position extended beyond target
return data.Position{
X: target.X + int(dx*float64(distance)),
Y: target.Y + int(dy*float64(distance)),
}
}

0 comments on commit b3d095c

Please sign in to comment.