Files
KittyMacro/kittymacro/macros/animevanguards.py
T
capyb2222 bc09d70516 feat(init): KittyMacro
Roblox macros that play from outside the game: Anime Vanguards, and a
falling-notes piano event.
2026-09-05 12:22:28 -07:00

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.")