stats-tracker/overlay.py
ZareMate 317c29879c Add initial implementation of Stats Tracker with environment configuration and player stats tracking
- 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
2026-03-23 19:37:38 +01:00

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()