Roblox macros that play from outside the game: Anime Vanguards, and a falling-notes piano event.
2482 lines
111 KiB
Python
2482 lines
111 KiB
Python
#!/usr/bin/env python3
|
||
"""KittyMacro - the window."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import os
|
||
import queue
|
||
import sys
|
||
import threading
|
||
import time
|
||
import traceback
|
||
from pathlib import Path
|
||
|
||
import tkinter as tk
|
||
from tkinter import messagebox
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(HERE))
|
||
|
||
# Started from a console, the log goes there too.
|
||
for _stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
from kittymacro import __version__, config, routine, screen, sound, winput # noqa: E402
|
||
from kittymacro.hotkeys import Hotkeys, Signals # noqa: E402
|
||
from kittymacro.macros import animevanguards, piano # noqa: E402
|
||
from kittymacro.runner import Runner # noqa: E402
|
||
|
||
MACRO = animevanguards # the pages that are about a tower defence game
|
||
GAMES = [("animevanguards", "Vanguards", animevanguards), ("piano", "Piano", piano)]
|
||
|
||
# ------------------------------------------------------------------ paint
|
||
BG = "#12121a" # the window itself
|
||
PANEL = "#191922" # a card on it
|
||
FIELD = "#20202c" # something you can type in or press
|
||
LINE = "#2c2c3c" # a border
|
||
TEXT = "#e9e9f2"
|
||
DIM = "#8c8ca4"
|
||
FAINT = "#5b5b74"
|
||
ACCENT = "#ff5fa2" # kitty pink
|
||
ACCENT_INK = "#1a0d14" # text that sits on the accent
|
||
GOOD = "#57d9a3"
|
||
WARN = "#ffbf5f"
|
||
BAD = "#ff6b6b"
|
||
|
||
UI = ("Segoe UI", 10)
|
||
UI_SMALL = ("Segoe UI", 9)
|
||
UI_BOLD = ("Segoe UI Semibold", 10)
|
||
UI_TINY = ("Segoe UI Semibold", 8)
|
||
UI_TITLE = ("Segoe UI Semibold", 15)
|
||
UI_BIG = ("Segoe UI Semibold", 19)
|
||
MONO = ("Consolas", 9)
|
||
MONO_BIG = ("Consolas", 15)
|
||
|
||
PAGES = [("home", "Home"), ("routines", "Routines"), ("calibrate", "Calibrate"),
|
||
("piano", "Piano"), ("event", "Event"), ("settings", "Settings")]
|
||
|
||
# What START does, per game.
|
||
MODES = {
|
||
"animevanguards": (("loop", "Loop matches"), ("once", "One match")),
|
||
"piano": (("loop", "Song after song"), ("once", "One song"),
|
||
("session", "Full session")),
|
||
}
|
||
|
||
# the four numbers you actually turn while watching the lane monitor
|
||
TUNING = [
|
||
("threshold", "Threshold", "how far from the empty track counts as a note"),
|
||
("offset_ms", "Offset", "ms to wait before pressing. Raise it if it hits early"),
|
||
("min_hold_ms", "Min hold", "ms — a tap has to last long enough to register"),
|
||
("gap_ms", "Gap", "ms — ignore a second note in a lane sooner than this"),
|
||
("let_go_ms", "Let go", "ms a held key waits for the note to come back before "
|
||
"letting go. Raise it if long notes stutter"),
|
||
("max_hold_ms", "Max hold", "ms — let go anyway, so a lane cannot jam. 0 never does"),
|
||
]
|
||
|
||
# path in the config -> label, and the one line that says why you would touch it
|
||
SETTINGS = [
|
||
("Match", (
|
||
("match.start_delay", "Start delay", "after pressing start, before the build order"),
|
||
("match.max_seconds", "Match limit", "give up on a match that never ends"),
|
||
("match.check_every", "Probe every", "how often to look at the results pixel"),
|
||
("match.load_seconds", "Load wait", "after replay, before the next match begins"),
|
||
("match.between", "Between", "a breath between rounds"),
|
||
)),
|
||
("Routines", (
|
||
("routines.speed", "Speed", "scales every recorded gap. Above 1.0 outruns your income"),
|
||
("routines.loop_every", "Loop every", "seconds between replays of the loop routine"),
|
||
)),
|
||
("Anti-AFK", (
|
||
("antiafk.enabled", "Enabled", "a jump every few minutes so Roblox does not idle you out"),
|
||
("antiafk.period", "Every", "seconds between jumps"),
|
||
("antiafk.key", "Key", "what to press. space is a jump"),
|
||
)),
|
||
("Hotkeys", (
|
||
("hotkeys.pause", "Start / pause", "polled globally, so it works while Roblox has focus"),
|
||
("hotkeys.stop", "Stop", "and, while recording, discard"),
|
||
("hotkeys.capture", "Capture point", "picks up the pixel under the cursor"),
|
||
)),
|
||
]
|
||
|
||
|
||
def _get(tree: dict, path: str):
|
||
node = tree
|
||
for part in path.split("."):
|
||
node = node[part]
|
||
return node
|
||
|
||
|
||
def _set(tree: dict, path: str, value) -> None:
|
||
parts = path.split(".")
|
||
node = tree
|
||
for part in parts[:-1]:
|
||
node = node[part]
|
||
node[parts[-1]] = value
|
||
|
||
|
||
def _short(value) -> str:
|
||
"""A number as you would write it down: 4 rather than 4.0."""
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError):
|
||
return str(value)
|
||
return str(int(number)) if number == int(number) else f"{number:g}"
|
||
|
||
|
||
def _hex(colour) -> str:
|
||
if not colour:
|
||
return "#000000"
|
||
return "#%02x%02x%02x" % tuple(int(channel) for channel in colour[:3])
|
||
|
||
|
||
# ------------------------------------------------------------- small parts
|
||
def label(parent, text="", font=UI, fg=TEXT, bg=PANEL, anchor="w", **kw):
|
||
return tk.Label(parent, text=text, font=font, fg=fg, bg=bg, anchor=anchor,
|
||
justify="left", **kw)
|
||
|
||
|
||
def button(parent, text, command, kind="normal", width=None):
|
||
face, ink, hover = {
|
||
"normal": (FIELD, TEXT, LINE),
|
||
"accent": (ACCENT, ACCENT_INK, "#ff7db5"),
|
||
"ghost": (PANEL, DIM, FIELD),
|
||
"danger": (FIELD, BAD, "#39222e"),
|
||
}[kind]
|
||
widget = tk.Button(
|
||
parent, text=text, command=command,
|
||
font=UI_BOLD if kind == "accent" else UI,
|
||
bg=face, fg=ink, activebackground=hover, activeforeground=ink,
|
||
disabledforeground=FAINT, relief="flat", bd=0, highlightthickness=0,
|
||
cursor="hand2", padx=13, pady=6, width=width,
|
||
)
|
||
widget.bind("<Enter>", lambda _e: widget["state"] == "normal" and widget.config(bg=hover))
|
||
widget.bind("<Leave>", lambda _e: widget.config(bg=face))
|
||
return widget
|
||
|
||
|
||
def entry(parent, variable, width=8):
|
||
return tk.Entry(parent, textvariable=variable, font=UI, width=width,
|
||
bg=FIELD, fg=TEXT, insertbackground=ACCENT, relief="flat",
|
||
highlightthickness=1, highlightbackground=LINE,
|
||
highlightcolor=ACCENT, bd=4)
|
||
|
||
|
||
def card(parent, title=None, fill="x", expand=False):
|
||
outer = tk.Frame(parent, bg=PANEL, highlightthickness=1,
|
||
highlightbackground=LINE, highlightcolor=LINE)
|
||
outer.pack(fill=fill, expand=expand, pady=(0, 12))
|
||
if title:
|
||
label(outer, title.upper(), font=UI_TINY, fg=FAINT).pack(
|
||
fill="x", padx=15, pady=(11, 0))
|
||
body = tk.Frame(outer, bg=PANEL)
|
||
body.pack(fill="both", expand=True, padx=15, pady=(7, 13))
|
||
body.outer = outer
|
||
return body
|
||
|
||
|
||
class Segmented(tk.Frame):
|
||
"""Two or three choices where only one can be true. A radio, but legible."""
|
||
|
||
def __init__(self, parent, options, value, command=None):
|
||
super().__init__(parent, bg=LINE, highlightthickness=0)
|
||
self.command = command
|
||
self.buttons = {}
|
||
self.options(options, value)
|
||
|
||
def options(self, options, value=None):
|
||
"""Different choices in the same place - the modes change with the game."""
|
||
for widget in self.buttons.values():
|
||
widget.destroy()
|
||
self.buttons = {}
|
||
for key, text in options:
|
||
widget = tk.Button(self, text=text, font=UI, relief="flat", bd=0,
|
||
padx=13, pady=5, highlightthickness=0, cursor="hand2",
|
||
command=lambda k=key: self.set(k))
|
||
widget.pack(side="left", padx=1, pady=1)
|
||
self.buttons[key] = widget
|
||
keys = list(self.buttons)
|
||
self.value = value if value in keys else (keys[0] if keys else None)
|
||
self._paint()
|
||
|
||
def set(self, key):
|
||
self.value = key
|
||
self._paint()
|
||
if self.command:
|
||
self.command(key)
|
||
|
||
def _paint(self):
|
||
for key, widget in self.buttons.items():
|
||
on = key == self.value
|
||
widget.config(bg=ACCENT if on else FIELD, fg=ACCENT_INK if on else DIM,
|
||
activebackground=ACCENT if on else LINE,
|
||
activeforeground=ACCENT_INK if on else TEXT)
|
||
|
||
|
||
class Relay:
|
||
"""Everything printed in this process, on its way to the log pane."""
|
||
|
||
def __init__(self, stream, sink: queue.Queue):
|
||
self.stream = stream
|
||
self.sink = sink
|
||
self.partial = ""
|
||
|
||
def write(self, text: str) -> int:
|
||
if self.stream is not None:
|
||
try:
|
||
self.stream.write(text)
|
||
except Exception:
|
||
pass
|
||
self.partial += text.replace("\r", "\n")
|
||
while "\n" in self.partial:
|
||
line, self.partial = self.partial.split("\n", 1)
|
||
if line.strip():
|
||
self.sink.put(line)
|
||
return len(text)
|
||
|
||
def flush(self) -> None:
|
||
if self.stream is not None:
|
||
try:
|
||
self.stream.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
def isatty(self) -> bool:
|
||
return False
|
||
|
||
|
||
# ------------------------------------------------------------------- app
|
||
class App(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("KittyMacro")
|
||
self.configure(bg=BG)
|
||
self.geometry("1040x740")
|
||
self.minsize(920, 620)
|
||
self._scale_for_display()
|
||
self._set_icon()
|
||
self._dark_title_bar()
|
||
|
||
self.cfg = config.load(MACRO.NAME, MACRO.DEFAULTS)
|
||
self.piano = piano.migrate(config.load(piano.NAME, piano.DEFAULTS))
|
||
self.playing = "animevanguards" # which macro START runs
|
||
self.ends = {} # the two hit-line ends being picked
|
||
self.lane_region = None
|
||
self.lane_cells = []
|
||
self.lane_picks = []
|
||
self.logs: queue.Queue[str] = queue.Queue()
|
||
sys.stdout = Relay(sys.stdout, self.logs)
|
||
sys.stderr = Relay(sys.stderr, self.logs)
|
||
|
||
# what the macro thread is doing, if anything
|
||
self.task: threading.Thread | None = None
|
||
self.kind: str | None = None # "run" | "play" | "record"
|
||
self.runner: Runner | None = None
|
||
self.signals: Signals | None = None
|
||
self.recording = False # the recorder is actually taking steps
|
||
self.countdown = None
|
||
|
||
self.page = "home"
|
||
self.pick: str | None = None # the point Capture is waiting for
|
||
# Watched every 15ms, not every tick: a tap of a key lasts about 80ms.
|
||
self.watch = Hotkeys(catch_taps=True,
|
||
take=self.cfg["hotkeys"].get("capture", "f6"),
|
||
skip=self.cfg["hotkeys"].get("stop", "f8"))
|
||
self.roblox = None
|
||
self._roblox_at = 0.0
|
||
self._ready_shown = None
|
||
|
||
self._build()
|
||
self.subtitle.config(text=self.SUBTITLE[self.playing])
|
||
self._show("home")
|
||
self._log(f"KittyMacro {__version__} — {sys.platform}, python {sys.version.split()[0]}")
|
||
self._check_input()
|
||
self._refresh_routines()
|
||
self._refresh_points()
|
||
self._load_fields()
|
||
self.protocol("WM_DELETE_WINDOW", self._close)
|
||
self._tick()
|
||
self._watch_keys()
|
||
|
||
# ---------------------------------------------------------- chrome
|
||
def _scale_for_display(self):
|
||
"""The macro makes the process DPI aware before Tk starts."""
|
||
try:
|
||
dpi = ctypes.windll.user32.GetDpiForSystem()
|
||
except Exception:
|
||
dpi = 96
|
||
self.tk.call("tk", "scaling", max(dpi, 96) / 72.0)
|
||
|
||
def _dark_title_bar(self):
|
||
"""The frame is Windows' to draw, and it draws it white unless told."""
|
||
try:
|
||
self.update_idletasks()
|
||
hwnd = ctypes.windll.user32.GetParent(self.winfo_id())
|
||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||
hwnd, 20, ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int))
|
||
except Exception:
|
||
pass
|
||
|
||
def _set_icon(self):
|
||
self.logo = None
|
||
for candidate in (HERE / "logo.png", HERE.parent / "FullLogo_NoBuffer.png"):
|
||
if not candidate.exists():
|
||
continue
|
||
try:
|
||
image = tk.PhotoImage(file=str(candidate))
|
||
self.iconphoto(True, image)
|
||
self.logo = image.subsample(max(1, round(image.height() / 34)))
|
||
self._icon_keep = image
|
||
except Exception:
|
||
pass
|
||
break
|
||
|
||
def _build(self):
|
||
self._build_header()
|
||
body = tk.Frame(self, bg=BG)
|
||
body.pack(fill="both", expand=True)
|
||
self._build_sidebar(body)
|
||
|
||
split = tk.PanedWindow(body, orient="vertical", bg=BG, bd=0, sashwidth=7,
|
||
sashrelief="flat", showhandle=False, sashpad=0)
|
||
split.pack(side="left", fill="both", expand=True)
|
||
|
||
stack = tk.Frame(split, bg=BG)
|
||
split.add(stack, minsize=240, stretch="always")
|
||
stack.rowconfigure(0, weight=1)
|
||
stack.columnconfigure(0, weight=1)
|
||
self.pages, self.sheets = {}, {}
|
||
for name, _title in PAGES:
|
||
self.pages[name], self.sheets[name] = self._scroller(stack)
|
||
|
||
self._page_home(self.sheets["home"])
|
||
self._page_routines(self.sheets["routines"])
|
||
self._page_calibrate(self.sheets["calibrate"])
|
||
self._page_piano(self.sheets["piano"])
|
||
self._page_event(self.sheets["event"])
|
||
self._page_settings(self.sheets["settings"])
|
||
|
||
self._build_log(split)
|
||
# The log is a sidecar, not half the window; leave it where it starts.
|
||
self.after(120, lambda: split.sash_place(0, 0, 470))
|
||
|
||
def _scroller(self, stack):
|
||
"""A page taller than the window is one you can still reach the bottom of."""
|
||
outer = tk.Frame(stack, bg=BG)
|
||
outer.grid(row=0, column=0, sticky="nsew")
|
||
bar = tk.Scrollbar(outer, bg=PANEL, troughcolor=BG, activebackground=LINE,
|
||
bd=0, highlightthickness=0, relief="flat", width=11)
|
||
canvas = tk.Canvas(outer, bg=BG, highlightthickness=0, bd=0,
|
||
yscrollcommand=bar.set)
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
bar.config(command=canvas.yview)
|
||
sheet = tk.Frame(canvas, bg=BG, padx=18, pady=16)
|
||
window = canvas.create_window((0, 0), window=sheet, anchor="nw")
|
||
|
||
def fitted(_event=None):
|
||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||
over = sheet.winfo_reqheight() > canvas.winfo_height()
|
||
if over and not bar.winfo_ismapped():
|
||
bar.pack(side="right", fill="y")
|
||
elif not over and bar.winfo_ismapped():
|
||
bar.pack_forget()
|
||
|
||
sheet.bind("<Configure>", fitted)
|
||
canvas.bind("<Configure>",
|
||
lambda event: (canvas.itemconfigure(window, width=event.width), fitted()))
|
||
# Only while the pointer is over this page - the log has its own wheel.
|
||
canvas.bind("<Enter>", lambda _e: canvas.bind_all(
|
||
"<MouseWheel>", lambda event: canvas.yview_scroll(-event.delta // 120, "units")))
|
||
canvas.bind("<Leave>", lambda _e: canvas.unbind_all("<MouseWheel>"))
|
||
outer.canvas = canvas
|
||
return outer, sheet
|
||
|
||
def _build_header(self):
|
||
head = tk.Frame(self, bg=PANEL, height=62)
|
||
head.pack(fill="x")
|
||
head.pack_propagate(False)
|
||
tk.Frame(self, bg=LINE, height=1).pack(fill="x")
|
||
|
||
left = tk.Frame(head, bg=PANEL)
|
||
left.pack(side="left", padx=18)
|
||
if self.logo is not None:
|
||
tk.Label(left, image=self.logo, bg=PANEL).pack(side="left", padx=(0, 11))
|
||
text = tk.Frame(left, bg=PANEL)
|
||
text.pack(side="left")
|
||
label(text, "KittyMacro", font=UI_TITLE).pack(anchor="w")
|
||
self.subtitle = label(text, "", font=UI_SMALL, fg=FAINT)
|
||
self.subtitle.pack(anchor="w")
|
||
|
||
right = tk.Frame(head, bg=PANEL)
|
||
right.pack(side="right", padx=18)
|
||
self.pill_stats = label(right, "", font=UI_SMALL, fg=DIM)
|
||
self.pill_stats.pack(side="right", padx=(18, 0))
|
||
self.pill_state = self._pill(right, "idle")
|
||
self.pill_roblox = self._pill(right, "Roblox")
|
||
|
||
def _pill(self, parent, text):
|
||
holder = tk.Frame(parent, bg=PANEL)
|
||
holder.pack(side="right", padx=(16, 0))
|
||
dot = tk.Label(holder, text="●", font=("Segoe UI", 11), fg=FAINT, bg=PANEL)
|
||
dot.pack(side="left", padx=(0, 5))
|
||
name = label(holder, text, font=UI_SMALL, fg=DIM)
|
||
name.pack(side="left")
|
||
holder.dot, holder.text = dot, name
|
||
return holder
|
||
|
||
def _build_sidebar(self, parent):
|
||
side = tk.Frame(parent, bg=PANEL, width=176)
|
||
side.pack(side="left", fill="y")
|
||
side.pack_propagate(False)
|
||
tk.Frame(parent, bg=LINE, width=1).pack(side="left", fill="y")
|
||
|
||
choose = tk.Frame(side, bg=PANEL)
|
||
choose.pack(fill="x", padx=14, pady=(14, 10))
|
||
label(choose, "PLAYING", font=UI_TINY, fg=FAINT).pack(anchor="w", pady=(0, 5))
|
||
self.game = Segmented(choose, [(name, title) for name, title, _m in GAMES],
|
||
"animevanguards", command=self._choose_game)
|
||
self.game.pack(fill="x")
|
||
|
||
self.nav = {}
|
||
for name, title in PAGES:
|
||
row = tk.Frame(side, bg=PANEL)
|
||
row.pack(fill="x")
|
||
bar = tk.Frame(row, bg=PANEL, width=3)
|
||
bar.pack(side="left", fill="y")
|
||
item = tk.Button(row, text=title, font=UI, anchor="w", bg=PANEL, fg=DIM,
|
||
activebackground=FIELD, activeforeground=TEXT, relief="flat",
|
||
bd=0, highlightthickness=0, cursor="hand2", padx=14, pady=9,
|
||
command=lambda n=name: self._show(n))
|
||
item.pack(side="left", fill="x", expand=True)
|
||
self.nav[name] = (bar, item)
|
||
|
||
foot = tk.Frame(side, bg=PANEL)
|
||
foot.pack(side="bottom", fill="x", padx=14, pady=14)
|
||
self.hotkey_hint = label(foot, "", font=UI_SMALL, fg=FAINT, bg=PANEL)
|
||
self.hotkey_hint.pack(fill="x", pady=(0, 10))
|
||
self.btn_go = button(foot, "START", self._go, kind="accent")
|
||
self.btn_go.pack(fill="x", pady=(0, 6))
|
||
self.btn_stop = button(foot, "Stop", self._stop, kind="danger")
|
||
self.btn_stop.pack(fill="x")
|
||
self.btn_stop.config(state="disabled")
|
||
|
||
SUBTITLE = {
|
||
"animevanguards": "Anime Vanguards — outside the game",
|
||
"piano": "a falling-notes event — outside the game",
|
||
}
|
||
|
||
def _mode_changed(self, _key):
|
||
self._ready_shown = None
|
||
|
||
def _choose_game(self, name):
|
||
self.playing = name
|
||
self.subtitle.config(text=self.SUBTITLE[name])
|
||
self.mode.options(MODES[name], self.mode.value)
|
||
self._ready_shown = None
|
||
self._log(f"START now runs: {dict((n, t) for n, t, _m in GAMES)[name]}")
|
||
if name == "piano":
|
||
self._show("piano")
|
||
|
||
def _module(self):
|
||
return dict((name, module) for name, _t, module in GAMES)[self.playing]
|
||
|
||
def _build_log(self, split):
|
||
pane = tk.Frame(split, bg=PANEL, highlightthickness=1, highlightbackground=LINE)
|
||
split.add(pane, minsize=96)
|
||
|
||
bar = tk.Frame(pane, bg=PANEL)
|
||
bar.pack(fill="x", padx=12, pady=(8, 4))
|
||
label(bar, "LOG", font=UI_TINY, fg=FAINT).pack(side="left")
|
||
button(bar, "Clear", self._clear_log, kind="ghost").pack(side="right")
|
||
self.follow = tk.BooleanVar(value=True)
|
||
tk.Checkbutton(bar, text="follow", variable=self.follow, font=UI_SMALL,
|
||
bg=PANEL, fg=DIM, activebackground=PANEL, activeforeground=TEXT,
|
||
selectcolor=FIELD, relief="flat", bd=0, highlightthickness=0
|
||
).pack(side="right", padx=8)
|
||
|
||
holder = tk.Frame(pane, bg=PANEL)
|
||
holder.pack(fill="both", expand=True, padx=12, pady=(0, 10))
|
||
bars = tk.Scrollbar(holder, bg=PANEL, troughcolor=BG, activebackground=LINE,
|
||
bd=0, highlightthickness=0, relief="flat", width=11)
|
||
bars.pack(side="right", fill="y")
|
||
self.log = tk.Text(holder, height=9, font=MONO, bg=BG, fg=DIM, relief="flat",
|
||
bd=0, highlightthickness=0, wrap="word", padx=10, pady=8,
|
||
yscrollcommand=bars.set, state="disabled")
|
||
self.log.pack(side="left", fill="both", expand=True)
|
||
bars.config(command=self.log.yview)
|
||
for tag, colour in (("good", GOOD), ("warn", WARN), ("bad", BAD),
|
||
("dim", FAINT), ("plain", TEXT)):
|
||
self.log.tag_config(tag, foreground=colour)
|
||
|
||
# ------------------------------------------------------------ pages
|
||
def _show(self, name):
|
||
self.page = name
|
||
self.pages[name].tkraise()
|
||
self.pages[name].canvas.yview_moveto(0)
|
||
for key, (bar, item) in self.nav.items():
|
||
on = key == name
|
||
bar.config(bg=ACCENT if on else PANEL)
|
||
item.config(fg=TEXT if on else DIM, bg=FIELD if on else PANEL)
|
||
if name == "routines":
|
||
self._refresh_routines()
|
||
if name == "settings":
|
||
self._load_fields()
|
||
if name == "piano":
|
||
self._rebuild_region()
|
||
if name == "event":
|
||
self._refresh_event()
|
||
|
||
# -- home ---------------------------------------------------------
|
||
def _page_home(self, page):
|
||
body = card(page, "session")
|
||
top = tk.Frame(body, bg=PANEL)
|
||
top.pack(fill="x")
|
||
self.state_big = label(top, "idle", font=UI_BIG)
|
||
self.state_big.pack(side="left")
|
||
self.mode = Segmented(top, MODES["animevanguards"], "loop",
|
||
command=self._mode_changed)
|
||
self.mode.pack(side="right")
|
||
self.state_note = label(body, "press START, then let it have the window",
|
||
font=UI_SMALL, fg=DIM)
|
||
self.state_note.pack(fill="x", pady=(6, 12))
|
||
|
||
stats = tk.Frame(body, bg=PANEL)
|
||
stats.pack(fill="x")
|
||
self.stat_widgets = {}
|
||
for key in ("a", "b", "c", "d"):
|
||
cell = tk.Frame(stats, bg=PANEL)
|
||
cell.pack(side="left", expand=True, fill="x")
|
||
value = label(cell, "—", font=UI_BOLD)
|
||
value.pack(anchor="w")
|
||
caption = label(cell, "", font=UI_SMALL, fg=FAINT)
|
||
caption.pack(anchor="w")
|
||
self.stat_widgets[key] = (value, caption)
|
||
|
||
body = card(page, "before you start")
|
||
self.ready_rows = tk.Frame(body, bg=PANEL)
|
||
self.ready_rows.pack(fill="x")
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(12, 0))
|
||
button(row, "Re-check", self._check_input).pack(side="left")
|
||
button(row, "Test input — jumps in game", self._test_input).pack(side="left", padx=8)
|
||
|
||
body = card(page, "what start does")
|
||
self.explain = label(body, "", font=UI_SMALL, fg=DIM, wraplength=640)
|
||
self.explain.pack(fill="x")
|
||
|
||
EXPLAIN = {
|
||
"animevanguards":
|
||
"Brings Roblox forward, waits three seconds, then: close the popup, press "
|
||
"start, replay your build order, upgrade while the waves run, watch the "
|
||
"results pixel, press replay. Again.\n\n"
|
||
"It only ever sends input while Roblox is the front window. Alt-tab away "
|
||
"and it lets go of everything and waits.",
|
||
"piano":
|
||
"Brings Roblox forward, waits three seconds, then reads the strip across "
|
||
"your hit line about 180 times a second. A lane that stops looking like "
|
||
"empty track gets its key held down until it looks empty again — which "
|
||
"taps the short notes and holds the long ones, without either being a "
|
||
"special case.\n\n"
|
||
"A song is over when the track stops changing, or the moment the results "
|
||
"pixel shows if you picked one. Song after song waits for the next one to "
|
||
"start; Full session walks in through the menus and plays every song at "
|
||
"every difficulty, remembering across restarts which ones it has done.\n\n"
|
||
"Nothing is predicted and nothing is read out of the game. If you can see "
|
||
"the note, so can it, at the same moment you can.",
|
||
}
|
||
|
||
def _ready_rows(self):
|
||
if self.playing == "piano":
|
||
lanes = self.piano.get("lanes") or []
|
||
sampled = [lane for lane in lanes if lane.get("empty") or lane.get("note")]
|
||
finish = self.piano.get("finish") or {}
|
||
probe = (finish.get("clock") or finish.get("probe") or {}).get("at")
|
||
rows = [
|
||
(bool(self.roblox), "Roblox window",
|
||
"found" if self.roblox else "not open — start the game"),
|
||
(bool(lanes), "Lanes",
|
||
f"{len(lanes)} across the hit line: "
|
||
f"{''.join(lane.get('key') or '·' for lane in lanes)}" if lanes
|
||
else "not laid out — Piano tab, pick both ends of the hit line"),
|
||
(bool(lanes) and len(sampled) == len(lanes), "Empty track",
|
||
f"sampled for all {len(lanes)}" if lanes and len(sampled) == len(lanes)
|
||
else f"{len(sampled)} of {len(lanes)} sampled — nothing fires without it"),
|
||
(bool(probe), "End of a song",
|
||
f"watching {tuple(probe)}" if probe
|
||
else "on stillness alone — Event tab, point at the song timer"),
|
||
]
|
||
if self.mode.value == "session":
|
||
rows += self._session_rows()
|
||
return rows
|
||
routines = config.list_routines()
|
||
build = self.cfg["routines"].get("build")
|
||
points = self.cfg["points"]
|
||
return [
|
||
(bool(self.roblox), "Roblox window",
|
||
"found" if self.roblox else "not open — start the game"),
|
||
(bool(build and build in routines), "Build routine",
|
||
f"{build!r} recorded" if build in routines else
|
||
f"nothing recorded — Routines tab, record {build or 'one'}"),
|
||
(bool(points.get("start")), "Start button",
|
||
"calibrated" if points.get("start") else
|
||
"not set — the macro will assume a match is already running"),
|
||
(bool(points.get("end_probe")), "Results pixel",
|
||
"calibrated" if points.get("end_probe") else
|
||
"not set — matches end on a timer instead, which wastes the fast ones"),
|
||
]
|
||
|
||
def _session_rows(self):
|
||
"""The three things a session needs that playing one song does not."""
|
||
routes = self.piano.get("routes") or {}
|
||
session = self.piano.get("session") or {}
|
||
songs = session.get("songs") or {}
|
||
levels = session.get("difficulties") or []
|
||
set_levels = [level for level in levels if level.get("at")]
|
||
into = [step for step in routes.get("into") or [] if step.get("at")]
|
||
done = len(piano.load_progress().get("done") or {})
|
||
rows = piano.song_rows(songs)
|
||
total = piano.song_count(songs) * max(1, len(levels))
|
||
return [
|
||
(bool(into), "Way in",
|
||
f"{len(into)} steps picked" if into
|
||
else "not picked — Event tab. Without it, start on the song list yourself"),
|
||
(bool(rows), "Song list",
|
||
f"{len(rows)} rows picked" if rows
|
||
else "not picked — Event tab, one point per row of the song list"),
|
||
(bool(levels) and len(set_levels) == len(levels), "Difficulties",
|
||
f"all {len(levels)} picked" if levels and len(set_levels) == len(levels)
|
||
else f"{len(set_levels)} of {len(levels)} picked — the rest play whatever "
|
||
f"is already chosen"),
|
||
(done < total, "Progress",
|
||
f"{done} of {total} runs done" if done < total
|
||
else f"all {total} done — Forget them on the Event tab to go again"),
|
||
]
|
||
|
||
def _refresh_ready(self):
|
||
rows = self._ready_rows()
|
||
self.explain.config(text=self.EXPLAIN[self.playing])
|
||
shown = [(ok, text) for ok, _name, text in rows] + [self.playing]
|
||
if shown == self._ready_shown:
|
||
return
|
||
self._ready_shown = shown
|
||
for child in self.ready_rows.winfo_children():
|
||
child.destroy()
|
||
for ok, name, text in rows:
|
||
row = tk.Frame(self.ready_rows, bg=PANEL)
|
||
row.pack(fill="x", pady=2)
|
||
tk.Label(row, text="✓" if ok else "✕", font=UI_BOLD, width=2,
|
||
fg=GOOD if ok else WARN, bg=PANEL).pack(side="left")
|
||
label(row, name, font=UI, width=15).pack(side="left")
|
||
label(row, text, font=UI_SMALL, fg=DIM).pack(side="left")
|
||
|
||
def _check_input(self):
|
||
width, height = screen.screen_size()
|
||
try:
|
||
elevated = bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||
except Exception:
|
||
elevated = False
|
||
try:
|
||
winput.move_rel(0, 0)
|
||
self._log(f"screen {width}x{height} · sendinput accepted · "
|
||
f"{'elevated' if elevated else 'not elevated'}")
|
||
except winput.InputError as error:
|
||
self._log(f"sendinput REFUSED — {error}")
|
||
self._log("if Roblox is running as administrator, run KittyMacro the same way")
|
||
self._ready_shown = None
|
||
self._refresh_ready()
|
||
|
||
def _test_input(self):
|
||
if self.task:
|
||
self._log("stop the macro first")
|
||
return
|
||
screen.focus_roblox()
|
||
self._log("live test — two jumps in three seconds")
|
||
self._count(3, self._jump_twice)
|
||
|
||
def _jump_twice(self):
|
||
if not screen.roblox_focused():
|
||
self._log("Roblox is not the front window — click it and try again")
|
||
return
|
||
winput.tap("space", hold=0.08)
|
||
self.after(400, lambda: winput.tap("space", hold=0.08))
|
||
self._log("sent two jumps. If your character jumped, everything below works.")
|
||
|
||
# -- routines -----------------------------------------------------
|
||
def _page_routines(self, page):
|
||
body = card(page, "recorded")
|
||
holder = tk.Frame(body, bg=PANEL)
|
||
holder.pack(fill="x")
|
||
bars = tk.Scrollbar(holder, bg=PANEL, troughcolor=BG, activebackground=LINE,
|
||
bd=0, highlightthickness=0, width=11)
|
||
bars.pack(side="right", fill="y")
|
||
self.routine_list = tk.Listbox(
|
||
holder, height=6, font=MONO, bg=FIELD, fg=TEXT, relief="flat", bd=0,
|
||
highlightthickness=0, activestyle="none", selectbackground=ACCENT,
|
||
selectforeground=ACCENT_INK, yscrollcommand=bars.set)
|
||
self.routine_list.pack(side="left", fill="x", expand=True)
|
||
bars.config(command=self.routine_list.yview)
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(10, 0))
|
||
self.play_speed = tk.StringVar(value="1.0")
|
||
button(row, "Play once", self._play).pack(side="left")
|
||
label(row, "speed", font=UI_SMALL, fg=DIM).pack(side="left", padx=(12, 5))
|
||
entry(row, self.play_speed, width=5).pack(side="left")
|
||
button(row, "Delete", self._delete_routine, kind="danger").pack(side="right")
|
||
button(row, "Open folder", lambda: os.startfile(config.ROUTINE_DIR)
|
||
if config.ROUTINE_DIR.exists() else self._log("nothing recorded yet"),
|
||
kind="ghost").pack(side="right", padx=8)
|
||
|
||
body = card(page, "record a new one")
|
||
label(body, "Press record, then play one round by hand exactly as you would — "
|
||
"pick a unit, click the tile, upgrade. Every click and key press is "
|
||
"saved with the gap before it, so your timing becomes the macro's.",
|
||
font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 10))
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x")
|
||
label(row, "name", font=UI_SMALL, fg=DIM).pack(side="left", padx=(0, 6))
|
||
self.record_name = tk.StringVar(value="build")
|
||
entry(row, self.record_name, width=16).pack(side="left")
|
||
self.btn_record = button(row, "Record", self._record, kind="accent")
|
||
self.btn_record.pack(side="left", padx=10)
|
||
self.record_note = label(row, "", font=UI_SMALL, fg=DIM)
|
||
self.record_note.pack(side="left")
|
||
|
||
body = card(page, "what the macro uses")
|
||
grid = tk.Frame(body, bg=PANEL)
|
||
grid.pack(fill="x")
|
||
self.use_build = tk.StringVar()
|
||
self.use_loop = tk.StringVar()
|
||
self.menu_build = self._picker(grid, "build — once, at the start of a match",
|
||
self.use_build, 0)
|
||
self.menu_loop = self._picker(grid, "loop — over and over until the match ends",
|
||
self.use_loop, 1)
|
||
label(body, "Upgrade clicks and ability keys belong in the loop routine. "
|
||
"Leave it as (none) if your build order is the whole round.",
|
||
font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(10, 0))
|
||
|
||
def _picker(self, parent, text, variable, row):
|
||
label(parent, text, font=UI_SMALL, fg=DIM).grid(row=row, column=1, sticky="w",
|
||
padx=12, pady=4)
|
||
menu = tk.OptionMenu(parent, variable, "")
|
||
menu.config(bg=FIELD, fg=TEXT, activebackground=LINE, activeforeground=TEXT,
|
||
font=UI, relief="flat", bd=0, highlightthickness=0, width=14,
|
||
anchor="w", direction="below", cursor="hand2")
|
||
menu["menu"].config(bg=FIELD, fg=TEXT, activebackground=ACCENT,
|
||
activeforeground=ACCENT_INK, font=UI, bd=0, relief="flat")
|
||
menu.grid(row=row, column=0, sticky="w", pady=4)
|
||
variable.trace_add("write", lambda *_: self._save_use())
|
||
return menu
|
||
|
||
def _refresh_routines(self):
|
||
names = config.list_routines()
|
||
selected = self._selected_routine()
|
||
self.routine_list.delete(0, "end")
|
||
for name in names:
|
||
try:
|
||
steps = config.load_routine(name)
|
||
except Exception:
|
||
continue
|
||
clicks = sum(1 for step in steps if step.get("do") == "click")
|
||
keys = sum(1 for step in steps if step.get("do") == "key")
|
||
seconds = sum(float(step.get("after", 0)) for step in steps)
|
||
self.routine_list.insert(
|
||
"end", f" {name:<16} {len(steps):>3} steps {clicks} clicks, "
|
||
f"{keys} keys, {seconds:.0f}s")
|
||
if not names:
|
||
self.routine_list.insert("end", " nothing recorded yet")
|
||
elif selected in names:
|
||
self.routine_list.selection_set(names.index(selected))
|
||
|
||
self._routines = names
|
||
for menu, variable, allow_none in ((self.menu_build, self.use_build, False),
|
||
(self.menu_loop, self.use_loop, True)):
|
||
inner = menu["menu"]
|
||
inner.delete(0, "end")
|
||
options = (["(none)"] if allow_none else []) + names
|
||
for option in options or ["(none)"]:
|
||
inner.add_command(label=option,
|
||
command=lambda v=variable, o=option: v.set(o))
|
||
self._quiet_use = True
|
||
self.use_build.set(self.cfg["routines"].get("build") or "(none)")
|
||
self.use_loop.set(self.cfg["routines"].get("loop") or "(none)")
|
||
self._quiet_use = False
|
||
self._ready_shown = None
|
||
|
||
def _selected_routine(self):
|
||
picked = self.routine_list.curselection() if hasattr(self, "routine_list") else ()
|
||
names = getattr(self, "_routines", [])
|
||
if not picked or picked[0] >= len(names):
|
||
return None
|
||
return names[picked[0]]
|
||
|
||
def _save_use(self):
|
||
if getattr(self, "_quiet_use", False):
|
||
return
|
||
build = self.use_build.get()
|
||
loop = self.use_loop.get()
|
||
self.cfg["routines"]["build"] = None if build == "(none)" else build
|
||
self.cfg["routines"]["loop"] = None if loop == "(none)" else loop
|
||
config.save(MACRO.NAME, self.cfg)
|
||
self._log(f"build = {self.cfg['routines']['build']!r}, "
|
||
f"loop = {self.cfg['routines']['loop']!r}")
|
||
self._ready_shown = None
|
||
|
||
def _delete_routine(self):
|
||
name = self._selected_routine()
|
||
if not name:
|
||
self._log("pick a routine in the list first")
|
||
return
|
||
if not messagebox.askyesno("Delete routine", f"Delete {name!r}?", parent=self):
|
||
return
|
||
config.routine_path(name).unlink(missing_ok=True)
|
||
self._log(f"deleted {name!r}")
|
||
self._refresh_routines()
|
||
|
||
# -- calibrate ----------------------------------------------------
|
||
def _page_calibrate(self, page):
|
||
body = card(page, "under the cursor")
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x")
|
||
self.cursor_at = label(row, "0, 0", font=MONO_BIG)
|
||
self.cursor_at.pack(side="left")
|
||
self.cursor_swatch = tk.Frame(row, bg="#000000", width=34, height=34,
|
||
highlightthickness=1, highlightbackground=LINE)
|
||
self.cursor_swatch.pack(side="left", padx=14)
|
||
self.cursor_swatch.pack_propagate(False)
|
||
self.cursor_rgb = label(row, "", font=MONO, fg=DIM)
|
||
self.cursor_rgb.pack(side="left")
|
||
button(row, "Capture in 3s", self._pick_after_countdown,
|
||
kind="ghost").pack(side="right")
|
||
self.pick_note = label(body, "Pick a point below, hover it in Roblox, "
|
||
"then press the capture key.",
|
||
font=UI_SMALL, fg=DIM)
|
||
self.pick_note.pack(fill="x", pady=(8, 0))
|
||
|
||
body = card(page, "points")
|
||
self.point_rows = {}
|
||
for key, prompt in MACRO.POINTS:
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=4)
|
||
head = tk.Frame(row, bg=PANEL, width=110)
|
||
head.pack(side="left", fill="y")
|
||
head.pack_propagate(False)
|
||
label(head, key, font=UI_BOLD).pack(anchor="w")
|
||
|
||
middle = tk.Frame(row, bg=PANEL)
|
||
middle.pack(side="left", fill="x", expand=True)
|
||
label(middle, prompt, font=UI_SMALL, fg=FAINT, wraplength=390).pack(anchor="w")
|
||
|
||
where = label(row, "not set", font=MONO, fg=WARN, width=16)
|
||
where.pack(side="left", padx=8)
|
||
swatch = tk.Frame(row, bg=PANEL, width=20, height=20,
|
||
highlightthickness=0, highlightbackground=LINE)
|
||
swatch.pack(side="left", padx=(0, 10))
|
||
swatch.pack_propagate(False)
|
||
button(row, "Clear", lambda k=key: self._clear_point(k), kind="ghost").pack(
|
||
side="right", padx=(6, 0))
|
||
pick = button(row, "Pick", lambda k=key: self._arm_pick(k))
|
||
pick.pack(side="right")
|
||
self.point_rows[key] = (where, swatch, pick)
|
||
|
||
body = card(page, "the results pixel")
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x")
|
||
label(row, "tolerance", font=UI_SMALL, fg=DIM).pack(side="left", padx=(0, 10))
|
||
self.tolerance = tk.StringVar(value=str(int(self.cfg.get("tolerance", 26))))
|
||
button(row, "−", lambda: self._nudge_tolerance(-2), width=2).pack(side="left")
|
||
box = entry(row, self.tolerance, width=4)
|
||
box.pack(side="left", padx=6)
|
||
box.bind("<Return>", lambda _e: self._save_tolerance())
|
||
box.bind("<FocusOut>", lambda _e: self._save_tolerance())
|
||
button(row, "+", lambda: self._nudge_tolerance(2), width=2).pack(side="left")
|
||
button(row, "Test now", self._test_probe).pack(side="left", padx=14)
|
||
label(body, "How far each of red, green and blue may drift before that pixel "
|
||
"counts as a different colour. Put the probe on something loud — a "
|
||
"coloured header, a button's fill — never on grey background.",
|
||
font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(8, 0))
|
||
|
||
def _page_piano(self, page):
|
||
body = card(page, "the hit line")
|
||
label(body, "Put one sampling point on each lane, right where the note is when "
|
||
"you would press it. Lanes are evenly spaced, so capture the two "
|
||
"ends and the rest are worked out.\n\n"
|
||
"Then Watch the track: it brings Roblox forward and watches your "
|
||
"lanes for six seconds of the song. The colour a lane shows most of "
|
||
"the time is the empty track, the rest are notes, and the threshold "
|
||
"goes between them. You do not have to find a quiet moment.",
|
||
font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 10))
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x")
|
||
label(row, "keys", font=UI_SMALL, fg=DIM).pack(side="left", padx=(0, 6))
|
||
self.lane_keys = tk.StringVar(value="".join(
|
||
lane.get("key") or "" for lane in self.piano.get("lanes") or []) or "asdfg")
|
||
entry(row, self.lane_keys, width=14).pack(side="left")
|
||
label(row, "one per lane, left to right", font=UI_SMALL, fg=FAINT).pack(
|
||
side="left", padx=10)
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(10, 0))
|
||
self.end_picks = {}
|
||
for end, text in (("left", "Pick left end"), ("right", "Pick right end")):
|
||
pick = button(row, text, lambda e=end: self._arm_pick(f"lane:{e}"))
|
||
pick.plain = text
|
||
pick.pack(side="left", padx=(0, 8))
|
||
self.end_picks[end] = pick
|
||
button(row, "Capture in 3s", self._pick_after_countdown, kind="ghost").pack(
|
||
side="left")
|
||
self.btn_watch = button(row, "Watch the track", self._sample_empty, kind="accent")
|
||
self.btn_watch.pack(side="right")
|
||
button(row, "Trace 5s", self._trace).pack(side="right", padx=8)
|
||
button(row, "Find the lanes", self._find_lanes).pack(side="right")
|
||
button(row, "Snapshot", self._snapshot, kind="ghost").pack(side="right", padx=8)
|
||
self.lane_note = label(body, "", font=UI_SMALL, fg=DIM)
|
||
self.lane_note.pack(fill="x", pady=(10, 0))
|
||
|
||
body = card(page, "what it is reading")
|
||
self.lane_strip = tk.Frame(body, bg=PANEL)
|
||
self.lane_strip.pack(fill="x")
|
||
label(body, "\u0394 is how far that pixel is from the empty track right now. "
|
||
"Watch it with the track clear, then with notes crossing: the "
|
||
"threshold belongs between the two.",
|
||
font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(10, 0))
|
||
|
||
body = card(page, "tuning")
|
||
self.tune = {}
|
||
for name, title, hint in TUNING:
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=3)
|
||
label(row, title, font=UI, width=11).pack(side="left")
|
||
variable = tk.StringVar(value=str(self.piano["detect"].get(name)))
|
||
box = entry(row, variable, width=7)
|
||
box.pack(side="left")
|
||
box.bind("<Return>", lambda _e: self._save_tuning())
|
||
box.bind("<FocusOut>", lambda _e: self._save_tuning())
|
||
label(row, hint, font=UI_SMALL, fg=FAINT, wraplength=430).pack(
|
||
side="left", padx=12)
|
||
self.tune[name] = variable
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(6, 0))
|
||
label(row, "Hold notes", font=UI, width=11).pack(side="left")
|
||
self.hold_notes = tk.BooleanVar(value=bool(self.piano["detect"].get("hold_notes")))
|
||
tk.Checkbutton(row, variable=self.hold_notes, bg=PANEL, fg=ACCENT,
|
||
activebackground=PANEL, activeforeground=ACCENT, selectcolor=FIELD,
|
||
relief="flat", bd=0, highlightthickness=0,
|
||
command=self._save_tuning).pack(side="left")
|
||
label(row, "keep the key down while the note is still under the sampler. "
|
||
"Off means every note is a tap",
|
||
font=UI_SMALL, fg=FAINT, wraplength=430).pack(side="left", padx=12)
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(6, 0))
|
||
label(row, "Match note", font=UI, width=11).pack(side="left")
|
||
self.match_note = tk.BooleanVar(
|
||
value=any(lane.get("note") for lane in self._lanes()))
|
||
tk.Checkbutton(row, variable=self.match_note, bg=PANEL, fg=ACCENT,
|
||
activebackground=PANEL, activeforeground=ACCENT, selectcolor=FIELD,
|
||
relief="flat", bd=0, highlightthickness=0,
|
||
command=self._apply_match).pack(side="left")
|
||
label(row, "go by the note's own colour rather than by anything unlike the "
|
||
"track. Try this if a lane lights up while its key is held and "
|
||
"never lets go",
|
||
font=UI_SMALL, fg=FAINT, wraplength=430).pack(side="left", padx=12)
|
||
|
||
def _apply_match(self):
|
||
on = self.match_note.get()
|
||
lanes = self._lanes()
|
||
missing = [lane["key"] for lane in lanes if not lane.get("note_seen")]
|
||
for lane in lanes:
|
||
lane["note"] = list(lane["note_seen"]) if (on and lane.get("note_seen")) else None
|
||
config.save(piano.NAME, self.piano)
|
||
if on and missing:
|
||
self._log(f"no note colour known for {''.join(missing)} — watch the track "
|
||
f"during the song first, then turn this back on")
|
||
self._log("going by the note colour" if on else "going by anything unlike the track")
|
||
|
||
# -- event --------------------------------------------------------
|
||
def _page_event(self, page):
|
||
"""The way into the event, and the forty runs once you are in."""
|
||
label(page, "Getting to the event and back out of a song is clicking, and "
|
||
"clicking is a point you pick once. Nothing here is guessed and "
|
||
"nothing is hardcoded — a step with nothing picked is skipped and "
|
||
"said out loud, so a half-built route still walks as far as it "
|
||
"goes.", font=UI_SMALL, fg=DIM, wraplength=660,
|
||
bg=BG).pack(fill="x", pady=(0, 12))
|
||
self.event_note = label(page, "", font=UI_SMALL, fg=DIM, bg=BG)
|
||
self.event_note.pack(fill="x", pady=(0, 8))
|
||
self.event_holder = tk.Frame(page, bg=BG)
|
||
self.event_holder.pack(fill="both", expand=True)
|
||
self.event_picks = {}
|
||
self._refresh_event()
|
||
|
||
def _refresh_event(self):
|
||
"""Draw the routes and the run grid from the config as it stands."""
|
||
for child in self.event_holder.winfo_children():
|
||
child.destroy()
|
||
self.event_picks = {}
|
||
self.piano.setdefault("routes", {})
|
||
self.piano.setdefault("session", {})
|
||
self.piano.setdefault("finish", {})
|
||
self._route_card(self.event_holder)
|
||
self._ending_card(self.event_holder)
|
||
self._runs_card(self.event_holder)
|
||
|
||
# ---------------------------------------------------------- pieces
|
||
def _save_piano(self):
|
||
config.save(piano.NAME, self.piano)
|
||
self._ready_shown = None
|
||
|
||
def _edited(self, box, save):
|
||
box.bind("<Return>", lambda _event: save())
|
||
box.bind("<FocusOut>", lambda _event: save())
|
||
return box
|
||
|
||
def _number(self, parent, read, write, width=5, whole=False):
|
||
"""A number you can type over. Saved when you leave it or press Enter."""
|
||
variable = tk.StringVar(value=_short(read()))
|
||
|
||
def save():
|
||
try:
|
||
write(int(float(variable.get())) if whole else float(variable.get()))
|
||
self._save_piano()
|
||
except ValueError:
|
||
self._log(f"{variable.get()!r} is not a number")
|
||
variable.set(_short(read()))
|
||
|
||
return self._edited(entry(parent, variable, width=width), save)
|
||
|
||
def _text(self, parent, read, write, width=18):
|
||
variable = tk.StringVar(value=str(read() or ""))
|
||
|
||
def save():
|
||
write(variable.get().strip())
|
||
self._save_piano()
|
||
|
||
return self._edited(entry(parent, variable, width=width), save)
|
||
|
||
def _point_row(self, parent, title, key, at, hint="", extra=None):
|
||
"""One picked point: what it is, where it is, and a button to set it."""
|
||
row = tk.Frame(parent, bg=PANEL)
|
||
row.pack(fill="x", pady=2)
|
||
label(row, title, font=UI, width=16).pack(side="left")
|
||
label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO,
|
||
fg=TEXT if at else WARN, width=12).pack(side="left")
|
||
pick = button(row, "Pick", lambda: self._arm_pick(f"event:{key}"), kind="ghost")
|
||
pick.plain = "Pick"
|
||
pick.pack(side="left")
|
||
self.event_picks[f"event:{key}"] = pick
|
||
if extra:
|
||
extra(row)
|
||
if hint:
|
||
label(row, hint, font=UI_SMALL, fg=FAINT).pack(side="left", padx=10)
|
||
return row
|
||
|
||
# ----------------------------------------------------------- routes
|
||
ROUTES = [("into", "the way in",
|
||
"Events → Special Events → scroll → the event → Play"),
|
||
("back", "back to the list", "off the results screen and onto the songs")]
|
||
|
||
def _route_card(self, page):
|
||
body = card(page, "routes")
|
||
label(body, "Pick a step, watch it land, then pick the next. Walk it runs the "
|
||
"route once without playing anything, which is the whole point of "
|
||
"having it separate.", font=UI_SMALL, fg=DIM,
|
||
wraplength=640).pack(fill="x", pady=(0, 4))
|
||
for which, title, hint in self.ROUTES:
|
||
steps = self.piano["routes"].setdefault(which, [])
|
||
head = tk.Frame(body, bg=PANEL)
|
||
head.pack(fill="x", pady=(12, 4))
|
||
label(head, title, font=UI_BOLD).pack(side="left")
|
||
label(head, hint, font=UI_SMALL, fg=FAINT).pack(side="left", padx=9)
|
||
button(head, "Walk it", lambda w=which: self._walk_route(w)).pack(side="right")
|
||
if not steps:
|
||
label(body, "no steps — add one below", font=UI_SMALL,
|
||
fg=FAINT).pack(fill="x")
|
||
for index, step in enumerate(steps):
|
||
self._step_row(body, which, index, step)
|
||
add = tk.Frame(body, bg=PANEL)
|
||
add.pack(fill="x", pady=(5, 0))
|
||
for kind in ("click", "scroll", "key", "wait"):
|
||
button(add, f"+ {kind}", lambda w=which, k=kind: self._add_step(w, k),
|
||
kind="ghost").pack(side="left", padx=(0, 5))
|
||
|
||
def _step_row(self, parent, which, index, step):
|
||
row = tk.Frame(parent, bg=PANEL)
|
||
row.pack(fill="x", pady=2)
|
||
kind = step.get("do", "click")
|
||
label(row, f"{index + 1}.", font=UI_SMALL, fg=FAINT, width=3).pack(side="left")
|
||
label(row, kind, font=UI_SMALL, fg=DIM, width=6).pack(side="left")
|
||
self._text(row, lambda: step.get("note"),
|
||
lambda value: step.__setitem__("note", value), width=20).pack(side="left")
|
||
|
||
if kind in ("click", "scroll"):
|
||
at = step.get("at")
|
||
label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO,
|
||
fg=TEXT if at else WARN, width=12).pack(side="left", padx=(8, 4))
|
||
key = f"event:route:{which}:{index}"
|
||
pick = button(row, "Pick", lambda k=key: self._arm_pick(k), kind="ghost")
|
||
pick.plain = "Pick"
|
||
pick.pack(side="left")
|
||
self.event_picks[key] = pick
|
||
elif kind == "key":
|
||
self._text(row, lambda: step.get("key") or "e",
|
||
lambda value: step.__setitem__("key", value or "e"),
|
||
width=4).pack(side="left", padx=(8, 4))
|
||
else:
|
||
label(row, "", width=12).pack(side="left", padx=(8, 4))
|
||
|
||
if kind == "scroll":
|
||
label(row, "clicks", font=UI_SMALL, fg=FAINT).pack(side="left", padx=(10, 3))
|
||
self._number(row, lambda: step.get("amount", -5),
|
||
lambda value: step.__setitem__("amount", value),
|
||
width=4, whole=True).pack(side="left")
|
||
label(row, "then wait", font=UI_SMALL, fg=FAINT).pack(side="left", padx=(10, 3))
|
||
self._number(row, lambda: step.get("after", 0.8),
|
||
lambda value: step.__setitem__("after", value),
|
||
width=4).pack(side="left")
|
||
button(row, "✕", lambda w=which, i=index: self._drop_step(w, i),
|
||
kind="ghost").pack(side="right")
|
||
|
||
def _add_step(self, which, kind):
|
||
step = {"do": kind, "after": 1.0, "note": ""}
|
||
if kind in ("click", "scroll"):
|
||
step["at"] = None
|
||
if kind == "scroll":
|
||
step["amount"] = -5
|
||
if kind == "key":
|
||
step["key"] = "e"
|
||
self.piano["routes"].setdefault(which, []).append(step)
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
self._log(f"{which}: added a {kind} step — pick where it goes")
|
||
|
||
def _drop_step(self, which, index):
|
||
steps = self.piano["routes"].get(which) or []
|
||
if index < len(steps):
|
||
gone = steps.pop(index)
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
self._log(f"{which}: dropped step {index + 1} "
|
||
f"({gone.get('note') or gone.get('do')})")
|
||
|
||
def _walk_route(self, which):
|
||
"""Run one route once, so it can be checked without playing a song."""
|
||
if self._busy():
|
||
return
|
||
steps = (self.piano.get("routes") or {}).get(which) or []
|
||
if not piano.picked(steps):
|
||
self._log(f"nothing picked in the {which} route yet")
|
||
return
|
||
signals = Signals()
|
||
runner = Runner(f"route {which}", self.piano["hotkeys"], signals=signals)
|
||
|
||
def once():
|
||
piano.walk(runner, steps, f"walking the {which} route —")
|
||
runner.stopped = True
|
||
|
||
self._spawn("play", lambda: runner.loop(once), runner=runner, signals=signals)
|
||
self._launch(signals, "pause")
|
||
|
||
# ------------------------------------------------------- the ending
|
||
def _ending_card(self, page):
|
||
body = card(page, "the end of a song")
|
||
finish = self.piano["finish"]
|
||
label(body, "A song is over when the track stops changing — which is also what "
|
||
"a long rest in the middle of a chart looks like, so the window is "
|
||
"a few seconds rather than half of one.\n\n"
|
||
"If the event has a timer on screen, point at it and that question "
|
||
"is settled: a clock counts while the song runs and stops when it "
|
||
"ends, and there is no colour to match or threshold to set. Watch "
|
||
"it says whether it is ticking right now.",
|
||
font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 8))
|
||
|
||
clock = finish.get("clock") or {}
|
||
|
||
def watching(row):
|
||
button(row, "Watch it", self._test_timer, kind="ghost").pack(side="left")
|
||
button(row, "Clear", self._clear_timer, kind="ghost").pack(side="left", padx=4)
|
||
|
||
self._point_row(body, "Song timer", "clock", clock.get("at"), extra=watching,
|
||
hint="the counting numbers, not the bar around them")
|
||
|
||
probe = finish.get("probe") or {}
|
||
|
||
def swatch(row):
|
||
patch = tk.Frame(row, bg=_hex(probe.get("color")), width=22, height=18,
|
||
highlightthickness=1 if probe.get("color") else 0,
|
||
highlightbackground=LINE)
|
||
patch.pack(side="left", padx=8)
|
||
patch.pack_propagate(False)
|
||
button(row, "Test", self._test_end_probe, kind="ghost").pack(side="left")
|
||
button(row, "Clear", self._clear_end_probe, kind="ghost").pack(side="left", padx=4)
|
||
|
||
self._point_row(body, "Results pixel", "probe", probe.get("at"), extra=swatch,
|
||
hint="optional, and the timer is easier")
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(8, 0))
|
||
for name, title, hint in (
|
||
("quiet_seconds", "still for", "s of an unchanging track ends a song"),
|
||
("min_seconds", "not before", "s in — the count-in is still and quiet too"),
|
||
("max_seconds", "give up after", "s, whatever the screen says")):
|
||
cell = tk.Frame(row, bg=PANEL)
|
||
cell.pack(fill="x", pady=2)
|
||
label(cell, title, font=UI, width=14).pack(side="left")
|
||
self._number(cell, lambda n=name: self.piano["finish"].get(n, 0),
|
||
lambda value, n=name: self.piano["finish"].__setitem__(n, value)
|
||
).pack(side="left")
|
||
label(cell, hint, font=UI_SMALL, fg=FAINT).pack(side="left", padx=10)
|
||
|
||
def _test_timer(self):
|
||
"""Two seconds of looking at the clock, to see whether it is counting."""
|
||
clock = (self.piano.get("finish") or {}).get("clock") or {}
|
||
if not clock.get("at"):
|
||
self._log("no timer picked yet")
|
||
return
|
||
screen.focus_roblox()
|
||
self._log("watching the timer for 2s —")
|
||
self._count(3, self._time_the_timer)
|
||
|
||
def _time_the_timer(self):
|
||
if not screen.roblox_focused():
|
||
self._log("Roblox is not the front window — that would have watched this "
|
||
"window instead of the game")
|
||
return
|
||
clock = self.piano["finish"]["clock"]
|
||
timer = piano.Timer(clock["at"])
|
||
started = time.perf_counter()
|
||
while time.perf_counter() - started < 2.0:
|
||
timer.look(time.perf_counter())
|
||
timer.close()
|
||
if timer.ticks:
|
||
self._log(f"the timer ticked {timer.ticks} times in 2s — a song ends "
|
||
f"{self.piano['finish'].get('quiet_seconds', 4):.0f}s after it "
|
||
f"stops")
|
||
else:
|
||
self._log("nothing changed there in 2s. If a song is playing, that is the "
|
||
"wrong spot — point at the numbers themselves")
|
||
|
||
def _clear_timer(self):
|
||
self.piano["finish"]["clock"] = None
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
self._log("timer cleared — songs will end on stillness alone")
|
||
|
||
def _test_end_probe(self):
|
||
probe = (self.piano.get("finish") or {}).get("probe")
|
||
if not probe or not probe.get("at"):
|
||
self._log("no results pixel picked yet")
|
||
return
|
||
x, y = probe["at"]
|
||
now = screen.pixel(x, y)
|
||
tolerance = int(self.piano["finish"].get("tolerance", 26))
|
||
hit = screen.close_enough(now, probe.get("color"), tolerance)
|
||
self._log(f"results pixel at {x},{y}: saved rgb{tuple(probe.get('color') or ())}, "
|
||
f"now rgb{now} — {'MATCH' if hit else 'no match'}")
|
||
|
||
def _clear_end_probe(self):
|
||
self.piano["finish"]["probe"] = None
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
self._log("results pixel cleared — songs will end on stillness alone")
|
||
|
||
# --------------------------------------------------------- the runs
|
||
def _runs_card(self, page):
|
||
body = card(page, "forty runs")
|
||
session = self.piano["session"]
|
||
songs = session.setdefault("songs", {})
|
||
points = songs.setdefault("points", [])
|
||
levels = session.setdefault("difficulties", [])
|
||
scrolls = int(songs.get("visible") or 0)
|
||
total = piano.song_count(songs) * max(1, len(levels))
|
||
label(body, "Every song at every difficulty, in order, remembering what it has "
|
||
"already played.", font=UI_SMALL, fg=DIM,
|
||
wraplength=640).pack(fill="x", pady=(0, 8))
|
||
|
||
head = tk.Frame(body, bg=PANEL)
|
||
head.pack(fill="x", pady=(4, 3))
|
||
label(head, "the song list", font=UI_BOLD).pack(side="left")
|
||
label(head, f"{len(points)} rows picked · {total} runs", font=UI_SMALL,
|
||
fg=FAINT).pack(side="left", padx=9)
|
||
if not points:
|
||
label(body, "no rows yet — add one per song below, or add two and space "
|
||
"the rest out between them", font=UI_SMALL,
|
||
fg=FAINT).pack(fill="x")
|
||
for index, at in enumerate(points):
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=1)
|
||
label(row, f"{index + 1}.", font=UI_SMALL, fg=FAINT, width=3).pack(side="left")
|
||
label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO,
|
||
fg=TEXT if at else WARN, width=12).pack(side="left")
|
||
key = f"event:songrow:{index}"
|
||
pick = button(row, "Pick", lambda k=key: self._arm_pick(k), kind="ghost")
|
||
pick.plain = "Pick"
|
||
pick.pack(side="left")
|
||
self.event_picks[key] = pick
|
||
button(row, "✕", lambda i=index: self._drop_song(i),
|
||
kind="ghost").pack(side="right")
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(6, 0))
|
||
button(row, "+ song", self._add_song, kind="ghost").pack(side="left", padx=(0, 5))
|
||
button(row, "Space them out", self._space_songs, kind="ghost").pack(side="left")
|
||
label(row, "fills the unpicked rows evenly between the first and the last",
|
||
font=UI_SMALL, fg=FAINT).pack(side="left", padx=9)
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(10, 2))
|
||
label(row, "of which", font=UI, width=9).pack(side="left")
|
||
self._number(row, lambda: songs.get("visible", 0),
|
||
lambda value: songs.__setitem__("visible", value),
|
||
width=4, whole=True).pack(side="left")
|
||
label(row, "fit on screen — 0 if they all do, and then there is no scrolling",
|
||
font=UI_SMALL, fg=FAINT).pack(side="left", padx=8)
|
||
if scrolls:
|
||
self._point_row(body, "Scroll the list at", "song:scroll_at",
|
||
songs.get("scroll_at"),
|
||
hint="where the cursor goes to use the wheel")
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=2)
|
||
label(row, "songs", font=UI, width=16).pack(side="left")
|
||
self._number(row, lambda: songs.get("count", 0),
|
||
lambda value: songs.__setitem__("count", value),
|
||
width=4, whole=True).pack(side="left")
|
||
label(row, "altogether, over every screenful", font=UI_SMALL,
|
||
fg=FAINT).pack(side="left", padx=8)
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=2)
|
||
label(row, "wheel clicks", font=UI, width=16).pack(side="left")
|
||
self._number(row, lambda: songs.get("top_clicks", 15),
|
||
lambda value: songs.__setitem__("top_clicks", value),
|
||
width=4, whole=True).pack(side="left")
|
||
label(row, "back to the top,", font=UI_SMALL, fg=FAINT).pack(side="left", padx=6)
|
||
self._number(row, lambda: songs.get("page_clicks", -3),
|
||
lambda value: songs.__setitem__("page_clicks", value),
|
||
width=4, whole=True).pack(side="left")
|
||
label(row, "for one screenful down", font=UI_SMALL,
|
||
fg=FAINT).pack(side="left", padx=6)
|
||
|
||
head = tk.Frame(body, bg=PANEL)
|
||
head.pack(fill="x", pady=(14, 3))
|
||
label(head, "difficulties", font=UI_BOLD).pack(side="left")
|
||
label(head, "clicked once the song is chosen", font=UI_SMALL,
|
||
fg=FAINT).pack(side="left", padx=9)
|
||
for index, level in enumerate(levels):
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=2)
|
||
label(row, f"{index + 1}.", font=UI_SMALL, fg=FAINT, width=3).pack(side="left")
|
||
self._text(row, lambda l=level: l.get("name"),
|
||
lambda value, l=level: l.__setitem__("name", value),
|
||
width=10).pack(side="left")
|
||
self._number(row, lambda l=level: 1 if l.get("clicks") is None else l["clicks"],
|
||
lambda value, l=level: l.__setitem__("clicks", value),
|
||
width=3, whole=True).pack(side="left", padx=(8, 4))
|
||
label(row, "clicks at", font=UI_SMALL, fg=FAINT).pack(side="left")
|
||
at = level.get("at")
|
||
label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO,
|
||
fg=TEXT if at else WARN, width=12).pack(side="left", padx=6)
|
||
key = f"event:diff:{index}"
|
||
pick = button(row, "Pick", lambda k=key: self._arm_pick(k), kind="ghost")
|
||
pick.plain = "Pick"
|
||
pick.pack(side="left")
|
||
self.event_picks[key] = pick
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(6, 0))
|
||
button(row, "One button that cycles", self._cycle_difficulty,
|
||
kind="ghost").pack(side="left")
|
||
label(row, "same point for all four, clicked 0, 1, 2, 3 times. If the panel "
|
||
"remembers the last difficulty instead of starting at the first, "
|
||
"make those 0, 1, 1, 1", font=UI_SMALL, fg=FAINT,
|
||
wraplength=430).pack(side="left", padx=9)
|
||
|
||
self._point_row(body, "Play button", "play", session.get("play"),
|
||
hint="on the song panel, if it has its own")
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(12, 2))
|
||
label(row, "when a song ends", font=UI, width=16).pack(side="left")
|
||
self.after_song = Segmented(row, (("list", "you are on the song list"),
|
||
("out", "you are outside the event")),
|
||
session.get("after_song") or "list",
|
||
command=self._save_after_song)
|
||
self.after_song.pack(side="left")
|
||
label(body, "Outside means the way in gets walked again before every run, "
|
||
"which is slower and is simply what some events do.",
|
||
font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(2, 0))
|
||
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=(12, 0))
|
||
done = piano.load_progress().get("done") or {}
|
||
label(row, f"{len(done)} of {total} runs done", font=UI_BOLD,
|
||
fg=ACCENT if done else DIM).pack(side="left")
|
||
button(row, "Forget them", self._reset_progress, kind="ghost").pack(side="right")
|
||
label(body, "Home page → Full session → START. Stopping is safe: it "
|
||
"picks up at the run it was on.", font=UI_SMALL, fg=FAINT,
|
||
wraplength=640).pack(fill="x", pady=(6, 0))
|
||
|
||
def _save_after_song(self, where):
|
||
self.piano["session"]["after_song"] = where
|
||
self._save_piano()
|
||
self._log("after a song: back on the song list" if where == "list"
|
||
else "after a song: outside the event, so the way in is walked again")
|
||
|
||
def _song_points(self):
|
||
return self.piano["session"].setdefault("songs", {}).setdefault("points", [])
|
||
|
||
def _add_song(self):
|
||
self._song_points().append(None)
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
|
||
def _drop_song(self, index):
|
||
points = self._song_points()
|
||
if index < len(points):
|
||
points.pop(index)
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
self._log(f"dropped song row {index + 1}")
|
||
|
||
def _space_songs(self):
|
||
"""Fill the unpicked rows evenly between the first picked and the last."""
|
||
points = self._song_points()
|
||
placed = [(index, point) for index, point in enumerate(points) if point]
|
||
if len(placed) < 2:
|
||
self._log("pick the first and last rows first — then this fills the middle")
|
||
return
|
||
(top, first), (bottom, last) = placed[0], placed[-1]
|
||
spread = piano.between(first, last, bottom - top + 1)
|
||
filled = 0
|
||
for offset, point in enumerate(spread):
|
||
if not points[top + offset]:
|
||
points[top + offset] = point
|
||
filled += 1
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
self._log(f"spaced {filled} rows out between {tuple(first)} and {tuple(last)}"
|
||
if filled else "every row between those two was already picked")
|
||
|
||
def _cycle_difficulty(self):
|
||
"""One button that goes easy → medium → hard → expert as you click it."""
|
||
levels = self.piano["session"].setdefault("difficulties", [])
|
||
at = next((level["at"] for level in levels if level.get("at")), None)
|
||
if not at:
|
||
self._log("pick the difficulty button on one of those rows first")
|
||
return
|
||
for index, level in enumerate(levels):
|
||
level["at"] = list(at)
|
||
level["clicks"] = index
|
||
self._save_piano()
|
||
self._refresh_event()
|
||
self._log(f"all four difficulties go through {tuple(at)} — "
|
||
f"{', '.join(str(index) for index in range(len(levels)))} clicks")
|
||
|
||
def _reset_progress(self):
|
||
if not messagebox.askyesno(
|
||
"KittyMacro", "Forget which songs have been played?\n\n"
|
||
"The next session starts again at the first song."):
|
||
return
|
||
piano.reset_progress()
|
||
self._log("progress forgotten — the next session starts at song 1")
|
||
self._refresh_event()
|
||
|
||
# -------------------------------------------------------- capturing
|
||
def _capture_event(self, what, x, y, colour):
|
||
"""A picked point, filed where the piano config keeps that kind."""
|
||
session = self.piano.setdefault("session", {})
|
||
parts = what.split(":")
|
||
if parts[0] == "route":
|
||
which, index = parts[1], int(parts[2])
|
||
steps = self.piano["routes"].get(which) or []
|
||
if index < len(steps):
|
||
steps[index]["at"] = [x, y]
|
||
self._log(f"{which} step {index + 1} "
|
||
f"({steps[index].get('note') or steps[index].get('do')}): {x},{y}")
|
||
elif parts[0] == "songrow":
|
||
points = session.setdefault("songs", {}).setdefault("points", [])
|
||
index = int(parts[1])
|
||
if index < len(points):
|
||
points[index] = [x, y]
|
||
self._log(f"song {index + 1}: {x},{y}")
|
||
elif parts[0] == "song":
|
||
session.setdefault("songs", {})[parts[1]] = [x, y]
|
||
self._log(f"song list {parts[1].replace('_', ' ')}: {x},{y}")
|
||
elif parts[0] == "diff":
|
||
levels = session.setdefault("difficulties", [])
|
||
index = int(parts[1])
|
||
if index < len(levels):
|
||
levels[index]["at"] = [x, y]
|
||
self._log(f"{levels[index].get('name') or index + 1}: {x},{y}")
|
||
elif parts[0] == "play":
|
||
session["play"] = [x, y]
|
||
self._log(f"play button: {x},{y}")
|
||
elif parts[0] == "clock":
|
||
self.piano["finish"]["clock"] = {"at": [x, y]}
|
||
self._log(f"song timer: {x},{y} — press Watch it during a song to check")
|
||
elif parts[0] == "probe":
|
||
self.piano["finish"]["probe"] = {"at": [x, y], "color": list(colour or ())}
|
||
self._log(f"results pixel: {x},{y} rgb{colour}")
|
||
self._save_piano()
|
||
self._finish_pick()
|
||
self._refresh_event()
|
||
|
||
# ------------------------------------------------------------- lanes
|
||
def _lanes(self):
|
||
return self.piano.get("lanes") or []
|
||
|
||
def _lay_out_lanes(self):
|
||
keys = self.lane_keys.get().strip().lower()
|
||
if not keys:
|
||
self._log("type the key for each lane first, left to right")
|
||
return
|
||
unknown = sorted({key for key in keys if key not in winput.KEYS})
|
||
if unknown:
|
||
self._log(f"not keys this can press: {', '.join(repr(k) for k in unknown)} "
|
||
f"— one character per lane, no spaces")
|
||
return
|
||
left, right = self.ends.get("left"), self.ends.get("right")
|
||
if not (left and right):
|
||
self._log("pick both ends of the hit line")
|
||
return
|
||
self.piano["lanes"] = piano.spread(keys, left, right)
|
||
config.save(piano.NAME, self.piano)
|
||
self._rebuild_region()
|
||
self._log(f"{len(self.piano['lanes'])} lanes: " +
|
||
", ".join(f"{lane['key']}@{lane['at'][0]},{lane['at'][1]}"
|
||
for lane in self.piano["lanes"]))
|
||
self._log("now press Watch the track, with the song playing")
|
||
self.ends.clear()
|
||
self._ready_shown = None
|
||
|
||
WATCHING = 6.0
|
||
TRACING = 5.0
|
||
SCANNING = 5.0
|
||
|
||
def _snapshot(self):
|
||
"""Save a picture of the track, because looking beats deducing."""
|
||
if not self._lanes():
|
||
self._log("no lanes yet — pick one point on the hit line first")
|
||
return
|
||
screen.focus_roblox()
|
||
self._log("snapshot of the track in —")
|
||
self._count(3, self._snapshot_now)
|
||
|
||
def _snapshot_now(self):
|
||
if not screen.roblox_focused():
|
||
self._log("Roblox is not the front window — click it and try again")
|
||
return
|
||
lanes = self._lanes()
|
||
xs = [lane["at"][0] for lane in lanes]
|
||
row = sorted(lane["at"][1] for lane in lanes)[len(lanes) // 2]
|
||
width, height = screen.screen_size()
|
||
left = max(0, min(xs) - 220)
|
||
right = min(width - 1, max(xs) + 220)
|
||
top = max(0, row - 320)
|
||
bottom = min(height - 1, row + 90)
|
||
shot = screen.Region(left, top, right - left + 1, bottom - top + 1)
|
||
try:
|
||
if not shot.read():
|
||
self._log("could not read the screen there")
|
||
return
|
||
path = shot.png(config.CONFIG_DIR / "snapshot.png")
|
||
finally:
|
||
shot.close()
|
||
self._log(f"saved {path}")
|
||
self._log(f" it covers x {left}–{right}, y {top}–{bottom}; "
|
||
f"the hit row y={row} is {row - top}px down it")
|
||
|
||
ROWS = 12 # px between the rows it tries
|
||
COLUMNS = 4 # px between the columns it watches
|
||
ABOVE = 460 # how far up the track to look
|
||
BELOW = 60 # and how far below the row you pointed at
|
||
|
||
def _scan_band(self):
|
||
"""The patch of screen to search: around the lanes, and well above them."""
|
||
width, height = screen.screen_size()
|
||
lanes = self._lanes()
|
||
points = ([tuple(lane["at"]) for lane in lanes] or list(self.ends.values()))
|
||
if not points:
|
||
return None
|
||
xs = [x for x, _y in points]
|
||
row = sorted(y for _x, y in points)[len(points) // 2]
|
||
left = max(0, min(xs) - 260)
|
||
right = min(width - 1, max(xs) + 260)
|
||
top = max(0, row - self.ABOVE)
|
||
bottom = min(height - 1, row + self.BELOW)
|
||
return left, top, right - left + 1, bottom - top + 1
|
||
|
||
def _find_lanes(self):
|
||
"""Let the picture say where the lanes are."""
|
||
keys = self.lane_keys.get().strip().lower()
|
||
if not keys:
|
||
self._log("type the key for each lane first, left to right")
|
||
return
|
||
unknown = sorted({key for key in keys if key not in winput.KEYS})
|
||
if unknown:
|
||
self._log(f"not keys this can press: {', '.join(repr(k) for k in unknown)}")
|
||
return
|
||
band = self._scan_band()
|
||
if band is None:
|
||
self._log("pick one point on a lane first, anywhere on it, so it knows "
|
||
"roughly where to look")
|
||
return
|
||
if getattr(self, "watching", False):
|
||
return
|
||
self.scan_keys = keys
|
||
self.band = band
|
||
left, top, width, height = band
|
||
screen.focus_roblox()
|
||
self._log(f"watching x {left}–{left + width - 1}, y {top}–{top + height - 1} "
|
||
f"for {self.SCANNING:.0f}s — let the song play")
|
||
self._count(3, self._scan_now)
|
||
|
||
def _scan_now(self):
|
||
if not screen.roblox_focused():
|
||
self._log("Roblox is not the front window — click it and try again")
|
||
return
|
||
left, top, width, height = self.band
|
||
self.scan_region = screen.Region(left, top, width, height)
|
||
self.scan_columns = list(range(0, width, self.COLUMNS))
|
||
self.scan_rows = list(range(0, height, self.ROWS))
|
||
self.scan_low = [[10 ** 9] * len(self.scan_columns) for _ in self.scan_rows]
|
||
self.scan_high = [[-1] * len(self.scan_columns) for _ in self.scan_rows]
|
||
self.watching = True
|
||
self.btn_watch.config(state="disabled")
|
||
self.scan_until = time.monotonic() + self.SCANNING
|
||
self.scans = 0
|
||
self._scan()
|
||
|
||
def _scan(self):
|
||
if not screen.roblox_focused():
|
||
self._stop_watching()
|
||
self._log("Roblox went behind — nothing found. Try again with the game up.")
|
||
return
|
||
_left, _top, width, _height = self.band
|
||
if self.scan_region.read():
|
||
self.scans += 1
|
||
# One copy out of the bitmap, then plain bytes indexing: reaching into the ctypes buffer.
|
||
picture = bytes(self.scan_region.pixels)
|
||
for index, row in enumerate(self.scan_rows):
|
||
start = row * width * 4
|
||
low, high = self.scan_low[index], self.scan_high[index]
|
||
for column, x in enumerate(self.scan_columns):
|
||
base = start + x * 4
|
||
value = picture[base] + picture[base + 1] + picture[base + 2]
|
||
if value < low[column]:
|
||
low[column] = value
|
||
if value > high[column]:
|
||
high[column] = value
|
||
left = self.scan_until - time.monotonic()
|
||
if left > 0:
|
||
self.lane_note.config(text=f"looking for the lanes… {left:.1f}s to go",
|
||
fg=ACCENT)
|
||
self.after(1, self._scan)
|
||
else:
|
||
self._report_find()
|
||
|
||
def _report_find(self):
|
||
self._stop_watching()
|
||
self.scan_region.close()
|
||
keys = self.scan_keys
|
||
left, top, _width, _height = self.band
|
||
columns = [left + x for x in self.scan_columns]
|
||
rows = [top + y for y in self.scan_rows]
|
||
movements = [[high - low for low, high in zip(low_row, high_row)]
|
||
for low_row, high_row in zip(self.scan_low, self.scan_high)]
|
||
self._log(f"{self.scans} looks at {len(rows)} rows")
|
||
|
||
best = piano.pick_row(rows, columns, movements, want=len(keys))
|
||
if best is None:
|
||
# Nothing worked; show the busiest row so there is something to read.
|
||
busiest = max(range(len(rows)), key=lambda i: sum(movements[i]))
|
||
found = piano.find_lanes(columns, movements[busiest], want=len(keys))
|
||
self._log(f"no row showed all {len(keys)} lanes at once. The busiest was "
|
||
f"y={rows[busiest]}, where it found {len(found)}:")
|
||
self._log(" " + piano.profile(columns, movements[busiest]))
|
||
self._log(" " + piano.marks(columns, found))
|
||
self._log("Try again during a busier part of the song, or press Snapshot "
|
||
"and look at where the lanes really are.")
|
||
return
|
||
|
||
row, found = best
|
||
self._log(f"lowest row where all {len(keys)} lanes are visible: y={row}")
|
||
self._log(" " + piano.profile(columns, movements[rows.index(row)]))
|
||
self._log(" " + piano.marks(columns, found))
|
||
for lane in found:
|
||
self._log(f" {lane['left']}–{lane['right']} centre {lane['centre']} "
|
||
f"moved {lane['score']}")
|
||
|
||
self.piano["lanes"] = [{"key": key, "at": [lane["centre"], row],
|
||
"empty": None, "note": None}
|
||
for key, lane in zip(keys, found)]
|
||
config.save(piano.NAME, self.piano)
|
||
self._rebuild_region()
|
||
self._ready_shown = None
|
||
self._log("lanes: " + ", ".join(
|
||
f"{lane['key']}@{lane['at'][0]}" for lane in self.piano["lanes"]))
|
||
self._log("now learning their colours —")
|
||
self.after(700, self._sample_empty)
|
||
|
||
|
||
def _trace(self):
|
||
"""Watch, but report instead of learning."""
|
||
if not self._lanes():
|
||
self._log("no lanes yet")
|
||
return
|
||
if getattr(self, "watching", False):
|
||
return
|
||
self.collect_then = self._report_trace
|
||
self.watch_for = self.TRACING
|
||
screen.focus_roblox()
|
||
self._log(f"tracing the lanes for {self.TRACING:.0f}s — play as you would, "
|
||
f"or let the macro play")
|
||
self._count(3, self._watch_now)
|
||
|
||
def _report_trace(self):
|
||
self._stop_watching()
|
||
threshold = int(self.piano["detect"]["threshold"])
|
||
scans = len(self.seen[0]) if self.seen else 0
|
||
each = self.watch_for / scans if scans else 0
|
||
self._log(f"traced {scans} scans, {each * 1000:.1f}ms apart")
|
||
for lane, colours in zip(self._lanes(), self.seen):
|
||
runs = piano.timeline(colours, tuple(lane.get("empty") or ()),
|
||
tuple(lane.get("note_seen") or ()), threshold)
|
||
share = {}
|
||
for what, count, _middle in runs:
|
||
share[what] = share.get(what, 0) + count
|
||
summary = " ".join(f"{what} {count / max(scans, 1):.0%}"
|
||
for what, count in sorted(share.items()))
|
||
self._log(f" {lane['key']}: {summary}")
|
||
for what, count, middle in runs[:8]:
|
||
self._log(f" {what:<10} {count * each:5.2f}s "
|
||
f"{middle if what != 'track' else ''}")
|
||
if len(runs) > 8:
|
||
self._log(f" … and {len(runs) - 8} more")
|
||
|
||
def _sample_empty(self):
|
||
"""Watch a few seconds of the song, and work the colours out from it."""
|
||
if not self._lanes():
|
||
self._log("no lanes yet — pick both ends of the hit line first")
|
||
return
|
||
if getattr(self, "watching", False):
|
||
return
|
||
self.collect_then = self._learn
|
||
self.watch_for = self.WATCHING
|
||
screen.focus_roblox()
|
||
self._log(f"watching the track for {self.WATCHING:.0f}s — let the song play")
|
||
self._count(3, self._watch_now)
|
||
|
||
def _watch_now(self):
|
||
if not screen.roblox_focused():
|
||
self._log("Roblox is not the front window — that would have watched this "
|
||
"window instead of the game. Click Roblox, then press it again.")
|
||
return
|
||
self._rebuild_region()
|
||
self.watching = True
|
||
self.btn_watch.config(text="watching…", state="disabled")
|
||
self.seen = [[] for _ in self._lanes()]
|
||
self.watch_until = time.monotonic() + self.watch_for
|
||
self._collect()
|
||
|
||
def _collect(self):
|
||
lanes = self._lanes()
|
||
if not screen.roblox_focused():
|
||
self._stop_watching()
|
||
self._log("Roblox went behind — nothing learnt. Try again with the game up.")
|
||
return
|
||
for _ in range(4):
|
||
if self.lane_region.read():
|
||
for index, lane in enumerate(lanes):
|
||
self.seen[index].append(self.lane_region.at(*lane["at"]))
|
||
left = self.watch_until - time.monotonic()
|
||
if left > 0:
|
||
self.lane_note.config(text=f"reading the lanes… {left:.1f}s to go", fg=ACCENT)
|
||
self.after(5, self._collect)
|
||
else:
|
||
self.collect_then()
|
||
|
||
def _stop_watching(self):
|
||
self.watching = False
|
||
self.btn_watch.config(text="Watch the track", state="normal")
|
||
self.lane_note.config(text=f"{len(self._lanes())} lanes on the hit line", fg=DIM)
|
||
|
||
def _learn(self):
|
||
self._stop_watching()
|
||
lanes = self._lanes()
|
||
scans = len(self.seen[0]) if self.seen else 0
|
||
found = piano.study(self.seen)
|
||
self._log(f"watched {scans} scans of each lane")
|
||
|
||
for lane, learnt in zip(lanes, found["lanes"]):
|
||
if learnt["suspect"] or not learnt["empty"]:
|
||
self._log(f" {lane['key']}: under something {learnt['covered']:.0%} of "
|
||
f"the time — cannot tell track from note here, left alone")
|
||
continue
|
||
lane["empty"] = list(learnt["empty"])
|
||
lane["note_seen"] = list(learnt["note"]) if learnt["note"] else None
|
||
if lane.get("note") and lane["note_seen"]:
|
||
lane["note"] = list(lane["note_seen"])
|
||
note = f"note {tuple(learnt['note'])} at {learnt['distance']} away" \
|
||
if learnt["note"] else "no notes crossed it"
|
||
self._log(f" {lane['key']}: track {tuple(learnt['empty'])}, {note}"
|
||
f"{' (turned back round)' if learnt['repaired'] else ''}, "
|
||
f"covered {learnt['covered']:.0%}")
|
||
|
||
if found["threshold"]:
|
||
self.piano["detect"]["threshold"] = found["threshold"]
|
||
self.tune["threshold"].set(str(found["threshold"]))
|
||
self._log(f"threshold {found['threshold']} — noise reaches "
|
||
f"{found['noise']}, the faintest note is {found['nearest']} away")
|
||
elif any(lane["suspect"] for lane in found["lanes"]):
|
||
self._log("watch it again over a stretch with gaps in it, or set the "
|
||
"threshold by hand from the \u0394 numbers")
|
||
else:
|
||
self._log("no notes crossed the line while watching — the track colours are "
|
||
"saved, but watch again during the song to set a threshold")
|
||
|
||
config.save(piano.NAME, self.piano)
|
||
self._ready_shown = None
|
||
self.lane_note.config(text=f"{len(lanes)} lanes on the hit line", fg=DIM)
|
||
|
||
def _rebuild_region(self):
|
||
if self.lane_region is not None:
|
||
self.lane_region.close()
|
||
self.lane_region = None
|
||
lanes = self._lanes()
|
||
if lanes:
|
||
self.lane_region = screen.around([lane["at"] for lane in lanes])
|
||
self.lane_cells = []
|
||
self.lane_picks = []
|
||
for child in self.lane_strip.winfo_children():
|
||
child.destroy()
|
||
for index, lane in enumerate(lanes):
|
||
cell = tk.Frame(self.lane_strip, bg=FIELD, highlightthickness=1,
|
||
highlightbackground=LINE)
|
||
cell.pack(side="left", expand=True, fill="both", padx=2)
|
||
key = label(cell, (lane.get("key") or "click").upper(), font=UI_BOLD,
|
||
bg=FIELD, anchor="center")
|
||
key.pack(fill="x", pady=(6, 2))
|
||
swatch = tk.Frame(cell, bg=PANEL, height=16)
|
||
swatch.pack(fill="x", padx=6)
|
||
delta = label(cell, "\u0394 —", font=MONO, fg=DIM, bg=FIELD, anchor="center")
|
||
delta.pack(fill="x", pady=(3, 4))
|
||
pick = button(cell, "pick", lambda i=index: self._arm_pick(f"lane:{i}"),
|
||
kind="ghost")
|
||
pick.plain = "pick"
|
||
pick.config(bg=FIELD, padx=6, pady=2, font=UI_SMALL)
|
||
pick.pack(pady=(0, 7))
|
||
self.lane_picks.append(pick)
|
||
self.lane_cells.append((cell, key, swatch, delta))
|
||
self.lane_note.config(
|
||
text=(f"{len(lanes)} lanes on the hit line" if lanes
|
||
else "no lanes yet — type the keys, then pick both ends"))
|
||
|
||
def _in_the_way(self, points) -> bool:
|
||
"""Is this window sitting over the lanes, with the game behind it?"""
|
||
if screen.roblox_focused():
|
||
return False
|
||
left, top = self.winfo_rootx(), self.winfo_rooty()
|
||
right, bottom = left + self.winfo_width(), top + self.winfo_height()
|
||
return any(left <= x <= right and top <= y <= bottom for x, y in points)
|
||
|
||
def _show_lanes(self):
|
||
lanes = self._lanes()
|
||
if len(lanes) != len(self.lane_cells):
|
||
self._rebuild_region()
|
||
return
|
||
if not lanes:
|
||
return
|
||
live = bool(self.task and self.task.is_alive() and self.playing == "piano")
|
||
stats = self.runner.stats if (live and self.runner) else {}
|
||
blocked = not live and self._in_the_way([lane["at"] for lane in lanes])
|
||
self.lane_note.config(
|
||
text="this window is covering the lanes — move it off them, or read these "
|
||
"with the game in front" if blocked
|
||
else f"{len(lanes)} lanes on the hit line",
|
||
fg=WARN if blocked else DIM)
|
||
colours = []
|
||
if not (live or blocked) and self.lane_region is not None and self.lane_region.read():
|
||
colours = [self.lane_region.at(*lane["at"]) for lane in lanes]
|
||
|
||
for index, lane in enumerate(lanes):
|
||
cell, key, swatch, delta = self.lane_cells[index]
|
||
empty = lane.get("empty") or ()
|
||
if colours:
|
||
colour = colours[index]
|
||
gap = (max(abs(int(a) - int(b)) for a, b in zip(colour, empty))
|
||
if colour and empty else None)
|
||
lit = gap is not None and gap > int(self.piano["detect"]["threshold"])
|
||
swatch.config(bg=_hex(colour))
|
||
delta.config(text=f"\u0394 {gap}" if gap is not None else "\u0394 —",
|
||
fg=ACCENT if lit else DIM)
|
||
elif blocked:
|
||
lit = False
|
||
swatch.config(bg=PANEL)
|
||
delta.config(text="\u0394 ?", fg=WARN)
|
||
else:
|
||
lit = bool(stats.get("lit", [False] * len(lanes))[index]) if stats else False
|
||
hits = (stats.get("hits") or [0] * len(lanes))[index]
|
||
swatch.config(bg=ACCENT if lit else PANEL)
|
||
delta.config(text=str(hits), fg=TEXT if lit else DIM)
|
||
cell.config(highlightbackground=ACCENT if lit else LINE)
|
||
key.config(fg=ACCENT if lit else TEXT)
|
||
|
||
def _save_tuning(self):
|
||
detect = self.piano["detect"]
|
||
for name, variable in self.tune.items():
|
||
try:
|
||
detect[name] = type(piano.DEFAULTS["detect"][name])(float(variable.get()))
|
||
except ValueError:
|
||
self._log(f"{name}: {variable.get()!r} is not a number")
|
||
variable.set(str(detect[name]))
|
||
detect["hold_notes"] = bool(self.hold_notes.get())
|
||
config.save(piano.NAME, self.piano)
|
||
|
||
def _refresh_points(self):
|
||
for key, (where, swatch, _pick) in self.point_rows.items():
|
||
point = self.cfg["points"].get(key)
|
||
if point and point.get("at"):
|
||
x, y = point["at"]
|
||
where.config(text=f"{x}, {y}", fg=TEXT)
|
||
swatch.config(bg=_hex(point.get("color")), highlightthickness=1)
|
||
else:
|
||
where.config(text="not set", fg=WARN)
|
||
swatch.config(bg=PANEL, highlightthickness=0)
|
||
self._ready_shown = None
|
||
|
||
def _watch_keys(self):
|
||
"""The capture key, polled fast enough to catch an ordinary tap."""
|
||
for pressed in self.watch.poll():
|
||
if pressed == "take":
|
||
if self.pick:
|
||
self._capture_point()
|
||
else:
|
||
key = self.cfg["hotkeys"].get("capture", "f6").upper()
|
||
self._log(f"{key} pressed, but nothing is waiting for it — "
|
||
f"press Pick on the point you want first")
|
||
elif pressed == "skip" and self.pick:
|
||
self._finish_pick(captured=False)
|
||
self.after(15, self._watch_keys)
|
||
|
||
def _rewatch(self):
|
||
self.watch = Hotkeys(catch_taps=True,
|
||
take=self.cfg["hotkeys"].get("capture", "f6"),
|
||
skip=self.cfg["hotkeys"].get("stop", "f8"))
|
||
|
||
def _arm_pick(self, key):
|
||
self.pick = key
|
||
capture = self.cfg["hotkeys"].get("capture", "f6").upper()
|
||
skip = self.cfg["hotkeys"].get("stop", "f8").upper()
|
||
self.pick_note.config(
|
||
text=f"picking {key} — hover it in Roblox and press [{capture}] "
|
||
f"[{skip}] to cancel", fg=ACCENT)
|
||
for name, (_w, _s, pick) in self.point_rows.items():
|
||
pick.config(text="Pick…" if name == key else "Pick")
|
||
for end, pick in self.end_picks.items():
|
||
pick.config(text="waiting…" if key == f"lane:{end}" else pick.plain)
|
||
for index, pick in enumerate(self.lane_picks):
|
||
pick.config(text="…" if key == f"lane:{index}" else pick.plain)
|
||
for name, pick in self.event_picks.items():
|
||
pick.config(text="…" if key == name else pick.plain)
|
||
if key.startswith("event:"):
|
||
self.event_note.config(
|
||
text=f"hover it in Roblox and press [{capture}] [{skip}] to cancel",
|
||
fg=ACCENT)
|
||
if key.startswith("lane:"):
|
||
self.lane_note.config(
|
||
text=f"hover the {key.split(':')[1]} lane where the note is when you "
|
||
f"would press it, then {capture}", fg=ACCENT)
|
||
self._log(f"pick {key}: hover the target and press {capture}, "
|
||
f"or use the 3 second countdown")
|
||
|
||
def _pick_after_countdown(self):
|
||
"""The same capture, for anyone whose keyboard is busy in the game."""
|
||
if not self.pick:
|
||
self._log("press Pick on a point first, then this")
|
||
return
|
||
screen.focus_roblox()
|
||
self._log("hold the mouse where you want it —")
|
||
self._count(3, lambda: self.pick and self._capture_point())
|
||
|
||
def _finish_pick(self, captured=True):
|
||
key, self.pick = self.pick, None
|
||
for _name, (_w, _s, pick) in self.point_rows.items():
|
||
pick.config(text="Pick")
|
||
for _end, pick in self.end_picks.items():
|
||
pick.config(text=pick.plain)
|
||
for pick in self.lane_picks:
|
||
pick.config(text=pick.plain)
|
||
for pick in self.event_picks.values():
|
||
pick.config(text=pick.plain)
|
||
self.event_note.config(text="")
|
||
self.pick_note.config(text="Pick a point below, hover it in Roblox, "
|
||
"then press the capture key.", fg=DIM)
|
||
if not captured:
|
||
self._log(f"{key}: cancelled")
|
||
|
||
def _capture_point(self):
|
||
key = self.pick
|
||
x, y = winput.cursor_pos()
|
||
colour = screen.pixel(x, y)
|
||
sound.tick()
|
||
if not screen.roblox_focused():
|
||
self._log("careful — Roblox is not the front window, so that colour is "
|
||
"whatever is drawn on top of it")
|
||
if key.startswith("event:"):
|
||
self._capture_event(key.split(":", 1)[1], x, y, colour)
|
||
return
|
||
if key.startswith("lane:") and key.split(":", 1)[1].isdigit():
|
||
index = int(key.split(":", 1)[1])
|
||
lanes = self._lanes()
|
||
if index < len(lanes):
|
||
lanes[index]["at"] = [x, y]
|
||
# A colour learnt for a pixel means nothing once the pixel moves.
|
||
lanes[index]["empty"] = None
|
||
lanes[index]["note"] = None
|
||
config.save(piano.NAME, self.piano)
|
||
self._log(f"lane {lanes[index]['key']}: {x},{y} rgb{colour} — "
|
||
f"watch the track again to relearn its colours")
|
||
self._finish_pick()
|
||
self._rebuild_region()
|
||
return
|
||
if key.startswith("lane:"):
|
||
end = key.split(":", 1)[1]
|
||
self.ends[end] = (x, y)
|
||
self._log(f"{end} end of the hit line: {x},{y} rgb{colour}")
|
||
self._finish_pick()
|
||
if "left" in self.ends and "right" in self.ends:
|
||
self._lay_out_lanes()
|
||
return
|
||
self.cfg["points"][key] = {"at": [x, y], "color": list(colour or ())}
|
||
config.save(MACRO.NAME, self.cfg)
|
||
self._log(f"{key}: {x},{y} rgb{colour}")
|
||
self._finish_pick()
|
||
self._refresh_points()
|
||
|
||
def _clear_point(self, key):
|
||
self.cfg["points"][key] = None
|
||
config.save(MACRO.NAME, self.cfg)
|
||
self._log(f"{key}: cleared")
|
||
self._refresh_points()
|
||
|
||
def _tolerance(self) -> int:
|
||
try:
|
||
return max(0, min(255, int(float(self.tolerance.get()))))
|
||
except ValueError:
|
||
return int(self.cfg.get("tolerance", 26))
|
||
|
||
def _save_tolerance(self):
|
||
value = self._tolerance()
|
||
self.tolerance.set(str(value))
|
||
if value != self.cfg.get("tolerance"):
|
||
self.cfg["tolerance"] = value
|
||
config.save(MACRO.NAME, self.cfg)
|
||
|
||
def _nudge_tolerance(self, by):
|
||
self.tolerance.set(str(max(0, min(255, self._tolerance() + by))))
|
||
self._save_tolerance()
|
||
|
||
def _test_probe(self):
|
||
point = self.cfg["points"].get("end_probe")
|
||
if not point or not point.get("at"):
|
||
self._log("no end_probe set — pick one first")
|
||
return
|
||
x, y = point["at"]
|
||
now = screen.pixel(x, y)
|
||
hit = screen.close_enough(now, point["color"], self._tolerance())
|
||
self._log(f"end_probe at {x},{y}: saved rgb{tuple(point['color'])}, "
|
||
f"now rgb{now} — {'MATCH' if hit else 'no match'}")
|
||
|
||
# -- settings -----------------------------------------------------
|
||
def _page_settings(self, page):
|
||
wrap = tk.Frame(page, bg=BG)
|
||
wrap.pack(fill="both", expand=True)
|
||
self.fields = {}
|
||
for title, rows in SETTINGS:
|
||
body = card(wrap, title)
|
||
for path, name, hint in rows:
|
||
row = tk.Frame(body, bg=PANEL)
|
||
row.pack(fill="x", pady=3)
|
||
label(row, name, font=UI, width=14).pack(side="left")
|
||
default = _get(MACRO.DEFAULTS, path)
|
||
if isinstance(default, bool):
|
||
variable = tk.BooleanVar()
|
||
tk.Checkbutton(row, variable=variable, bg=PANEL, fg=ACCENT,
|
||
activebackground=PANEL, activeforeground=ACCENT,
|
||
selectcolor=FIELD, relief="flat", bd=0,
|
||
highlightthickness=0).pack(side="left")
|
||
else:
|
||
variable = tk.StringVar()
|
||
entry(row, variable, width=9).pack(side="left")
|
||
label(row, hint, font=UI_SMALL, fg=FAINT, wraplength=420).pack(
|
||
side="left", padx=12)
|
||
self.fields[path] = variable
|
||
|
||
row = tk.Frame(wrap, bg=BG)
|
||
row.pack(fill="x")
|
||
button(row, "Save settings", self._save_fields, kind="accent").pack(side="left")
|
||
button(row, "Reload", self._load_fields, kind="ghost").pack(side="left", padx=8)
|
||
button(row, "Open config", lambda: os.startfile(config.CONFIG_DIR),
|
||
kind="ghost").pack(side="left")
|
||
label(row, str(config.config_path(MACRO.NAME)), font=UI_SMALL, fg=FAINT,
|
||
bg=BG).pack(side="left", padx=12)
|
||
|
||
def _load_fields(self):
|
||
for path, variable in self.fields.items():
|
||
value = _get(self.cfg, path)
|
||
if isinstance(variable, tk.BooleanVar):
|
||
variable.set(bool(value))
|
||
else:
|
||
variable.set("" if value is None else str(value))
|
||
self._hotkey_hint()
|
||
|
||
def _save_fields(self):
|
||
problems = []
|
||
for path, variable in self.fields.items():
|
||
default = _get(MACRO.DEFAULTS, path)
|
||
raw = variable.get()
|
||
if isinstance(default, bool):
|
||
_set(self.cfg, path, bool(raw))
|
||
elif isinstance(default, (int, float)):
|
||
try:
|
||
_set(self.cfg, path, type(default)(float(raw)))
|
||
except ValueError:
|
||
problems.append(f"{path}: {raw!r} is not a number")
|
||
else:
|
||
text = str(raw).strip().lower()
|
||
if path.startswith("hotkeys.") or path == "antiafk.key":
|
||
if text not in winput.KEYS:
|
||
problems.append(f"{path}: {text!r} is not a key this can press")
|
||
continue
|
||
_set(self.cfg, path, text)
|
||
for problem in problems:
|
||
self._log(problem)
|
||
config.save(MACRO.NAME, self.cfg)
|
||
self._load_fields()
|
||
self._log("settings saved" + (" (with the problems above left alone)"
|
||
if problems else ""))
|
||
|
||
def _hotkey_hint(self):
|
||
keys = self.cfg["hotkeys"]
|
||
self.hotkey_hint.config(
|
||
text=f"[{keys.get('pause', 'f7').upper()}] start / pause\n"
|
||
f"[{keys.get('stop', 'f8').upper()}] stop\n"
|
||
f"[{keys.get('capture', 'f6').upper()}] capture a point")
|
||
|
||
# ------------------------------------------------------------ tasks
|
||
def _busy(self) -> bool:
|
||
if self.task and self.task.is_alive():
|
||
self._log("something is already running — stop it first")
|
||
return True
|
||
return False
|
||
|
||
def _spawn(self, kind, target, runner=None, signals=None):
|
||
self.kind = kind
|
||
self.runner = runner
|
||
self.signals = signals
|
||
self.recording = False
|
||
|
||
def wrapped():
|
||
try:
|
||
target()
|
||
except Exception:
|
||
traceback.print_exc()
|
||
|
||
self.task = threading.Thread(target=wrapped, name=f"kittymacro-{kind}", daemon=True)
|
||
self.task.start()
|
||
self.btn_stop.config(state="normal")
|
||
|
||
def _go(self):
|
||
"""START, RESUME or PAUSE, depending on what is happening."""
|
||
if self.task and self.task.is_alive():
|
||
self._toggle()
|
||
return
|
||
if self.countdown is not None:
|
||
return
|
||
self._start_run(self.mode.value)
|
||
|
||
DOING = {
|
||
"loop": "playing every song that comes up, until you stop it",
|
||
"once": "one song, then stop",
|
||
"session": "the whole event — in through the menus, then every song at "
|
||
"every difficulty, picking up where it left off",
|
||
}
|
||
|
||
def _start_run(self, mode="loop"):
|
||
if self._busy():
|
||
return
|
||
module = self._module()
|
||
self.cfg = config.load(MACRO.NAME, MACRO.DEFAULTS)
|
||
self.piano = piano.migrate(config.load(piano.NAME, piano.DEFAULTS))
|
||
cfg = self.piano if module is piano else self.cfg
|
||
signals = Signals()
|
||
title = dict((n, t) for n, t, _m in GAMES)[self.playing]
|
||
runner = Runner(title, cfg["hotkeys"], signals=signals)
|
||
if module is piano:
|
||
self._spawn("run", lambda: piano.run(mode=mode, runner=runner),
|
||
runner=runner, signals=signals)
|
||
self._log(self.DOING[mode])
|
||
else:
|
||
self._spawn("run", lambda: module.run(once=mode == "once", runner=runner),
|
||
runner=runner, signals=signals)
|
||
self._log("one match, then stop" if mode == "once"
|
||
else "looping matches until you stop it")
|
||
self._launch(signals, "pause")
|
||
|
||
def _play(self):
|
||
if self._busy():
|
||
return
|
||
name = self._selected_routine()
|
||
if not name:
|
||
self._log("pick a routine in the list first")
|
||
return
|
||
try:
|
||
steps = config.load_routine(name)
|
||
speed = float(self.play_speed.get())
|
||
except (FileNotFoundError, ValueError) as error:
|
||
self._log(str(error))
|
||
return
|
||
signals = Signals()
|
||
runner = Runner(f"routine {name}", self.cfg["hotkeys"], signals=signals)
|
||
|
||
def once():
|
||
routine.play(runner, steps, speed)
|
||
runner.stopped = True
|
||
|
||
self._spawn("play", lambda: runner.loop(once), runner=runner, signals=signals)
|
||
self._log(f"playing {name!r} — {len(steps)} steps at {speed}x")
|
||
self._launch(signals, "pause")
|
||
|
||
def _record(self):
|
||
if self.task and self.task.is_alive() and self.kind == "record":
|
||
self._toggle()
|
||
return
|
||
if self._busy() or self.countdown is not None:
|
||
return
|
||
name = self.record_name.get().strip()
|
||
if not name:
|
||
self._log("give the routine a name first")
|
||
return
|
||
signals = Signals()
|
||
result: list = []
|
||
|
||
def capture():
|
||
steps = routine.record(name, self.cfg["hotkeys"], extra=signals)
|
||
result.append(steps)
|
||
if steps:
|
||
path = config.save_routine(name, steps)
|
||
print(f"saved {len(steps)} steps to {path}")
|
||
|
||
self._spawn("record", capture, signals=signals)
|
||
self._launch(signals, "toggle")
|
||
|
||
def _launch(self, signals, press):
|
||
"""Give Roblox the window, count down, then press start for real."""
|
||
screen.focus_roblox()
|
||
self._count(3, lambda: signals.send(press))
|
||
|
||
def _count(self, remaining, then):
|
||
if remaining <= 0:
|
||
self.countdown = None
|
||
then()
|
||
return
|
||
self.countdown = remaining
|
||
self._log(f" {remaining}…")
|
||
self.after(1000, lambda: self._count(remaining - 1, then))
|
||
|
||
def _toggle(self):
|
||
if not self.signals:
|
||
return
|
||
self.signals.send("toggle" if self.kind == "record" else "pause")
|
||
|
||
def _stop(self):
|
||
if not (self.task and self.task.is_alive()):
|
||
return
|
||
self.signals.send("quit" if self.kind == "record" else "stop")
|
||
|
||
def _task_ended(self):
|
||
kind = self.kind
|
||
self.task = self.kind = self.runner = self.signals = None
|
||
self.recording = False
|
||
self.btn_stop.config(state="disabled")
|
||
self.btn_record.config(text="Record")
|
||
self.record_note.config(text="")
|
||
if kind == "record":
|
||
self._refresh_routines()
|
||
winput.release_all()
|
||
|
||
# ------------------------------------------------------------- tick
|
||
def _tick(self):
|
||
self._drain()
|
||
now = time.monotonic()
|
||
if now - self._roblox_at > 1.0:
|
||
self._roblox_at = now
|
||
self.roblox = screen.find_roblox()
|
||
self._refresh_ready()
|
||
focused = screen.roblox_focused()
|
||
|
||
self._paint_pills(focused)
|
||
|
||
if self.page == "calibrate":
|
||
x, y = winput.cursor_pos()
|
||
colour = screen.pixel(x, y)
|
||
self.cursor_at.config(text=f"{x}, {y}")
|
||
self.cursor_swatch.config(bg=_hex(colour))
|
||
self.cursor_rgb.config(text=f"rgb{colour} {_hex(colour)}" if colour else "unreadable")
|
||
|
||
if self.page == "piano":
|
||
self._show_lanes()
|
||
|
||
if self.task and not self.task.is_alive():
|
||
self._task_ended()
|
||
|
||
self.after(120, self._tick)
|
||
|
||
def _paint_pills(self, focused):
|
||
live = bool(self.task and self.task.is_alive())
|
||
if self.roblox is None:
|
||
self.pill_roblox.dot.config(fg=BAD)
|
||
self.pill_roblox.text.config(text="Roblox not open")
|
||
elif focused:
|
||
self.pill_roblox.dot.config(fg=GOOD)
|
||
self.pill_roblox.text.config(text="Roblox in front")
|
||
else:
|
||
self.pill_roblox.dot.config(fg=WARN)
|
||
self.pill_roblox.text.config(text="Roblox behind")
|
||
|
||
if self.countdown is not None:
|
||
state, colour, note = f"starting in {self.countdown}", ACCENT, "give Roblox the window"
|
||
elif not live:
|
||
state, colour, note = "idle", DIM, "press START, then let it have the window"
|
||
elif self.kind == "record":
|
||
if self.recording:
|
||
state, colour, note = "recording", ACCENT, "play your round; press Finish when done"
|
||
else:
|
||
state, colour, note = "armed", WARN, "press Record again, or the pause key, to begin"
|
||
elif self.runner and self.runner.stopped:
|
||
state, colour, note = "stopping", WARN, "letting go of everything"
|
||
elif self.runner and self.runner.paused:
|
||
state, colour, note = "paused", WARN, "nothing is being sent"
|
||
elif not focused:
|
||
state, colour, note = "waiting", WARN, "Roblox is not the front window"
|
||
else:
|
||
state, colour, note = "running", GOOD, "hands off the mouse"
|
||
|
||
self.pill_state.dot.config(fg=colour)
|
||
self.pill_state.text.config(text=state, fg=colour)
|
||
self.state_big.config(text=state, fg=colour if state != "idle" else TEXT)
|
||
self.state_note.config(text=note)
|
||
|
||
if not live:
|
||
go = "START"
|
||
elif self.kind == "record":
|
||
go = "FINISH" if self.recording else "BEGIN"
|
||
else:
|
||
go = "PAUSE" if state == "running" else "RESUME"
|
||
self.btn_go.config(text=go)
|
||
if self.kind == "record":
|
||
self.btn_record.config(text="Finish" if self.recording else "Begin")
|
||
self.record_note.config(text=note, fg=colour)
|
||
|
||
stats = self.runner.stats if self.runner else {}
|
||
started = stats.get("started")
|
||
minutes = (time.monotonic() - started) / 60 if started else 0
|
||
if self.playing == "piano":
|
||
lanes = self._lanes()
|
||
notes = stats.get("notes")
|
||
total = stats.get("total")
|
||
cells = [(str(notes) if notes is not None else "—", "notes played"),
|
||
("—" if not started else f"{minutes:.1f} min", "elapsed"),
|
||
((f"{stats.get('runs', 0)}/{total}", "runs done") if total
|
||
else (str(len(lanes)) if lanes else "—", "lanes")),
|
||
(f"{stats['rate']:.0f}/s" if stats.get("rate") else "—", "scan rate")]
|
||
summary = "" if notes is None else (
|
||
f"{stats.get('runs', 0)}/{total} runs · {notes} notes" if total
|
||
else f"{notes} notes · {stats.get('rate', 0):.0f}/s")
|
||
else:
|
||
matches = stats.get("matches")
|
||
cells = [("—" if matches is None else str(matches), "matches"),
|
||
("—" if not started else f"{minutes:.1f} min", "elapsed"),
|
||
(self.cfg["routines"].get("build") or "—", "build routine"),
|
||
(self.cfg["routines"].get("loop") or "—", "loop routine")]
|
||
summary = "" if matches is None else f"{matches} matches · {minutes:.0f} min"
|
||
for key, (value, caption) in zip("abcd", cells):
|
||
widget, title = self.stat_widgets[key]
|
||
widget.config(text=value)
|
||
title.config(text=caption)
|
||
self.pill_stats.config(text=summary)
|
||
|
||
# -------------------------------------------------------------- log
|
||
def _log(self, message):
|
||
print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)
|
||
|
||
def _drain(self):
|
||
lines = []
|
||
while True:
|
||
try:
|
||
lines.append(self.logs.get_nowait())
|
||
except queue.Empty:
|
||
break
|
||
if not lines:
|
||
return
|
||
self.log.config(state="normal")
|
||
for line in lines:
|
||
self.log.insert("end", line + "\n", self._tag(line))
|
||
# A day of farming is a lot of lines; the last thousand is plenty.
|
||
if int(self.log.index("end-1c").split(".")[0]) > 1000:
|
||
self.log.delete("1.0", "300.0")
|
||
self.log.config(state="disabled")
|
||
if self.follow.get():
|
||
self.log.see("end")
|
||
for line in lines:
|
||
if line.endswith("recording…"):
|
||
self.recording = True
|
||
elif "finished —" in line or "discarded." in line:
|
||
self.recording = False
|
||
|
||
@staticmethod
|
||
def _tag(line):
|
||
low = line.lower()
|
||
if any(word in low for word in ("refused", "traceback", "error", "not a")):
|
||
return "bad"
|
||
if any(word in low for word in ("not found", "not open", "no ", "paused",
|
||
"waiting", "stopped", "cancelled", "timeout")):
|
||
return "warn"
|
||
if any(word in low for word in ("running", "recording", "captured", "saved",
|
||
"accepted", "match", "deleted", "found")):
|
||
return "good"
|
||
if line.startswith(" "):
|
||
return "dim"
|
||
return "plain"
|
||
|
||
def _clear_log(self):
|
||
self.log.config(state="normal")
|
||
self.log.delete("1.0", "end")
|
||
self.log.config(state="disabled")
|
||
|
||
# ------------------------------------------------------------ close
|
||
def _close(self):
|
||
if self.task and self.task.is_alive():
|
||
self._stop()
|
||
self.task.join(timeout=2.0)
|
||
winput.release_all()
|
||
if self.lane_region is not None:
|
||
self.lane_region.close()
|
||
sys.stdout = getattr(sys.stdout, "stream", sys.stdout)
|
||
sys.stderr = getattr(sys.stderr, "stream", sys.stderr)
|
||
self.destroy()
|
||
|
||
|
||
def main():
|
||
if sys.platform != "win32":
|
||
print("KittyMacro drives Windows input directly — it only runs on Windows.")
|
||
return 2
|
||
App().mainloop()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|