- Create .env.example for environment variable setup - Add .gitignore to exclude sensitive files and cache - Implement README.md with setup instructions and usage details - Develop main.py for fetching and posting player stats to Discord - Create overlay.py for displaying stats in a GUI overlay - Add players.json to define players to track - Specify dependencies in requirements.txt
211 lines
6.9 KiB
Python
211 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tkinter as tk
|
|
from tkinter import font as tkfont
|
|
|
|
CACHE_FILE = 'stats_cache.json'
|
|
REFRESH_MS = 3000 # Re-read cache every 3 seconds
|
|
BG_COLOR = "#232323"
|
|
WINDOW_CHECK_MS = 80
|
|
MINECRAFT_PATTERNS = ('minecraft', 'lunar client', 'badlion', 'fabric', 'forge')
|
|
|
|
# ── formatting helpers (mirrors main.py, no imports needed) ──────────────────
|
|
|
|
def fmt_money(value):
|
|
if not value or value in ('N/A', '❌'):
|
|
return value or 'N/A'
|
|
try:
|
|
num = int(value)
|
|
if num >= 1_000_000_000:
|
|
return f"{num / 1_000_000_000:.2f} B"
|
|
elif num >= 1_000_000:
|
|
return f"{num / 1_000_000:.2f} M"
|
|
elif num >= 1_000:
|
|
return f"{num / 1_000:.2f} K"
|
|
return str(num)
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
|
|
|
|
def _run_cmd(command):
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
timeout=0.10,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
return result.stdout.strip()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def get_active_window_info():
|
|
"""Return active window id and title via xdotool, or (None, None)."""
|
|
win_id = _run_cmd(['xdotool', 'getactivewindow'])
|
|
if not win_id:
|
|
return None, None
|
|
title = _run_cmd(['xdotool', 'getwindowname', win_id]) or ''
|
|
return win_id, title
|
|
|
|
|
|
def is_minecraft_title(title):
|
|
title_l = (title or '').lower()
|
|
return any(pattern in title_l for pattern in MINECRAFT_PATTERNS)
|
|
|
|
|
|
def get_window_geometry(win_id):
|
|
"""Return dict with x,y,width,height for a window id, or None."""
|
|
out = _run_cmd(['xwininfo', '-id', str(win_id)])
|
|
if not out:
|
|
return None
|
|
|
|
def find_int(pattern):
|
|
match = re.search(pattern, out)
|
|
return int(match.group(1)) if match else None
|
|
|
|
x = find_int(r'Absolute upper-left X:\s+(-?\d+)')
|
|
y = find_int(r'Absolute upper-left Y:\s+(-?\d+)')
|
|
width = find_int(r'Width:\s+(\d+)')
|
|
height = find_int(r'Height:\s+(\d+)')
|
|
|
|
if None in (x, y, width, height):
|
|
return None
|
|
return {'x': x, 'y': y, 'width': width, 'height': height}
|
|
|
|
# ── overlay ──────────────────────────────────────────────────────────────────
|
|
|
|
class Overlay(tk.Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
# Window chrome
|
|
self.overrideredirect(True) # no title bar
|
|
self.attributes('-topmost', True)
|
|
self.configure(bg=BG_COLOR)
|
|
|
|
# Fonts
|
|
self.name_font = tkfont.Font(family='Consolas', size=9)
|
|
self.money_font = tkfont.Font(family='Consolas', size=10, weight='bold')
|
|
|
|
# Player rows container
|
|
self.rows_frame = tk.Frame(self, bg=BG_COLOR)
|
|
self.rows_frame.pack(fill='both', padx=6, pady=4)
|
|
|
|
self.total_label = tk.Label(
|
|
self,
|
|
text='Total: 0',
|
|
bg=BG_COLOR,
|
|
fg='#f0c040',
|
|
font=self.money_font,
|
|
anchor='w'
|
|
)
|
|
self.total_label.pack(fill='x', padx=6, pady=(0, 4))
|
|
|
|
# Initial position (top-right corner area)
|
|
self.update_idletasks()
|
|
self.geometry('+0+100')
|
|
|
|
# Player label widgets: {username: (name_lbl, money_lbl)}
|
|
self._labels: dict[str, tuple[tk.Label, tk.Label]] = {}
|
|
self._visible_for_minecraft = True
|
|
|
|
self._sync_window_visibility()
|
|
self._refresh()
|
|
|
|
# ── data ────────────────────────────────────────────────────────────────
|
|
|
|
def _load_cache(self):
|
|
if not os.path.exists(CACHE_FILE):
|
|
return None
|
|
try:
|
|
with open(CACHE_FILE, 'r') as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return None
|
|
|
|
def _sync_window_visibility(self):
|
|
"""Show overlay only while Minecraft window is focused and anchor to it."""
|
|
win_id, title = get_active_window_info()
|
|
|
|
if win_id and is_minecraft_title(title):
|
|
geo = get_window_geometry(win_id)
|
|
if geo:
|
|
# Top-left corner of Minecraft with a small inset.
|
|
self.update_idletasks()
|
|
x = geo['x']
|
|
y = geo['y'] + 90
|
|
self.geometry(f'+{max(0, x)}+{max(0, y)}')
|
|
|
|
if not self._visible_for_minecraft:
|
|
self.deiconify()
|
|
self._visible_for_minecraft = True
|
|
else:
|
|
if self._visible_for_minecraft:
|
|
self.withdraw()
|
|
self._visible_for_minecraft = False
|
|
|
|
self.after(WINDOW_CHECK_MS, self._sync_window_visibility)
|
|
|
|
def _refresh(self):
|
|
data = self._load_cache()
|
|
|
|
if data is None:
|
|
self.total_label.configure(text='Total: N/A')
|
|
self.after(REFRESH_MS, self._refresh)
|
|
return
|
|
|
|
# Create or update a row per player
|
|
players = list(data.keys())
|
|
total_money = 0
|
|
for player in players:
|
|
stats = data.get(player) or {}
|
|
raw = stats.get('money', 'N/A')
|
|
money = fmt_money(raw) if raw else 'N/A'
|
|
color = '#2ecc71' if stats else '#e74c3c'
|
|
|
|
if stats:
|
|
try:
|
|
total_money += int(raw)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if player not in self._labels:
|
|
row = tk.Frame(self.rows_frame, bg=BG_COLOR)
|
|
row.pack(fill='x', pady=1)
|
|
name_lbl = tk.Label(row, text=player, bg=BG_COLOR, fg='#aaaaaa',
|
|
font=self.name_font,
|
|
width=14, anchor='w')
|
|
money_lbl = tk.Label(row, text='', bg=BG_COLOR, fg=color,
|
|
font=self.money_font,
|
|
anchor='e')
|
|
name_lbl.pack(side='left')
|
|
money_lbl.pack(side='right')
|
|
self._labels[player] = (name_lbl, money_lbl)
|
|
else:
|
|
name_lbl, money_lbl = self._labels[player]
|
|
money_lbl.configure(fg=color)
|
|
|
|
self._labels[player][1].configure(text=money)
|
|
|
|
# Remove rows for players no longer in cache
|
|
for player in list(self._labels.keys()):
|
|
if player not in players:
|
|
self._labels[player][0].master.destroy()
|
|
del self._labels[player]
|
|
|
|
self.total_label.configure(text=f'Total: {fmt_money(total_money)}')
|
|
self.after(REFRESH_MS, self._refresh)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app = Overlay()
|
|
app.mainloop()
|