feat(tools): add firebolt_roam — standalone roam-and-cast for levelling
src/run/cold_plains.py needs a waypoint, the town manager and the bot state
machine. None of that is usable for a clvl 1 character standing in Blood Moor,
which has no waypoint at all.
This starts from wherever the character already is: scan with target_detect ->
cast the configured skill at the nearest target -> roam if nothing is visible.
No waypoints, no town, no state machine.
python tools/firebolt_roam.py --key f1 --minutes 10
The skill is a plain hotkey, so Fire Bolt now and Fireball at clvl 12 is the
same command. Stop key (default F12) is polled between every cast, and there is
a hard --minutes budget.
Explicitly NOT included: potion, chicken or death handling. Documented in the
module docstring — it will keep casting while dying, so it wants supervision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
111
tools/firebolt_roam.py
Normal file
111
tools/firebolt_roam.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Roam wherever the character is standing and cast one skill at whatever moves.
|
||||
|
||||
Built for levelling a fresh character in an open area (Blood Moor, Cold Plains).
|
||||
Unlike src/run/cold_plains.py this does NOT use the waypoint, the town manager
|
||||
or the bot state machine — it starts from wherever the character already is, so
|
||||
it needs no waypoints and works at clvl 1.
|
||||
|
||||
<botty-env-python> tools/firebolt_roam.py --key f1 --minutes 10
|
||||
|
||||
Put Fire Bolt on that key in game. At clvl 12 put Fireball on the same key and
|
||||
change nothing else.
|
||||
|
||||
Press the stop key (default F12) at any time to stop. Ctrl+C also works.
|
||||
|
||||
SAFETY: there is no potion, chicken or death handling here. It will happily keep
|
||||
casting while dying. Watch it rather than leaving it unattended.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(add_help=True)
|
||||
ap.add_argument("--key", default="f1", help="hotkey holding the attack skill (default: f1)")
|
||||
ap.add_argument("--button", default="right", choices=["left", "right"],
|
||||
help="mouse button the skill casts on (default: right)")
|
||||
ap.add_argument("--minutes", type=float, default=10.0, help="stop after this long (default: 10)")
|
||||
ap.add_argument("--radius", type=int, default=600, help="ignore targets further than this (px)")
|
||||
ap.add_argument("--casts", type=int, default=4, help="casts per engagement before rescanning")
|
||||
ap.add_argument("--cast-delay", type=float, default=0.25, help="seconds between casts")
|
||||
ap.add_argument("--stop-key", default="f12", help="press to stop (default: f12)")
|
||||
opts = ap.parse_args()
|
||||
|
||||
import screen
|
||||
from config import Config
|
||||
from input_layer import keyboard, mouse
|
||||
from screen import convert_abs_to_monitor
|
||||
from target_detect import get_visible_targets
|
||||
from utils.misc import wait
|
||||
|
||||
screen.find_and_set_window_position(force=True)
|
||||
if not screen.get_offset_state():
|
||||
print("ERROR: D2R window not found. Is the game running and visible?")
|
||||
return 2
|
||||
|
||||
force_move = Config().char["force_move"]
|
||||
deadline = time.time() + opts.minutes * 60
|
||||
kills_attempted = 0
|
||||
roams = 0
|
||||
|
||||
print(f"roaming: key={opts.key} button={opts.button} radius={opts.radius} "
|
||||
f"for {opts.minutes:g} min — press {opts.stop_key.upper()} to stop")
|
||||
|
||||
def stopping() -> bool:
|
||||
try:
|
||||
return keyboard.is_pressed(opts.stop_key)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
if stopping():
|
||||
print("\nstop key pressed")
|
||||
break
|
||||
|
||||
targets = get_visible_targets(radius_max=opts.radius)
|
||||
if targets:
|
||||
kills_attempted += 1
|
||||
pos = targets[0].center_monitor
|
||||
keyboard.send(opts.key)
|
||||
wait(0.06, 0.12)
|
||||
for _ in range(opts.casts):
|
||||
if stopping():
|
||||
break
|
||||
x = pos[0] + random.randint(-8, 8)
|
||||
y = pos[1] + random.randint(-8, 8)
|
||||
mouse.move(x, y, randomize=5, delay_factor=[0.2, 0.4])
|
||||
mouse.click(button=opts.button)
|
||||
wait(opts.cast_delay, opts.cast_delay * 1.4)
|
||||
else:
|
||||
roams += 1
|
||||
# y is halved because D2's projection is isometric — an equal
|
||||
# x/y step travels visibly further vertically than horizontally.
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
dist = random.randint(150, 260)
|
||||
pos_abs = (math.cos(angle) * dist, math.sin(angle) * dist * 0.5)
|
||||
mx, my = convert_abs_to_monitor(pos_abs)
|
||||
mouse.move(mx, my, randomize=5, delay_factor=[0.2, 0.4])
|
||||
keyboard.send(force_move)
|
||||
wait(0.9, 1.4)
|
||||
|
||||
left = max(0, deadline - time.time())
|
||||
print(f"\r engagements={kills_attempted} roams={roams} {left:5.0f}s left ",
|
||||
end="", flush=True)
|
||||
except KeyboardInterrupt:
|
||||
print("\ninterrupted")
|
||||
|
||||
print(f"\ndone: {kills_attempted} engagements, {roams} roam steps")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user