Files
KittyMacro/kittymacro/runner.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

114 lines
3.9 KiB
Python

"""The loop every macro runs inside: pause, stop, focus, cleanup."""
from __future__ import annotations
import time
from . import screen, sound, winput
from .hotkeys import Hotkeys, Signals
class Stopped(Exception):
"""Raised out of wait() when the stop key is pressed."""
class Runner:
TICK = 0.02
def __init__(self, name: str, keys: dict | None = None, require_focus: bool = True,
signals: Signals | None = None):
keys = keys or {}
self.name = name
self.require_focus = require_focus
self.hotkeys = Hotkeys(
pause=keys.get("pause", "f7"),
stop=keys.get("stop", "f8"),
)
# A window's buttons press the same two things the F-keys do, and they arrive here.
self.signals = signals or Signals()
# Whatever the macro wants a front end to be able to show.
self.stats: dict = {}
self.paused = True # armed but not running until you say so
self.stopped = False
self._said = None
# ------------------------------------------------------------ output
def log(self, message: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)
def status(self, message: str) -> None:
"""Log only when it changes - a loop would otherwise print it forever."""
if message != self._said:
self._said = message
self.log(message)
# ------------------------------------------------------------- state
def _handle_controls(self) -> None:
for pressed in self.hotkeys.poll() + self.signals.poll():
if pressed == "stop":
self.stopped = True
elif pressed == "pause":
self.paused = not self.paused
if self.paused:
winput.release_all()
sound.stopped()
else:
sound.started()
self.log("paused" if self.paused else "running")
def ready(self) -> bool:
"""Running, not paused, and Roblox is in front."""
if self.paused:
return False
if self.require_focus and not screen.roblox_focused():
return False
return True
def wait(self, seconds: float) -> None:
"""Sleep, but stay responsive and never sleep through a stop."""
remaining = max(float(seconds), 0.0)
while True:
self._handle_controls()
if self.stopped:
raise Stopped()
if self.paused:
self.status(f"paused — {self.hotkeys.describe()}")
time.sleep(self.TICK)
continue
if self.require_focus and not screen.roblox_focused():
if winput.held_keys or winput.held_buttons:
winput.release_all()
self.status("waiting for Roblox to be the front window")
time.sleep(self.TICK)
continue
if remaining <= 0:
return
slice_ = min(self.TICK, remaining)
time.sleep(slice_)
remaining -= slice_
def gate(self) -> None:
"""Block until running, paused-off and focused. A wait of nothing."""
self.wait(0)
# -------------------------------------------------------------- main
def loop(self, tick) -> None:
"""Drive a macro until it stops or the stop key is pressed."""
self.log(f"{self.name} armed. {self.hotkeys.describe()}")
if not screen.find_roblox():
self.log("Roblox is not open — start it and this will pick it up.")
try:
while not self.stopped:
self.gate()
tick()
except Stopped:
sound.alarm()
self.log("stopped.")
except KeyboardInterrupt:
self.log("interrupted.")
finally:
winput.release_all()