Roblox macros that play from outside the game: Anime Vanguards, and a falling-notes piano event.
201 lines
7.5 KiB
Python
201 lines
7.5 KiB
Python
"""Anime Vanguards - run a stage, place your units, take the reward, again."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
from .. import config, routine, screen, winput
|
|
from ..runner import Runner
|
|
|
|
NAME = "animevanguards"
|
|
|
|
DEFAULTS = {
|
|
"hotkeys": {"pause": "f7", "stop": "f8", "capture": "f6"},
|
|
"routines": {
|
|
# Recorded with `record`.
|
|
"build": "build",
|
|
"loop": None,
|
|
"loop_every": 6.0,
|
|
"speed": 1.0,
|
|
},
|
|
"match": {
|
|
"start_delay": 4.0, # after pressing start, before the build order
|
|
"max_seconds": 900.0, # give up on a match that never ends
|
|
"check_every": 0.75, # how often to look at the results probe
|
|
"load_seconds": 12.0, # after replay, before the next match begins
|
|
"between": 1.0,
|
|
},
|
|
"points": {
|
|
# Each is {"at": [x, y], "color": [r, g, b]} or null.
|
|
"close": None, # X on a popup, clicked before each match
|
|
"start": None, # the button that starts / replays a match
|
|
"end_probe": None, # a pixel that is only this colour on results
|
|
"replay": None, # results-screen replay button
|
|
"confirm": None, # a second click, if replay asks to confirm
|
|
},
|
|
"tolerance": 26, # per-channel colour slack on the probe
|
|
"antiafk": {"enabled": True, "period": 240.0, "key": "space"},
|
|
}
|
|
|
|
POINTS = [
|
|
("close", "Popup close button (the X). Skip if nothing pops up."),
|
|
("start", "The button that STARTS a match from the lobby or results screen."),
|
|
("end_probe", "A pixel that is only this colour when the RESULTS screen is up "
|
|
"— a coloured header or button on the panel, not grey background."),
|
|
("replay", "The REPLAY / PLAY AGAIN button on the results screen."),
|
|
("confirm", "A confirmation click after replay, if there is one. Skip if not."),
|
|
]
|
|
|
|
|
|
# ------------------------------------------------------------- calibrate
|
|
def calibrate(cfg: dict) -> dict:
|
|
keys = cfg["hotkeys"]
|
|
print("\ncalibrating Anime Vanguards")
|
|
print("Put Roblox where you can see each thing before you capture it —")
|
|
print("the results-screen points are easiest to capture at the end of a run.")
|
|
for key, prompt in POINTS:
|
|
current = cfg["points"].get(key)
|
|
if current:
|
|
print(f"\n{key}: currently {current['at']} colour {current['color']}")
|
|
captured = routine.capture_point(f"{key} — {prompt}", keys)
|
|
if captured:
|
|
cfg["points"][key] = captured
|
|
return cfg
|
|
|
|
|
|
# ------------------------------------------------------------------ bits
|
|
def _click_point(point: dict | None, runner: Runner, label: str) -> bool:
|
|
if not point or not point.get("at"):
|
|
return False
|
|
x, y = point["at"]
|
|
runner.log(f"click {label} at {x},{y}")
|
|
winput.click(x, y)
|
|
return True
|
|
|
|
|
|
def _probe_matches(point: dict | None, tolerance: int) -> bool:
|
|
if not point or not point.get("at") or not point.get("color"):
|
|
return False
|
|
x, y = point["at"]
|
|
return screen.close_enough(screen.pixel(x, y), point["color"], tolerance)
|
|
|
|
|
|
class AnimeVanguards:
|
|
def __init__(self, cfg: dict, runner: Runner):
|
|
self.cfg = cfg
|
|
self.runner = runner
|
|
self.matches = 0
|
|
self.started = time.monotonic()
|
|
|
|
names = cfg["routines"]
|
|
self.build = self._routine(names.get("build"), runner)
|
|
self.loop = self._routine(names.get("loop"), runner)
|
|
self.speed = float(names.get("speed", 1.0) or 1.0)
|
|
self._afk_at = time.monotonic()
|
|
runner.stats.update(matches=0, started=self.started)
|
|
|
|
@staticmethod
|
|
def _routine(name, runner: Runner) -> list[dict]:
|
|
if not name:
|
|
return []
|
|
try:
|
|
return config.load_routine(name)
|
|
except FileNotFoundError:
|
|
runner.log(f"no routine called {name!r} — carrying on without it "
|
|
f"(record one with: python macro.py record {name})")
|
|
return []
|
|
|
|
# ------------------------------------------------------------ pieces
|
|
def antiafk(self) -> None:
|
|
settings = self.cfg["antiafk"]
|
|
if not settings.get("enabled"):
|
|
return
|
|
if time.monotonic() - self._afk_at < float(settings.get("period", 240)):
|
|
return
|
|
self._afk_at = time.monotonic()
|
|
# A jump, not a mouse jiggle: Roblox's idle timer watches for input the game itself.
|
|
winput.tap(settings.get("key", "space"))
|
|
|
|
def wait_for_end(self) -> str:
|
|
"""Wait out the match, upgrading if there is a loop routine."""
|
|
match = self.cfg["match"]
|
|
probe = self.cfg["points"].get("end_probe")
|
|
tolerance = int(self.cfg.get("tolerance", 26))
|
|
deadline = time.monotonic() + float(match.get("max_seconds", 900))
|
|
next_loop = time.monotonic()
|
|
|
|
if not probe:
|
|
self.runner.log("no end_probe calibrated — waiting on the timer instead")
|
|
|
|
while time.monotonic() < deadline:
|
|
self.runner.wait(float(match.get("check_every", 0.75)))
|
|
self.antiafk()
|
|
|
|
if _probe_matches(probe, tolerance):
|
|
# Twice, a beat apart: a matching pixel can be a coincidence of an effect passing over.
|
|
self.runner.wait(0.5)
|
|
if _probe_matches(probe, tolerance):
|
|
return "results"
|
|
|
|
if self.loop and time.monotonic() >= next_loop:
|
|
next_loop = time.monotonic() + float(self.cfg["routines"].get("loop_every", 6))
|
|
routine.play(self.runner, self.loop, self.speed)
|
|
|
|
return "timeout"
|
|
|
|
# -------------------------------------------------------------- match
|
|
def one_match(self) -> None:
|
|
cfg = self.cfg
|
|
points = cfg["points"]
|
|
match = cfg["match"]
|
|
|
|
if _click_point(points.get("close"), self.runner, "close"):
|
|
self.runner.wait(0.6)
|
|
|
|
if _click_point(points.get("start"), self.runner, "start"):
|
|
self.runner.wait(float(match.get("start_delay", 4)))
|
|
else:
|
|
self.runner.log("no start point calibrated — assuming the match is already running")
|
|
|
|
if self.build:
|
|
self.runner.log(f"placing — {len(self.build)} recorded steps")
|
|
routine.play(self.runner, self.build, self.speed)
|
|
else:
|
|
self.runner.log("no build routine recorded — nothing to place")
|
|
|
|
how = self.wait_for_end()
|
|
self.matches += 1
|
|
self.runner.stats["matches"] = self.matches
|
|
minutes = (time.monotonic() - self.started) / 60
|
|
self.runner.log(f"match {self.matches} ended ({how}) — {minutes:.1f} min elapsed")
|
|
|
|
if _click_point(points.get("replay"), self.runner, "replay"):
|
|
self.runner.wait(1.0)
|
|
if _click_point(points.get("confirm"), self.runner, "confirm"):
|
|
self.runner.wait(1.0)
|
|
|
|
self.runner.wait(float(match.get("load_seconds", 12)))
|
|
self.runner.wait(float(match.get("between", 1)))
|
|
|
|
|
|
def run(once: bool = False, runner: Runner | None = None) -> None:
|
|
"""Farm until stopped."""
|
|
cfg = config.load(NAME, DEFAULTS)
|
|
# Written back on every run, so the file always exists to edit and gains any setting.
|
|
config.save(NAME, cfg)
|
|
runner = runner or Runner("Anime Vanguards", cfg["hotkeys"])
|
|
macro = AnimeVanguards(cfg, runner)
|
|
|
|
if once:
|
|
runner.log("single match — press pause to begin")
|
|
|
|
def tick():
|
|
macro.one_match()
|
|
runner.stopped = True
|
|
else:
|
|
def tick():
|
|
macro.one_match()
|
|
|
|
runner.loop(tick)
|
|
runner.log(f"{macro.matches} matches this session.")
|