diff --git a/tools/firebolt_roam.py b/tools/firebolt_roam.py new file mode 100644 index 0000000..97aeb23 --- /dev/null +++ b/tools/firebolt_roam.py @@ -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. + + 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())