Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
306 lines
14 KiB
Python
306 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Мастер первоначальной настройки — показывается при первом запуске.
|
|
|
|
Окно живёт до построения главного окна (см. ui.run): пользователь выбирает
|
|
язык, ник и объём ОЗУ. Результат пишется в settings, флаг onboarding_done
|
|
не даёт мастеру появиться снова; апгрейд со старой версии считается уже
|
|
настроенным (см. config.load_settings).
|
|
"""
|
|
|
|
import getpass
|
|
import re
|
|
import tkinter as tk
|
|
|
|
from . import i18n
|
|
from .i18n import T
|
|
|
|
NICK_RE = re.compile(r"[A-Za-z0-9_]{3,16}")
|
|
|
|
|
|
def total_ram_mb():
|
|
"""ОЗУ системы в МБ (Windows — GlobalMemoryStatusEx, иначе 4096)."""
|
|
try:
|
|
import ctypes
|
|
|
|
class _MemStatus(ctypes.Structure):
|
|
_fields_ = [
|
|
("dwLength", ctypes.c_ulong),
|
|
("dwMemoryLoad", ctypes.c_ulong),
|
|
("ullTotalPhys", ctypes.c_ulonglong),
|
|
("ullAvailPhys", ctypes.c_ulonglong),
|
|
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
]
|
|
|
|
st = _MemStatus()
|
|
st.dwLength = ctypes.sizeof(_MemStatus)
|
|
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(st)):
|
|
return max(1024, st.ullTotalPhys // (1024 * 1024))
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return 4096
|
|
|
|
|
|
def default_ram_mb():
|
|
"""Разумный дефолт: половина ОЗУ, в пределах 2-8 ГБ, кратно 512 МБ."""
|
|
mb = (total_ram_mb() // 2) // 512 * 512
|
|
return max(2048, min(8192, mb))
|
|
|
|
|
|
def _suggest_nick():
|
|
"""Ник по умолчанию: имя пользователя Windows, если оно допустимо."""
|
|
try:
|
|
user = getpass.getuser()
|
|
except Exception: # noqa: BLE001
|
|
user = ""
|
|
return user if NICK_RE.fullmatch(user) else "Player"
|
|
|
|
|
|
class Wizard:
|
|
"""Окно мастера: язык -> ник -> ОЗУ -> готово."""
|
|
|
|
def __init__(self, root, settings):
|
|
# палитру берём из ui (лениво: ui импортирует этот модуль в run())
|
|
from .ui import (BG, DIM, ERR, FAINT, GREEN, GREEN_HI, INPUT,
|
|
INPUT_BD, PANEL, TEXT)
|
|
|
|
self.pal = dict(BG=BG, DIM=DIM, ERR=ERR, FAINT=FAINT, GREEN=GREEN,
|
|
GREEN_HI=GREEN_HI, INPUT=INPUT, INPUT_BD=INPUT_BD,
|
|
PANEL=PANEL, TEXT=TEXT)
|
|
self.root = root
|
|
self.settings = settings
|
|
self.step = 0
|
|
self.result = None # "done" | "skip"
|
|
|
|
root.title("PixelLauncher")
|
|
root.configure(bg=BG)
|
|
root.geometry("560x430")
|
|
root.resizable(False, False)
|
|
root.protocol("WM_DELETE_WINDOW", self.skip)
|
|
|
|
self.var_lang = tk.StringVar(
|
|
value="en" if settings.get("lang") == "en" else "ru")
|
|
self.var_nick = tk.StringVar(value=_suggest_nick())
|
|
self.var_auto = tk.BooleanVar(value=True)
|
|
self.var_ram = tk.IntVar(value=default_ram_mb())
|
|
self.var_err = tk.StringVar(value="")
|
|
|
|
self.lbl_ram = None
|
|
self.scale_ram = None
|
|
self._goto(0)
|
|
self._center()
|
|
|
|
# ------------------------------------------------------------ каркас
|
|
def _goto(self, i):
|
|
"""Показать шаг i (окно перерисовывается целиком — тексты живые)."""
|
|
self.step = i
|
|
for w in self.root.winfo_children():
|
|
w.destroy()
|
|
p = self.pal
|
|
|
|
head = tk.Frame(self.root, bg=p["BG"])
|
|
head.pack(fill="x", padx=36, pady=(26, 0))
|
|
tk.Label(head, text="PixelLauncher", bg=p["BG"], fg=p["GREEN"],
|
|
font=("Consolas", 13, "bold")).pack(side="left")
|
|
tk.Label(head, text=T("Шаг {n} из {m}").format(n=i + 1, m=4),
|
|
bg=p["BG"], fg=p["DIM"],
|
|
font=("Segoe UI", 9)).pack(side="right")
|
|
|
|
body = tk.Frame(self.root, bg=p["BG"])
|
|
body.pack(fill="both", expand=True, padx=36, pady=18)
|
|
(self._step0 if i == 0 else self._step1 if i == 1
|
|
else self._step2 if i == 2 else self._step3)(body)
|
|
|
|
foot = tk.Frame(self.root, bg=p["BG"])
|
|
foot.pack(fill="x", padx=36, pady=(0, 24))
|
|
if i > 0:
|
|
self._btn(foot, T("Назад"), lambda: self._goto(i - 1))
|
|
self._btn(foot, T("Пропустить настройку"), self.skip, dim=True)
|
|
label = T("Начать играть") if i == 3 else T("Далее")
|
|
self._btn(foot, label, self._next, accent=True, right=True)
|
|
|
|
def _btn(self, parent, text, cmd, accent=False, dim=False, right=False):
|
|
p = self.pal
|
|
bg = p["GREEN"] if accent else (p["BG"] if dim else p["PANEL"])
|
|
fg = "#0e1a08" if accent else (p["DIM"] if dim else p["TEXT"])
|
|
b = tk.Button(parent, text=text, command=cmd, bg=bg, fg=fg,
|
|
activebackground=p["GREEN_HI"] if accent else bg,
|
|
activeforeground=fg, relief="flat", cursor="hand2",
|
|
font=("Segoe UI", 10, "bold" if accent else "normal"),
|
|
padx=16, pady=6)
|
|
b.pack(side="right" if right else "left",
|
|
padx=(10, 0) if right else (0, 10))
|
|
return b
|
|
|
|
def _center(self):
|
|
self.root.update_idletasks()
|
|
w, h = 560, 430
|
|
x = (self.root.winfo_screenwidth() - w) // 2
|
|
y = (self.root.winfo_screenheight() - h) // 2 - 30
|
|
self.root.geometry("%dx%d+%d+%d" % (w, h, max(0, x), max(0, y)))
|
|
|
|
def _title(self, parent, text):
|
|
p = self.pal
|
|
tk.Label(parent, text=text, bg=p["BG"], fg=p["TEXT"],
|
|
font=("Segoe UI", 15, "bold")).pack(anchor="w")
|
|
|
|
# ------------------------------------------------------------ шаги
|
|
def _step0(self, body):
|
|
"""Язык."""
|
|
p = self.pal
|
|
self._title(body, T("Добро пожаловать в PixelLauncher!"))
|
|
tk.Label(body, wraplength=470, justify="left", bg=p["BG"],
|
|
fg=p["DIM"], font=("Segoe UI", 10),
|
|
text=T("Первая настройка займёт минуту: язык, ник и "
|
|
"память для игры.")).pack(anchor="w", pady=(10, 18))
|
|
row = tk.Frame(body, bg=p["BG"])
|
|
row.pack(anchor="w")
|
|
for code, caption in (("ru", "🇷🇺 Русский"), ("en", "🇬🇧 English")):
|
|
chosen = self.var_lang.get() == code
|
|
b = tk.Button(row, text=caption, bg=p["GREEN"] if chosen
|
|
else p["PANEL"], fg="#0e1a08" if chosen else p["TEXT"],
|
|
activebackground=p["GREEN_HI"], relief="flat",
|
|
cursor="hand2", font=("Segoe UI", 11, "bold"),
|
|
padx=22, pady=10,
|
|
command=lambda c=code: self._set_lang(c))
|
|
b.pack(side="left", padx=(0, 12))
|
|
tk.Label(body, wraplength=470, justify="left", bg=p["BG"],
|
|
fg=p["FAINT"], font=("Segoe UI", 9),
|
|
text=T("Язык можно поменять позже в настройках.")
|
|
).pack(anchor="w", pady=(18, 0))
|
|
|
|
def _set_lang(self, code):
|
|
self.var_lang.set(code)
|
|
i18n.set_language(code)
|
|
self.settings["lang"] = code # сохранится даже при пропуске
|
|
self._goto(self.step)
|
|
|
|
def _step1(self, body):
|
|
"""Ник."""
|
|
p = self.pal
|
|
self._title(body, T("Как тебя зовут?"))
|
|
tk.Label(body, bg=p["BG"], fg=p["DIM"], font=("Segoe UI", 10),
|
|
text=T("Ник игрока (виден в игре):")
|
|
).pack(anchor="w", pady=(12, 6))
|
|
e = tk.Entry(body, textvariable=self.var_nick, bg=p["INPUT"],
|
|
fg=p["TEXT"], insertbackground=p["TEXT"],
|
|
relief="flat", font=("Segoe UI", 13), width=24)
|
|
e.pack(anchor="w", ipady=7)
|
|
e.focus_set()
|
|
e.bind("<Return>", lambda ev: self._next())
|
|
tk.Label(body, textvariable=self.var_err, bg=p["BG"], fg=p["ERR"],
|
|
font=("Segoe UI", 9)).pack(anchor="w", pady=(8, 0))
|
|
tk.Label(body, wraplength=470, justify="left", bg=p["BG"],
|
|
fg=p["DIM"], font=("Segoe UI", 9),
|
|
text=T("Ник: 3-16 символов, латиница, цифры и _")
|
|
).pack(anchor="w", pady=(4, 0))
|
|
|
|
def _step2(self, body):
|
|
"""ОЗУ."""
|
|
p = self.pal
|
|
self._title(body, T("Сколько памяти выделить игре?"))
|
|
auto = tk.Checkbutton(body, text=T("Подобрать автоматически"),
|
|
variable=self.var_auto, bg=p["BG"], fg=p["TEXT"],
|
|
selectcolor=p["INPUT"], activebackground=p["BG"],
|
|
activeforeground=p["TEXT"], relief="flat",
|
|
font=("Segoe UI", 10), cursor="hand2",
|
|
command=self._sync_ram)
|
|
auto.pack(anchor="w", pady=(14, 8))
|
|
self.scale_ram = tk.Scale(body, from_=1024, to=16384, resolution=512,
|
|
orient="horizontal", showvalue=0,
|
|
bg=p["BG"], fg=p["TEXT"], troughcolor=p["INPUT"],
|
|
highlightthickness=0, length=440,
|
|
sliderrelief="flat", sliderlength=18,
|
|
command=self._on_ram)
|
|
self.scale_ram.set(self.var_ram.get())
|
|
self.scale_ram.pack(anchor="w")
|
|
self.lbl_ram = tk.Label(body, text=self._ram_text(), bg=p["BG"],
|
|
fg=p["GREEN"], font=("Segoe UI", 11, "bold"))
|
|
self.lbl_ram.pack(anchor="w", pady=(4, 6))
|
|
tk.Label(body, wraplength=470, justify="left", bg=p["BG"],
|
|
fg=p["DIM"], font=("Segoe UI", 9),
|
|
text=T("Можно оставить авто — лаунчер подберёт значение "
|
|
"при запуске.")).pack(anchor="w")
|
|
self._sync_ram()
|
|
|
|
def _on_ram(self, value):
|
|
try:
|
|
self.var_ram.set(int(float(value)))
|
|
except (TypeError, ValueError):
|
|
return
|
|
if self.lbl_ram is not None:
|
|
self.lbl_ram.config(text=self._ram_text())
|
|
|
|
def _ram_text(self):
|
|
mb = int(self.var_ram.get())
|
|
return T("{mb} МБ ({gb} ГБ)").format(mb=mb, gb=int(round(mb / 1024)))
|
|
|
|
def _sync_ram(self):
|
|
"""Галка «авто» включает/выключает ползунок."""
|
|
if self.scale_ram is not None:
|
|
self.scale_ram.config(
|
|
state="disabled" if self.var_auto.get() else "normal")
|
|
|
|
def _step3(self, body):
|
|
"""Готово."""
|
|
p = self.pal
|
|
self._title(body, T("Всё готово!"))
|
|
lang = T("English") if self.var_lang.get() == "en" else "Русский"
|
|
ram = T("авто") if self.var_auto.get() else self._ram_text()
|
|
info = "\n".join([T("Ник: {nick}").format(
|
|
nick=self.var_nick.get().strip() or "Player"),
|
|
T("Язык: {lang}").format(lang=lang),
|
|
T("Память: {ram}").format(ram=ram)])
|
|
tk.Label(body, justify="left", anchor="w", bg=p["PANEL"], fg=p["TEXT"],
|
|
font=("Segoe UI", 11), padx=18, pady=14, text=info
|
|
).pack(anchor="w", pady=(14, 12))
|
|
tk.Label(body, wraplength=470, justify="left", bg=p["BG"],
|
|
fg=p["DIM"], font=("Segoe UI", 9),
|
|
text=T("Чтобы пройти настройку заново, удалите файл "
|
|
"settings.json.")).pack(anchor="w")
|
|
|
|
# ------------------------------------------------------------ действия
|
|
def _next(self):
|
|
if self.step == 1 and not NICK_RE.fullmatch(
|
|
self.var_nick.get().strip()):
|
|
self.var_err.set(T("Недопустимый ник: латиница, цифры и _, "
|
|
"3-16 символов"))
|
|
return
|
|
if self.step == 3:
|
|
self.finish()
|
|
return
|
|
self._goto(self.step + 1)
|
|
|
|
def finish(self):
|
|
"""Применить выбор: язык, ник, ОЗУ — и закрыть мастер."""
|
|
nick = self.var_nick.get().strip()
|
|
s = self.settings
|
|
s["lang"] = "en" if self.var_lang.get() == "en" else "ru"
|
|
i18n.set_language(s["lang"])
|
|
s["username"] = nick
|
|
s["accounts"] = [nick]
|
|
s["active_account"] = nick
|
|
s["ram"] = int(self.var_ram.get())
|
|
s["auto_ram"] = bool(self.var_auto.get())
|
|
s["onboarding_done"] = True
|
|
self.result = "done"
|
|
self.root.destroy()
|
|
|
|
def skip(self):
|
|
"""Настроить позже: только зафиксировать, что мастер показан."""
|
|
self.settings["onboarding_done"] = True
|
|
self.result = "skip"
|
|
self.root.destroy()
|
|
|
|
|
|
def run(settings, _auto_close_ms=None):
|
|
"""Показать мастер и дождаться закрытия. settings меняется на месте."""
|
|
root = tk.Tk()
|
|
Wizard(root, settings)
|
|
if _auto_close_ms: # тестовый режим: просто мигнуть окном
|
|
root.after(_auto_close_ms, root.destroy)
|
|
root.mainloop()
|