Minecraft启动器Py
2026-10-29 19:13:47
发布于:重庆
from __future__ import annotations
import hashlib,json,math,os,platform,re,shutil,subprocess,sys,tarfile,threading,time,zipfile
from collections import deque
from concurrent.futures import ThreadPoolExecutor,as_completed
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
import tkinter as tk
from tkinter import filedialog,messagebox
import requests
try:
import customtkinter as ctk
except ImportError:
print("请先安装 customtkinter 库: pip install customtkinter");sys.exit(1)
try:
from PIL import Image
except ImportError:
print("请先安装 Pillow 库: pip install pillow");sys.exit(1)
N="MC Launcher"
D=Path.home()/".mc_launcher"
CF=D/"config.json"
MU="https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
AU="https://api.adoptium.net/v3/assets/latest/{java_major}/hotspot?os={os}&architecture={arch}&image_type=jre&heap_size=normal"
MS="https://api.modrinth.com/v2/search"
MV="https://api.modrinth.com/v2/project/{pid}/version"
DS=100000;TM=50;SUM=500;LPS=1.5;CLL=30000
JVO=["自动","Java 8","Java 17","Java 21"]
JVM={"自动":None,"Java 8":8,"Java 17":17,"Java 21":21}
C={
"bg":("#f4f4f5","#18181b"),
"fg":("#18181b","#e4e4e7"),
"mu":("#71717a","#a1a1aa"),
"cd":("#ffffff","#27272a"),
"ch":("#e4e4e7","#3f3f46"),
"ac":("#3b82f6","#3b82f6"),
"ah":("#2563eb","#2563eb"),
"dg":("#dc2626","#dc2626"),
"dh":("#b91c1c","#b91c1c"),
"lb":("#fafafa","#0f0f11"),
"na":("#e4e4e7","#3f3f46"),
"bd":("#d4d4d8","#3f3f46"),
"ok":("#16a34a","#22c55e"),
"wn":("#d97706","#f59e0b"),
}
def _h2r(h):
h=h.lstrip("#");return tuple(int(h[i:i+2],16) for i in(0,2,4))
def _r2h(r):
return"#%02x%02x%02x"%tuple(max(0,min(255,int(v)))for v in r)
def _lerp_c(a,b,t):
t=max(0.0,min(1.0,t))
la,da=a if isinstance(a,tuple)else(a,a)
lb,db=b if isinstance(b,tuple)else(b,b)
ra,rb=_h2r(la),_h2r(lb);ra2,rb2=_h2r(da),_h2r(db)
ol=_r2h(tuple(ra[i]+(rb[i]-ra[i])*t for i in range(3)))
od=_r2h(tuple(ra2[i]+(rb2[i]-ra2[i])*t for i in range(3)))
return(ol,od)
class CE(Exception):pass
class CT:
def __init__(s):s._e=threading.Event()
def cancel(s):s._e.set()
def is_cancelled(s):return s._e.is_set()
def check(s):
if s._e.is_set():raise CE("用户取消")
@dataclass
class DT:
url:str
dest:Path
expected_size:int|None=None
sha1:str|None=None
class PT:
def __init__(s):
s.lock=threading.Lock();s.cw=0.0;s.w=0.0;s.pn="";s.pt=0;s.pd=0;s.gd=0
def bp(s,name,w,tb=0):
with s.lock:s.cw+=s.w;s.w=w;s.pn=name;s.pt=tb;s.pd=0
def ad(s,n):
with s.lock:s.pd+=n;s.gd+=n
def ep(s):
with s.lock:s.cw+=s.w;s.w=0.0;s.pn="";s.pt=0;s.pd=0
def _fl(s):
b=s.cw
if s.pt>0:b+=s.w*min(1.0,s.pd/s.pt)
else:b+=s.w*(0.9 if s.pd>0 else 0.25)
return min(1.0,b)
def sn(s):
with s.lock:return{"f":s._fl(),"n":s.pn,"pd":s.pd,"pt":s.pt,"gd":s.gd}
class SM:
def __init__(s,w=2.0):s.w=w;s.ss=deque()
def smp(s,t):
now=time.monotonic();s.ss.append((now,t));c=now-s.w
while s.ss and s.ss[0][0]<c:s.ss.popleft()
def sp(s):
if len(s.ss)<2:return 0.0
t0,b0=s.ss[0];t1,b1=s.ss[-1];dt=t1-t0
if dt<0.25:return 0.0
return max(0.0,(b1-b0)/dt)
def r(s):s.ss.clear()
def fs(b):
if b<=0:return"--"
if b<1024:return f"{b:.0f} B/s"
if b<1048576:
v=b/1024;return f"{v:.0f} KB/s" if v>=10 else f"{v:.1f} KB/s"
if b<1073741824:
v=b/1048576;return f"{v:.1f} MB/s" if v>=10 else f"{v:.2f} MB/s"
v=b/1073741824;return f"{v:.2f} GB/s"
def fe(s):
if s<=0 or s>18000 or math.isinf(s):return"--"
t=int(s)
if t<=60:return f"{t}s"
if t<120:
m,x=divmod(t,60);return f"{m}m{x:02d}s"
m,_=divmod(t,60)
if m<60:return f"{m}m"
h,m=divmod(m,60);return f"{h}h" if m==0 else f"{h}h{m:02d}m"
def gon():
s=platform.system().lower();return"mac" if s=="darwin" else s
def gan():
m=platform.machine().lower()
if m in("amd64","x86_64"):return"x64"
if m in("arm64","aarch64"):return"aarch64"
if m in("x86","i386","i686"):return"x86"
return"x64"
def gab():return"64" if platform.machine().lower() in("amd64","x86_64","arm64","aarch64") else"32"
def ma():
m=platform.machine().lower()
if m in("amd64","x86_64"):return"x86_64"
if m in("arm64","aarch64"):return"arm64"
if m in("x86","i386","i686"):return"x86"
return m
def cs():return";" if platform.system()=="Windows" else":"
def ed(p):p.mkdir(parents=True,exist_ok=True);return p
def sh(p):
h=hashlib.sha1()
with open(p,"rb") as f:
for c in iter(lambda:f.read(262144),b""):h.update(c)
return h.hexdigest()
def hg(u,t=30):
r=requests.get(u,timeout=t,headers={"User-Agent":f"{N}/1.0"});r.raise_for_status();return r
def df(u,d,sz=None,h=None,retries=3,ct=None,oc=None):
ed(d.parent)
if d.exists():
ls=d.stat().st_size;ok=(sz is None)or(ls==sz)
if ok and ls>0:
if h is not None:
try:
if sh(d)==h:
if oc:oc(ls)
return d
try:d.unlink()
except:pass
except:
try:d.unlink()
except:pass
else:
if oc:oc(ls)
return d
pt=d.with_name(d.name+".part");le=None
for a in range(retries):
if ct and ct.is_cancelled():raise CE("用户取消")
try:
with requests.get(u,stream=True,timeout=(10,10),headers={"User-Agent":f"{N}/1.0"}) as r:
r.raise_for_status()
with open(pt,"wb") as f:
for c in r.iter_content(chunk_size=65536):
if ct and ct.is_cancelled():raise CE("用户取消")
if c:
f.write(c)
if oc:oc(len(c))
if sz is not None and pt.stat().st_size!=sz:raise RuntimeError(f"大小不符: {pt.stat().st_size} != {sz}")
if h is not None and sh(pt)!=h:raise RuntimeError("sha1 校验失败")
os.replace(pt,d);return d
except CE:
if pt.exists():
try:pt.unlink()
except:pass
raise
except Exception as e:
le=e
if a<retries-1:
for _ in range(10):
if ct and ct.is_cancelled():break
time.sleep(0.1)
if pt.exists():
try:pt.unlink()
except:pass
if le is not None:raise le
raise RuntimeError("下载失败")
def iw(b,t):
try:t.resolve().relative_to(b.resolve());return True
except:return False
def sz(z,d):
for n in z.namelist():
if iw(d,d/n):z.extract(n,d)
def st(t,d):
for m in t.getmembers():
if not iw(d,d/m.name):continue
if not(m.isdir() or m.isfile()):continue
try:t.extract(m,d,filter="data")
except TypeError:t.extract(m,d)
def co():
s=platform.system().lower();return"osx" if s=="darwin" else s
def ra(r):
if not r:return True
o=co();a=ma();ok=False
for x in r:
ac=x.get("action")=="allow";o2=x.get("os")or{};on=o2.get("name");oa=o2.get("arch");mt=True
if on is not None and on!=o:mt=False
if oa is not None and oa!=a:mt=False
ft=x.get("features")
if ft:
for v in ft.values():
if v:mt=False;break
if mt:ok=ac
return ok
def ea(al):
o=[]
for a in al:
if isinstance(a,str):o.append(a)
elif isinstance(a,dict):
if ra(a.get("rules",[])):
v=a.get("value")
if isinstance(v,str):o.append(v)
elif isinstance(v,list):o.extend(str(x) for x in v)
return o
class Cfg:
DEF={"game_dir":str(Path.home()/"Minecraft"),"username":"Player","selected_version":None,"jvm_xmx":"2G","preferred_java_major":None,"theme":"dark"}
def __init__(s):
ed(D);s.d=dict(s.DEF)
if CF.exists():
try:
with open(CF,"r",encoding="utf-8") as f:s.d.update(json.load(f))
except:pass
def get(s,k):return s.d.get(k,s.DEF.get(k))
def set(s,k,v):s.d[k]=v;s.sv()
def sv(s):
try:
with open(CF,"w",encoding="utf-8") as f:json.dump(s.d,f,indent=2,ensure_ascii=False)
except:pass
class JM:
def __init__(s,cfg):
s.cfg=cfg;s.jd=ed(D/"java");s._bc=None;s.be=s._fbj()
def _fab(s):
if s._bc is not None:return s._bc
e="java.exe" if platform.system()=="Windows" else "java";r={}
for p in s.jd.rglob(e):
if not p.is_file():continue
try:m=s.jmv(str(p))
except:m=None
if m is None:continue
if m not in r or len(str(p))<len(str(r[m])):r[m]=p
s._bc=r;return r
def _ic(s):s._bc=None;s.be=s._fbj()
def _fbj(s):
c=s._fab()
if not c:return None
return c[max(c.keys())]
def ffm(s,m):return s._fab().get(m)
def dsj(s):
e="java.exe" if platform.system()=="Windows" else "java";return shutil.which(e)
def av(s):
if s.be:return str(s.be)
return s.dsj()
def jmv(s,p):
try:
o=subprocess.check_output([p,"-version"],stderr=subprocess.STDOUT,text=True)
m=re.search(r'version "(\d+)(?:\.(\d+))?',o)
if not m:return None
ma_=int(m.group(1))
if ma_==1 and m.group(2):ma_=int(m.group(2))
return ma_
except:return None
def rmfi(s,vid):
try:
ps=vid.split(".");mi=int(ps[1])if len(ps)>1 else 0;pa=int(ps[2])if len(ps)>2 and ps[2].isdigit()else 0
except:return 21
if mi>=21:return 21
if mi==20:return 21 if pa>=5 else 17
if mi in(17,18,19):return 17
return 8
def rmr(s,vj):
jv=vj.get("javaVersion")
if isinstance(jv,dict)and"majorVersion"in jv:return int(jv["majorVersion"])
return s.rmfi(vj.get("id",""))
def rj(s,vj):
rq=s.rmr(vj);pf=s.cfg.get("preferred_java_major")
if pf is not None:
t=int(pf);p=s.ffm(t)
if p:return str(p),t,rq
return None,t,rq
p=s.ffm(rq)
if p:return str(p),rq,rq
c=s._fab();cs_=sorted(m for m in c if m>=rq)
if cs_:
m=cs_[0];return str(c[m]),m,rq
sj=s.dsj()
if sj:
m=s.jmv(sj);return sj,m,rq
return None,None,rq
def _btmu(s,pn,mj):
m=re.search(r'_(x64|aarch64|arm)_(windows|linux|mac)',pn)
if not m:return None
ar,ow=m.group(1),m.group(2)
am={"x64":"x64","aarch64":"aarch64","arm":"arm"};om={"windows":"windows","linux":"linux","mac":"mac"}
a=am.get(ar);o=om.get(ow)
if not a or not o:return None
return f"https://mirrors.tuna.tsinghua.edu.cn/Adoptium/{mj}/jre/{a}/{o}/{pn}"
def dj(s,mj,tr=None,ct=None,lc=None):
lg=lc or(lambda m:None);tr=tr or PT()
ex=s.ffm(mj)
if ex:lg(f"Java {mj} 已存在: {ex}");return ex
tr.bp(f"获取 Java {mj} 下载地址",0.02,0);lg(f"请求 Java {mj} 下载地址...")
if ct:ct.check()
u=AU.format(java_major=mj,os=gon(),arch=gan());info=hg(u).json()
if not info:raise RuntimeError(f"Adoptium 没有返回 Java {mj} 的可用构建")
b=info[0]["binary"];pk=b["package"];pu=pk["link"];pn=pk["name"];ps=pk.get("size")or DS
tr.ep();tr.bp(f"下载 Java {mj}",0.93,ps);lg(f"下载 Java {mj} ({pn})...")
ar=s.jd/pn;mi=s._btmu(pn,mj);ok=False
if mi:
lg(f"尝试国内镜像: {mi}")
try:
df(mi,ar,sz=pk.get("size"),ct=ct,oc=tr.ad);ok=True;lg("镜像下载成功")
except Exception as e:lg(f"镜像下载失败: {e},回退到官方源")
if not ok:
lg("从官方源下载...");df(pu,ar,sz=pk.get("size"),ct=ct,oc=tr.ad)
tr.ep();tr.bp("解压 Java",0.05,0)
if ct:ct.check()
if platform.system()=="Windows":
with zipfile.ZipFile(ar,"r") as z:sz(z,s.jd)
else:
with tarfile.open(ar,"r:gz") as t:st(t,s.jd)
tr.ep();s._ic();p=s.ffm(mj)
if not p:raise RuntimeError(f"解压后未找到 Java {mj} 的可执行文件")
if platform.system()!="Windows":os.chmod(p,0o755)
return p
class VM:
def __init__(s,cfg,jm):
s.cfg=cfg;s.jm=jm;s._m=None;s._cc={};s.sgd(cfg.get("game_dir"))
def sgd(s,p):
s.gd=Path(p);s.vd=ed(s.gd/"versions");s.ld=ed(s.gd/"libraries");s.ad=ed(s.gd/"assets");s.nr=ed(s.gd/"natives");s._cc.clear()
def mn(s):
if s._m is None:s._m=hg(MU).json()
return s._m
def lv(s):return s.mn().get("versions",[])
def vj(s,vid):
vd=s.vd/vid;vjf=vd/f"{vid}.json"
if vjf.exists():
try:
with open(vjf,"r",encoding="utf-8") as f:return json.load(f)
except:pass
for v in s.lv():
if v["id"]==vid:
d=hg(v["url"]).json();ed(vd)
with open(vjf,"w",encoding="utf-8") as f:json.dump(d,f,indent=2)
return d
raise RuntimeError(f"找不到版本 {vid}")
def ii(s,vid):
vd=s.vd/vid;return(vd/f"{vid}.json").exists()and(vd/f"{vid}.jar").exists()
def iv(s):
if not s.vd.exists():return[]
o=[p.name for p in s.vd.iterdir()if p.is_dir()and s.ii(p.name)];return sorted(o)
def dv(s,vid):
d=s.vd/vid
if d.exists():shutil.rmtree(d,ignore_errors=True)
s._cc.pop(vid,None);return True
def _idl(s,t):
if not t.dest.exists():return False
z=t.dest.stat().st_size
if t.expected_size is not None:return z==t.expected_size
return z>0
def _clt(s,vj):
ts=[]
for lb in vj.get("libraries",[]):
if not ra(lb.get("rules",[])):continue
dl=lb.get("downloads",{});at=dl.get("artifact")
if at:ts.append(DT(at["url"],s.ld/at["path"],at.get("size"),at.get("sha1")))
ck=(lb.get("natives",{})or{}).get(co())
if ck:
ck=ck.replace("${arch}",gab());cl=dl.get("classifiers",{}).get(ck)
if cl:ts.append(DT(cl["url"],s.ld/cl["path"],cl.get("size"),cl.get("sha1")))
return ts
def inst(s,vid,tr=None,ct=None,lc=None):
lg=lc or(lambda m:None);tr=tr or PT()
tr.bp("读取版本元数据",0.01,0)
if ct:ct.check()
vj=s.vj(vid);vd=ed(s.vd/vid);tr.ep()
ci=vj["downloads"]["client"];ctk_=DT(ci["url"],vd/f"{vid}.jar",ci.get("size"),ci.get("sha1"))
td=[]if s._idl(ctk_)else[ctk_]
tr.bp(f"下载客户端 {vid}.jar",0.03,sum(t.expected_size or DS for t in td));s._par(td,lg,tr,ct);tr.ep()
ai=vj["assetIndex"];ip=ed(s.ad/"indexes")/f"{ai['id']}.json"
it=DT(ai["url"],ip,ai.get("size"),ai.get("sha1"))
td=[]if s._idl(it)else[it]
tr.bp("下载资源索引",0.02,sum(t.expected_size or DS for t in td));s._par(td,lg,tr,ct);tr.ep()
with open(ip,"r",encoding="utf-8") as f:id_=json.load(f)
objs=id_.get("objects",{});od=ed(s.ad/"objects");at=[]
for _,mt in objs.items():
h=mt["hash"];sz_=int(mt.get("size",-1))
t=DT(f"https://resources.download.minecraft.net/{h[:2]}/{h}",od/h[:2]/h,sz_ if sz_>0 else None,h)
if not s._idl(t):at.append(t)
tr.bp(f"下载资源文件 ({len(at)} 个)",0.68,sum(t.expected_size or DS for t in at));s._par(at,lg,tr,ct);tr.ep()
la=s._clt(vj);lt=[t for t in la if not s._idl(t)]
tr.bp("下载库文件",0.22,sum(t.expected_size or DS for t in lt));s._par(lt,lg,tr,ct);tr.ep()
tr.bp("解压原生库",0.03,0)
if ct:ct.check()
s._xn(vj,vid);tr.ep()
(vd/".complete").write_text("ok",encoding="utf-8")
lg(f"版本 {vid} 安装完成 ✓")
def _par(s,ts,lg,tr,ct,mw=8):
total=len(ts)
if total==0:return
dn=0;futs=[];cc=False;e0=None
try:
with ThreadPoolExecutor(max_workers=mw) as ex:
for t in ts:
if ct and ct.is_cancelled():cc=True;break
futs.append(ex.submit(s._rd,t,tr,ct))
for fu in as_completed(futs):
dn+=1
if dn%20==0 or dn==total:lg(f"进度 {dn}/{total}")
try:fu.result()
except CE:
cc=True
if ct:ct.cancel()
except Exception as e:
if e0 is None:e0=e
lg(f" [error] 下载失败: {e}")
finally:
for f in futs:
if not f.done():f.cancel()
else:
try:f.result()
except:pass
if cc or(ct and ct.is_cancelled()):raise CE("用户取消")
if e0 is not None:raise RuntimeError(f"有文件下载失败,安装未完成。首个错误: {e0}")
def _rd(s,t,tr,ct):df(t.url,t.dest,t.expected_size,t.sha1,ct=ct,oc=tr.ad)
def _xn(s,vj,vid):
no=ed(s.nr/vid)
for ch in no.iterdir():
try:
if ch.is_file():ch.unlink()
except:pass
for lb in vj.get("libraries",[]):
if not ra(lb.get("rules",[])):continue
ck=(lb.get("natives",{})or{}).get(co())
if not ck:continue
ck=ck.replace("${arch}",gab());cl=lb.get("downloads",{}).get("classifiers",{}).get(ck)
if not cl:continue
jp=s.ld/cl["path"]
if not jp.exists():continue
try:
with zipfile.ZipFile(jp,"r") as z:
for n in z.namelist():
if n.startswith("META-INF/"):continue
if n.endswith((".dll",".so",".dylib")):
if iw(no,no/n):z.extract(n,no)
except:pass
def _bm(s,vid):
if vid in s._cc:return s._cc[vid]
ch=[s.vj(vid)];cu=ch[-1]
while cu.get("inheritsFrom"):
cu=s.vj(cu["inheritsFrom"]);ch.append(cu)
mg={};lm={}
for v in reversed(ch):
for k,val in v.items():
if k=="libraries":continue
mg[k]=val
for lb in v.get("libraries",[]):
nm=lb.get("name","");ps=nm.split(":")
if len(ps)>=3:kp=[ps[0],ps[1]]+ps[3:];key=":".join(kp)
elif len(ps)>=2:key=f"{ps[0]}:{ps[1]}"
else:key=nm or str(id(lb))
lm[key]=lb
mg["libraries"]=list(lm.values());mg["_jar_version"]=ch[-1]["id"];s._cc[vid]=mg;return mg
def lch(s,vid,un,jp,xmx="2G",lc=None):
mg=s._bm(vid);vd=s.vd/vid;jv=mg.get("_jar_version",vid);jpth=s.vd/jv/f"{jv}.jar"
if not jpth.exists():jpth=vd/f"{vid}.jar"
if not jpth.exists():raise RuntimeError(f"缺少客户端 jar: {jpth}")
rq=s.jm.rmr(mg);ac=s.jm.jmv(jp)
if ac is not None and ac<rq:raise RuntimeError(f"Java 版本过低:{vid} 需要 Java {rq}+,当前是 {ac}。")
cpe=[];ml=[]
for lb in mg.get("libraries",[]):
if not ra(lb.get("rules",[])):continue
dl=lb.get("downloads",{})or{};at=dl.get("artifact")
if at and at.get("path"):
p=s.ld/at["path"];cpe.append(str(p))
if not p.exists():ml.append(str(p))
continue
nm=lb.get("name","")
if nm:
ps=nm.split(":")
if len(ps)>=3:
g,ai,v=ps[0],ps[1],ps[2];cl=f"-{ps[3]}" if len(ps)>3 else ""
rel=Path(*g.split("."))/ai/v/f"{ai}-{v}{cl}.jar";p=s.ld/rel
if p.exists():cpe.append(str(p))
else:ml.append(str(p))
continue
ml.append(f"<unresolved lib: {nm or 'anonymous'}>")
cpe.append(str(jpth));cp=cs().join(cpe)
if "lwjgl" not in cp.lower():raise RuntimeError("Classpath 中缺少 LWJGL 库,请重新下载版本。")
if ml and lc:lc(f"[警告] 有 {len(ml)} 个库文件不存在,可能导致崩溃。")
mc=mg["mainClass"];aid=mg["assetIndex"]["id"];nd=ed(s.nr/vid)
if "arguments" in mg:
ga=ea(mg["arguments"].get("game",[]));ja=ea(mg["arguments"].get("jvm",[]))
else:
ga=mg.get("minecraftArguments","").split();ja=[]
def sub(x):
return(x.replace("${auth_player_name}",un).replace("${version_name}",vid)
.replace("${game_directory}",str(s.gd)).replace("${assets_root}",str(s.ad))
.replace("${assets_index_name}",aid).replace("${auth_uuid}","00000000000000000000000000000000")
.replace("${auth_access_token}","0").replace("${clientid}","").replace("${xuid}","")
.replace("${user_type}","legacy").replace("${version_type}",mg.get("type","release"))
.replace("${natives_directory}",str(nd)).replace("${classpath}",cp)
.replace("${launcher_name}",N).replace("${launcher_version}","1.0"))
jc=[jp,f"-Xmx{xmx}",f"-Djava.library.path={nd}","-Dorg.lwjgl.util.Debug=true","-Dorg.lwjgl.util.DebugLoader=true"]
sk=False
for a in ja:
a2=sub(a)
if not a2:continue
if sk:sk=False;continue
if a2 in("-cp","-classpath"):sk=True;continue
if a2==cp or a2.startswith("-Djava.library.path") or a2.startswith("-Xmx"):continue
jc.append(a2)
jc+=["-cp",cp,mc]
fc=jc+[sub(a) for a in ga]
cl_=sum(len(a)+1 for a in fc)
if cl_>CLL:raise RuntimeError("启动命令行过长。")
cf=getattr(subprocess,"CREATE_NO_WINDOW",0)if platform.system()=="Windows" else 0
pr=subprocess.Popen(fc,cwd=str(s.gd),stdout=subprocess.PIPE,stderr=subprocess.STDOUT,creationflags=cf,text=True)
time.sleep(LPS);rc=pr.poll()
if rc is not None:
try:o,_=pr.communicate(timeout=3)
except:o=""
tl=(o or"").strip()[-3000:]
raise RuntimeError(f"游戏启动后立即退出(exit code={rc}):\n{tl or '(无输出)'}")
if lc:lc("游戏进程已启动 ✓")
return pr
class MM:
def __init__(s,cfg,vm):s.cfg=cfg;s.vm=vm
@property
def md(s):return ed(Path(s.cfg.get("game_dir"))/"mods")
def _dl(s):
vid=(s.cfg.get("selected_version")or"").lower()
if"neoforge"in vid:return"neoforge"
if"forge"in vid:return"forge"
if"quilt"in vid:return"quilt"
if"fabric"in vid:return"fabric"
return"fabric"
def se(s,q,gv=None,lim=20):
f=[["project_type:mod"]]
if gv:f.append([f"versions:{gv}"])
p={"query":q,"limit":lim,"facets":json.dumps(f)}
r=requests.get(MS,params=p,timeout=15,headers={"User-Agent":f"{N}/1.0"});r.raise_for_status();return r.json().get("hits",[])
def dll(s,pid,gv=None,ld=None,tr=None,ct=None):
tr=tr or PT()
if ld is None:ld=s._dl()
if ct:ct.check()
tr.bp(f"查询 {pid} 版本",0.03,0)
u=MV.format(pid=pid);p={"loaders":json.dumps([ld])}
if gv:p["game_versions"]=json.dumps([gv])
r=requests.get(u,params=p,timeout=15,headers={"User-Agent":f"{N}/1.0"});r.raise_for_status();vs=r.json()
if not vs:
p.pop("loaders",None);r=requests.get(u,params=p,timeout=15,headers={"User-Agent":f"{N}/1.0"});vs=r.json()
tr.ep()
if not vs:return None
fl=vs[0]["files"];tg=next((f for f in fl if f.get("primary")),fl[0]);d=s.md/tg["filename"]
hs=tg.get("hashes",{})or{};sz_=tg.get("size")or DS
tr.bp(f"下载 {tg['filename']}",0.97,sz_)
df(tg["url"],d,tg.get("size"),hs.get("sha1"),ct=ct,oc=tr.ad)
tr.ep();return d
def li(s):
if not s.md.exists():return[]
return sorted(p.name for p in s.md.iterdir()if p.suffix==".jar")
def cuf():
s=platform.system()
if s=="Windows":return"Microsoft YaHei UI"
if s=="Darwin":return"PingFang SC"
return"Noto Sans CJK SC"
class SF(ctk.CTkScrollableFrame):
def __init__(s,p,**kw):
kw.setdefault("fg_color",C["bg"])
kw.setdefault("corner_radius",0)
kw.setdefault("scrollbar_button_color",C["ch"])
kw.setdefault("scrollbar_button_hover_color",C["na"])
super().__init__(p,**kw)
def clear(s):
for w in s.winfo_children():w.destroy()
class App:
def __init__(s,root):
s.root=root;root.title(f"{N} - 免费开源的 Minecraft 启动器");root.geometry("1020x860");root.minsize(920,760)
s.cfg=Cfg();s.jm=JM(s.cfg);s.vm=VM(s.cfg,s.jm);s.mm=MM(s.cfg,s.vm)
s.tm=s.cfg.get("theme")
ctk.set_appearance_mode(s.tm);ctk.set_default_color_theme("blue")
s.fn=cuf()
s._pa=False;s._ct=None;s._cct=None;s._dp=0.0;s._sm=SM();s._lsu=0.0;s._tj=None
s._svr=tk.StringVar(value="");s._mi={}
s._toasts=[];s._toast_job=None;s._dlg=None
s._bui();s._rs()
def _bui(s):
fn=s.fn
root=s.root;root.configure(fg_color=C["bg"])
top=ctk.CTkFrame(root,fg_color="transparent");top.pack(fill="x",padx=22,pady=(20,10))
ctk.CTkLabel(top,text="⛏ MC Launcher",font=ctk.CTkFont(family=fn,size=22,weight="bold"),text_color=C["fg"]).pack(side="left")
s.sl=ctk.CTkLabel(top,text="初始化中...",font=ctk.CTkFont(family=fn,size=12),text_color=C["mu"],anchor="w")
s.sl.pack(side="left",padx=(20,0),fill="x",expand=True)
s.nf=ctk.CTkFrame(root,fg_color="transparent");s.nf.pack(fill="x",padx=22,pady=0)
s.nb={};s.pg={}
ni=[("启动",s._bpt),("版本下载",s._bvt),("Mod",s._bmt),("设置",s._bst)]
s.cf=ctk.CTkFrame(root,fg_color="transparent");s.cf.pack(fill="both",expand=True,padx=22,pady=10)
s.cf.grid_rowconfigure(0,weight=1);s.cf.grid_columnconfigure(0,weight=1)
for name,builder in ni:
btn=ctk.CTkButton(s.nf,text=name,fg_color="transparent",hover_color=C["ch"],text_color=C["mu"],font=ctk.CTkFont(family=fn,size=13),corner_radius=8,height=38,width=96,command=lambda n=name:s._swt(n))
btn.pack(side="left",padx=(0,4));s.nb[name]=btn
page=ctk.CTkFrame(s.cf,fg_color="transparent");page.grid(row=0,column=0,sticky="nsew");s.pg[name]=page;builder(page)
bt=ctk.CTkFrame(root,fg_color="transparent");bt.pack(fill="x",padx=22,pady=(0,20));bt.grid_columnconfigure(0,weight=1)
s._bpc(bt);s._bl(bt);s._swt("启动")
def _swt(s,name):
for n,p in s.pg.items():
if n==name:p.tkraise()
for n,btn in s.nb.items():
if n==name:btn.configure(fg_color=C["na"],text_color=C["ac"],font=ctk.CTkFont(family=s.fn,size=13,weight="bold"))
else:btn.configure(fg_color="transparent",text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=13))
p=s.pg[name]
for i in range(5):
s.root.after(i*22,lambda i=i,p=p:p.grid_configure(padx=0,pady=(10-i*2,0)))
# ---------- Toast ----------
def _toast(s,msg,kind="info"):
if s._dlg is not None:return
icon={"info":"ℹ","success":"✓","error":"✕","warn":"⚠"}.get(kind,"ℹ")
col={"info":C["ac"],"success":C["ok"],"error":C["dg"],"warn":C["wn"]}.get(kind,C["ac"])
f=ctk.CTkFrame(s.root,fg_color=C["cd"],corner_radius=12,border_width=2,border_color=col)
inner=ctk.CTkFrame(f,fg_color="transparent");inner.pack(padx=14,pady=10)
ctk.CTkLabel(inner,text=icon,text_color=col,font=ctk.CTkFont(family=s.fn,size=15,weight="bold")).pack(side="left",padx=(0,8))
ctk.CTkLabel(inner,text=msg,text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=12),anchor="w",wraplength=340,justify="left").pack(side="left")
f.place(relx=1.0,x=240,y=-80,anchor="ne");f.lift()
ent={"f":f,"y":-80.0,"xo":0.0,"born":time.monotonic(),"leaving":False}
s._toasts.insert(0,ent)
if s._toast_job is None:s._toast_tick()
def _toast_tick(s):
s._toast_job=None
if not s._toasts:return
now=time.monotonic();alive=[]
for i,ent in enumerate(s._toasts):
f=ent["f"]
if not f.winfo_exists():continue
ty=16+i*58
ent["y"]+=(ty-ent["y"])*0.28
if abs(ty-ent["y"])<0.5:ent["y"]=ty
if not ent["leaving"] and now-ent["born"]>3.0:
ent["leaving"]=True
xo=ent["xo"]
if ent["leaving"]:
xo+=(240-xo)*0.3;ent["xo"]=xo
if xo>220:
try:f.destroy()
except:pass
continue
try:f.place(relx=1.0,x=int(-20+xo),y=int(ent["y"]),anchor="ne")
except:pass
alive.append(ent)
s._toasts=alive
if alive:s._toast_job=s.root.after(16,s._toast_tick)
# ---------- 确认对话框 ----------
def _confirm(s,title,msg,on_yes,yes_text="确认",danger=False):
if s._dlg is not None:return
ov=ctk.CTkFrame(s.root,fg_color=C["bg"],corner_radius=0)
ov.place(relx=0,rely=0,relwidth=1,relheight=1);ov.lift()
dlg=ctk.CTkFrame(ov,fg_color=C["cd"],corner_radius=16,border_width=1,border_color=C["bd"])
dlg.place(relx=0.5,rely=0.5,anchor="center",y=24);dlg.lift()
ctk.CTkLabel(dlg,text=title,font=ctk.CTkFont(family=s.fn,size=16,weight="bold"),text_color=C["fg"],anchor="w").pack(fill="x",padx=24,pady=(22,6))
ctk.CTkLabel(dlg,text=msg,font=ctk.CTkFont(family=s.fn,size=12),text_color=C["mu"],anchor="w",justify="left",wraplength=400).pack(fill="x",padx=24,pady=(0,18))
br=ctk.CTkFrame(dlg,fg_color="transparent");br.pack(fill="x",padx=24,pady=(0,20))
ctk.CTkButton(br,text="取消",width=90,height=36,fg_color=C["ch"],hover_color=C["na"],text_color=C["fg"],corner_radius=8,font=ctk.CTkFont(family=s.fn,size=13),command=lambda:s._dialog_close(ov,dlg)).pack(side="right",padx=(8,0))
def yes():
s._dialog_close(ov,dlg);s.root.after(140,on_yes)
ctk.CTkButton(br,text=yes_text,width=90,height=36,fg_color=C["dg"] if danger else C["ac"],hover_color=C["dh"] if danger else C["ah"],text_color="#ffffff",corner_radius=8,font=ctk.CTkFont(family=s.fn,size=13),command=yes).pack(side="right")
s._dlg=(ov,dlg)
def step(i,n=14):
if not ov.winfo_exists():return
t=i/n;e=1-(1-t)**3
try:
ov.configure(fg_color=_lerp_c(C["bg"],("#9ca3af","#0b0b0e"),e))
dlg.place_configure(y=int(24*(1-e)))
except:return
if i<n:s.root.after(16,lambda:step(i+1,n))
step(0)
def _dialog_close(s,ov,dlg):
if s._dlg and s._dlg[0] is ov:s._dlg=None
def step(i,n=10):
if not ov.winfo_exists():return
t=i/n
try:
ov.configure(fg_color=_lerp_c(("#9ca3af","#0b0b0e"),C["bg"],t))
dlg.place_configure(y=int(24*t))
except:return
if i<n:s.root.after(16,lambda:step(i+1,n))
else:
try:ov.destroy()
except:pass
step(0)
def _flash_ver(s):
orig=C["bd"]
def step(i,n=6):
if i>n*2:
try:s.vmu.configure(border_color=orig)
except:pass
return
t=i/n;e=t if t<=1 else 2-t
try:s.vmu.configure(border_color=_lerp_c(C["ok"],orig,e))
except:return
s.root.after(70,lambda:step(i+1,n))
step(0)
# ---------- UI 构建 ----------
def _bpc(s,p):
s.pf=ctk.CTkFrame(p,fg_color=C["cd"],corner_radius=14,border_width=1,border_color=C["bd"])
s.pf.grid(row=0,column=0,sticky="ew",pady=(0,10));s.pf.grid_columnconfigure(0,weight=1)
tr=ctk.CTkFrame(s.pf,fg_color="transparent");tr.grid(row=0,column=0,sticky="ew",padx=16,pady=(12,2));tr.grid_columnconfigure(0,weight=1)
s.pl=ctk.CTkLabel(tr,text="准备中...",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),anchor="w")
s.pl.grid(row=0,column=0,sticky="w")
s.cb=ctk.CTkButton(tr,text="停止",fg_color=C["dg"],hover_color=C["dh"],text_color="#ffffff",width=74,height=28,corner_radius=6,font=ctk.CTkFont(family=s.fn,size=12),command=s._ocd)
s.cb.grid(row=0,column=1,padx=(10,0))
s.ps=ctk.CTkLabel(s.pf,text="",text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=11),anchor="w")
s.ps.grid(row=1,column=0,sticky="ew",padx=16,pady=(0,6))
s.pb=ctk.CTkProgressBar(s.pf,progress_color=C["ac"],fg_color=C["ch"],height=8,corner_radius=4)
s.pb.grid(row=2,column=0,sticky="ew",padx=16,pady=(0,14));s.pb.set(0)
s.pf.grid_remove()
def _bl(s,p):
w=ctk.CTkFrame(p,fg_color="transparent");w.grid(row=1,column=0,sticky="nsew");w.grid_columnconfigure(0,weight=1);w.grid_rowconfigure(0,weight=1)
s.lt=ctk.CTkTextbox(w,height=140,fg_color=C["lb"],text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=12),corner_radius=10,border_width=1,border_color=C["bd"],wrap="word")
s.lt.grid(row=0,column=0,sticky="nsew")
s.lt.configure(state="disabled");s.lg("欢迎使用 MC Launcher。")
def _bpt(s,t):
cf=ctk.CTkFrame(t,fg_color="transparent");cf.pack(fill="both",expand=True,padx=40,pady=20)
card=ctk.CTkFrame(cf,fg_color=C["cd"],corner_radius=16,border_width=1,border_color=C["bd"]);card.pack(fill="x",pady=10)
inner=ctk.CTkFrame(card,fg_color="transparent");inner.pack(fill="x",padx=26,pady=26)
r=ctk.CTkFrame(inner,fg_color="transparent");r.pack(fill="x",pady=8)
ctk.CTkLabel(r,text="玩家昵称",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=80,anchor="w").pack(side="left")
s.uv=tk.StringVar(value=s.cfg.get("username"))
e=ctk.CTkEntry(r,textvariable=s.uv,fg_color=C["bg"],border_color=C["bd"],text_color=C["fg"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=13))
e.pack(side="left",fill="x",expand=True,padx=10)
e.bind("<FocusOut>",lambda _:s.cfg.set("username",s.uv.get()))
e.bind("<Return>",lambda _:s.cfg.set("username",s.uv.get()))
r=ctk.CTkFrame(inner,fg_color="transparent");r.pack(fill="x",pady=8)
ctk.CTkLabel(r,text="游戏版本",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=80,anchor="w").pack(side="left")
s.vv=tk.StringVar(value=s.cfg.get("selected_version")or"未选择")
s.vmu=ctk.CTkComboBox(r,values=["未选择"],variable=s.vv,state="readonly",fg_color=C["bg"],border_color=C["bd"],button_color=C["ch"],button_hover_color=C["na"],text_color=C["fg"],dropdown_fg_color=C["cd"],dropdown_text_color=C["fg"],dropdown_hover_color=C["ch"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=13),command=s._ovs)
s.vmu.pack(side="left",fill="x",expand=True,padx=10)
ctk.CTkButton(r,text="刷新",width=62,height=36,fg_color=C["ch"],hover_color=C["na"],text_color=C["fg"],corner_radius=8,font=ctk.CTkFont(family=s.fn,size=12),command=s._ri).pack(side="left",padx=(0,6))
ctk.CTkButton(r,text="删除",width=62,height=36,fg_color=C["dg"],hover_color=C["dh"],text_color="#ffffff",corner_radius=8,font=ctk.CTkFont(family=s.fn,size=12),command=s._delv).pack(side="left")
r=ctk.CTkFrame(inner,fg_color="transparent");r.pack(fill="x",pady=8)
ctk.CTkLabel(r,text="最大内存",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=80,anchor="w").pack(side="left")
s.xv=tk.StringVar(value=s.cfg.get("jvm_xmx"))
xm=ctk.CTkComboBox(r,values=["1G","2G","4G","6G","8G","16G"],variable=s.xv,state="readonly",width=100,fg_color=C["bg"],border_color=C["bd"],button_color=C["ch"],button_hover_color=C["na"],text_color=C["fg"],dropdown_fg_color=C["cd"],dropdown_text_color=C["fg"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=13),command=lambda _:s.cfg.set("jvm_xmx",s.xv.get()))
xm.pack(side="left",padx=10)
s.lbtn=ctk.CTkButton(cf,text="▶ 启动游戏",height=58,font=ctk.CTkFont(family=s.fn,size=18,weight="bold"),fg_color=C["ac"],hover_color=C["ah"],text_color="#ffffff",corner_radius=14,command=s._ol)
s.lbtn.pack(fill="x",pady=30)
def _bvt(s,t):
top=ctk.CTkFrame(t,fg_color="transparent");top.pack(fill="x",pady=(0,10))
ctk.CTkLabel(top,text="筛选:",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13)).pack(side="left")
s.fv=tk.StringVar(value="release")
fm=ctk.CTkComboBox(top,values=["release","snapshot","old_beta","old_alpha"],variable=s.fv,state="readonly",width=140,fg_color=C["bg"],border_color=C["bd"],button_color=C["ch"],button_hover_color=C["na"],text_color=C["fg"],dropdown_fg_color=C["cd"],dropdown_text_color=C["fg"],corner_radius=8,height=34,font=ctk.CTkFont(family=s.fn,size=13),command=lambda _:s._lvl())
fm.pack(side="left",padx=8)
ctk.CTkButton(top,text="加载列表",height=34,fg_color=C["ac"],hover_color=C["ah"],text_color="#ffffff",corner_radius=8,font=ctk.CTkFont(family=s.fn,size=13),command=s._lvl).pack(side="left")
s.il=ctk.CTkLabel(t,text="",text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=12),anchor="w");s.il.pack(fill="x",pady=(0,8))
s.vlf=SF(t);s.vlf.pack(fill="both",expand=True,pady=5)
s.dvb=ctk.CTkButton(t,text="下载选中版本",height=42,fg_color=C["ac"],hover_color=C["ah"],text_color="#ffffff",corner_radius=10,font=ctk.CTkFont(family=s.fn,size=14,weight="bold"),command=s._odv)
s.dvb.pack(fill="x",pady=(10,0))
def _bmt(s,t):
top=ctk.CTkFrame(t,fg_color="transparent");top.pack(fill="x",pady=(0,10))
ctk.CTkLabel(top,text="搜索 Mod:",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13)).pack(side="left")
s.msv=tk.StringVar()
se=ctk.CTkEntry(top,textvariable=s.msv,fg_color=C["bg"],border_color=C["bd"],text_color=C["fg"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=13))
se.pack(side="left",fill="x",expand=True,padx=8)
se.bind("<Return>",lambda _:s._oms())
ctk.CTkButton(top,text="搜索",height=36,fg_color=C["ac"],hover_color=C["ah"],text_color="#ffffff",corner_radius=8,font=ctk.CTkFont(family=s.fn,size=13),command=s._oms).pack(side="left")
ctk.CTkLabel(t,text="搜索结果",text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=12),anchor="w").pack(anchor="w")
s.mr=SF(t);s.mr.pack(fill="both",expand=True,pady=4)
ctk.CTkLabel(t,text="已安装的 Mod",text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=12),anchor="w").pack(anchor="w",pady=(10,0))
s.im=SF(t);s.im.pack(fill="both",expand=True,pady=(4,0))
s._rim()
def _bst(s,t):
r=ctk.CTkFrame(t,fg_color="transparent");r.pack(fill="x",pady=10)
ctk.CTkLabel(r,text="游戏目录:",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=100,anchor="w").pack(side="left")
s.gdv=tk.StringVar(value=s.cfg.get("game_dir"))
ctk.CTkEntry(r,textvariable=s.gdv,fg_color=C["bg"],border_color=C["bd"],text_color=C["fg"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=13)).pack(side="left",fill="x",expand=True,padx=8)
ctk.CTkButton(r,text="浏览...",width=80,height=36,fg_color=C["ch"],hover_color=C["na"],text_color=C["fg"],corner_radius=8,font=ctk.CTkFont(family=s.fn,size=13),command=s._pgd).pack(side="left")
r=ctk.CTkFrame(t,fg_color="transparent");r.pack(fill="x",pady=10)
ctk.CTkLabel(r,text="Java 版本:",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=100,anchor="w").pack(side="left")
pf=s.cfg.get("preferred_java_major")
dp=next((k for k,v in JVM.items() if v==pf),"自动")
s.jvv=tk.StringVar(value=dp)
s.jvm=ctk.CTkComboBox(r,values=JVO,variable=s.jvv,state="readonly",width=140,fg_color=C["bg"],border_color=C["bd"],button_color=C["ch"],button_hover_color=C["na"],text_color=C["fg"],dropdown_fg_color=C["cd"],dropdown_text_color=C["fg"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=13),command=s._ojs)
s.jvm.pack(side="left",padx=8)
s.djb=ctk.CTkButton(r,text="下载 Java",width=110,height=36,fg_color=C["ac"],hover_color=C["ah"],text_color="#ffffff",corner_radius=8,font=ctk.CTkFont(family=s.fn,size=13),command=s._odsj)
s.djb.pack(side="left",padx=8)
r=ctk.CTkFrame(t,fg_color="transparent");r.pack(fill="x",pady=10)
ctk.CTkLabel(r,text="外观主题:",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=100,anchor="w").pack(side="left")
s.tv=tk.StringVar(value="深色模式" if s.tm=="dark" else "浅色模式")
tm=ctk.CTkComboBox(r,values=["深色模式","浅色模式"],variable=s.tv,state="readonly",width=140,fg_color=C["bg"],border_color=C["bd"],button_color=C["ch"],button_hover_color=C["na"],text_color=C["fg"],dropdown_fg_color=C["cd"],dropdown_text_color=C["fg"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=13),command=s._otc)
tm.pack(side="left",padx=8)
r=ctk.CTkFrame(t,fg_color="transparent");r.pack(fill="x",pady=10)
ctk.CTkLabel(r,text="已安装 Java:",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=100,anchor="nw").pack(side="left",anchor="n")
s.jll=ctk.CTkLabel(r,text="",text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=12),justify="left",anchor="w")
s.jll.pack(side="left",fill="x",expand=True,padx=8)
r=ctk.CTkFrame(t,fg_color="transparent");r.pack(fill="x",pady=10)
ctk.CTkLabel(r,text="系统 Java:",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=13),width=100,anchor="w").pack(side="left")
s.jpv=tk.StringVar()
ctk.CTkEntry(r,textvariable=s.jpv,state="readonly",fg_color=C["bg"],border_color=C["bd"],text_color=C["mu"],corner_radius=8,height=36,font=ctk.CTkFont(family=s.fn,size=12)).pack(side="left",fill="x",expand=True,padx=8)
info=ctk.CTkFrame(t,fg_color=C["cd"],corner_radius=14,border_width=1,border_color=C["bd"]);info.pack(fill="both",expand=True,pady=20)
it=("说明:\n"
" · 首次使用前请先在「版本下载」页下载一个游戏版本。\n"
" · 启动器使用离线模式(仅本地昵称),不会联机验证正版。\n"
" · Mod 来自 Modrinth,会按所选版本推断 Fabric/Forge/NeoForge 加载器。\n"
" · 任何下载任务进行中都可以点进度条旁的「停止」取消。\n"
" · 在「启动」页可一键删除已安装的版本。\n\n"
"Java 版本推荐:\n"
" · Minecraft 1.20.5+ → Java 21\n"
" · Minecraft 1.17 ~ 1.20.4 → Java 17\n"
" · Minecraft 1.16 及以下 → Java 8\n"
" · 选「自动」时会按游戏版本自动选择最合适的 Java。")
ctk.CTkLabel(info,text=it,justify="left",text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=12),anchor="w").pack(anchor="w",padx=16,pady=16)
def _otc(s,_=None):
ch=s.tv.get();nt="dark" if ch=="深色模式" else "light"
if nt==s.tm:return
s.tm=nt;s.cfg.set("theme",nt);ctk.set_appearance_mode(nt)
s._toast(f"外观已切换为{ch}","info")
def _sp(s,title="准备中...",tr=None):
s._pa=True;s._dp=0.0;s._lsu=0.0;s.pb.set(0);s.pl.configure(text=title);s.ps.configure(text="")
s.cb.configure(state="normal",text="停止");s._sm.r();s._ct=tr
if not s.pf.winfo_ismapped():s.pf.grid()
s._sct()
def _sct(s):
if s._tj:
try:s.root.after_cancel(s._tj)
except:pass
s._tj=s.root.after(TM,s._tp)
def _tp(s):
s._tj=None
if not s._pa:return
tr=s._ct
if tr is None:s._sct();return
sn=tr.sn();tg=sn["f"];di=tg-s._dp
k=min(0.55,0.12+di*0.9)if di>0 else 0.25
s._dp+=di*k
if abs(tg-s._dp)<0.002:s._dp=tg
s.pb.set(s._dp)
now=time.monotonic()
if now-s._lsu>=SUM/1000.0:
s._lsu=now;s._sm.smp(sn["gd"]);spd=s._sm.sp();nm=sn["n"]or"下载中...";pc=tg*100.0
if sn["pt"]>0 and spd>0:
rm=max(0,sn["pt"]-sn["pd"]);es="即将完成" if rm<=0 else fe(rm/spd)
elif sn["pt"]==0 and nm:es="处理中"
else:es="--"
s.pl.configure(text=nm);s.ps.configure(text=f"{pc:5.1f}% · ⬇ {fs(spd)} · ⏱ {es}")
s._sct()
def _ep(s,ok=True):
s._pa=False
if s._tj:
try:s.root.after_cancel(s._tj)
except:pass
s._tj=None
if ok:
s._dp=1.0;s.pb.set(1.0);s.ps.configure(text="100.0% · ✓ 完成");d=500
else:d=100
s.root.after(d,s._dhp)
def _dhp(s):s._ct=None;s.pf.grid_remove()
def _ocd(s):
if s._cct:s._cct.cancel();s.cb.configure(state="disabled",text="停止中");s.pl.configure(text="正在取消...")
def lg(s,m):
def ap():
s.lt.configure(state="normal");s.lt.insert("end",m+"\n");s.lt.see("end");s.lt.configure(state="disabled")
s.root.after(0,ap)
def _rs(s):
j=s.jm.av()or"未找到";s.jpv.set(j)
s.sl.configure(text=f"📁 游戏目录: {s.cfg.get('game_dir')} | ☕ Java: {j}")
s._ri();s._rjl()
def _rjl(s):
c=s.jm._fab()
if not c:s.jll.configure(text="(无,请点击右侧按钮下载)");return
lines=[f"Java {m} — {c[m]}" for m in sorted(c.keys())]
s.jll.configure(text="\n".join(lines))
def _ri(s):
v=s.vm.iv()
if not v:
s.vmu.configure(values=["未选择"]);s.vv.set("未选择");s.il.configure(text="尚未安装任何版本,请到「版本下载」页下载。");return
s.vmu.configure(values=v)
cu=s.cfg.get("selected_version")
if cu in v:s.vv.set(cu)
else:s.vv.set(v[0]);s.cfg.set("selected_version",v[0])
s.il.configure(text=f"已安装版本: {', '.join(v)}")
def _ovs(s,_=None):s.cfg.set("selected_version",s.vv.get())
def _pgd(s):
d=filedialog.askdirectory(initialdir=s.cfg.get("game_dir"))
if d:s.gdv.set(d);s.cfg.set("game_dir",d);s.vm.sgd(d);s._ri();s._toast(f"游戏目录已切换","info")
def _ojs(s,_=None):
dp=s.jvv.get();m=JVM.get(dp);s.cfg.set("preferred_java_major",m)
if m is None:s.djb.configure(text="下载 Java")
else:s.djb.configure(text=f"下载 Java {m}")
def _odsj(s):
dp=s.jvv.get();m=JVM.get(dp)
if m is None:
v=s.cfg.get("selected_version")
if not v:s._toast("请先选择一个游戏版本","warn");return
try:vj=s.vm.vj(v);m=s.jm.rmr(vj)
except Exception as e:s._toast(f"读取版本信息失败: {e}","error");return
s._odj(int(m))
def _odj(s,m):
if s._pa:s._toast("已有下载任务进行中","warn");return
ex=s.jm.ffm(m)
if ex:s._toast(f"Java {m} 已经安装过了","info");return
tk=CT();tr=PT();s._cct=tk;s._sp(f"准备下载 Java {m}",tr)
def w():
ok=False
try:
p=s.jm.dj(m,tr=tr,ct=tk,lc=s.lg);ok=True;s.lg(f"Java {m} 已就绪: {p}")
s.root.after(0,s._rs);s.root.after(0,lambda:s._toast(f"Java {m} 下载完成","success"))
except CE:s.root.after(0,lambda:s._toast(f"已取消 Java {m} 下载","warn"))
except Exception as e:
er=str(e);s.lg(f"下载 Java {m} 失败: {er}");s.root.after(0,lambda er=er:s._toast(f"Java 下载失败: {er[:60]}","error"))
finally:s.root.after(0,lambda:s._ep(ok));s._cct=None
threading.Thread(target=w,daemon=True).start()
def _lvl(s):
def w():
try:
vs=s.vm.lv();kd=s.fv.get();it=[v for v in vs if v.get("type")==kd];s.root.after(0,lambda:s._rvl(it[:80]))
except Exception as e:
er=str(e);s.root.after(0,lambda er=er:s._toast(f"加载版本列表失败: {er[:60]}","error"))
threading.Thread(target=w,daemon=True).start()
def _rvl(s,it):
s.vlf.clear();s._svr.set("")
if not it:s._toast("没有匹配该筛选的版本","info");return
for v in it:
rb=ctk.CTkRadioButton(s.vlf,text=f"{v['id']} ({v.get('type','')}) {v.get('releaseTime','')[:10]}",variable=s._svr,value=v["id"],text_color=C["fg"],fg_color=C["ac"],hover_color=C["ah"],font=ctk.CTkFont(family=s.fn,size=12),radiobutton_width=16,radiobutton_height=16)
rb.pack(fill="x",anchor="w",padx=14,pady=5)
def _odv(s):
vid=s._svr.get()
if not vid:s._toast("请先在列表中选择一个版本","warn");return
if s._pa:s._toast("已有下载任务进行中","warn");return
tk=CT();tr=PT();s._cct=tk;s._sp(f"准备安装 {vid}",tr);s.dvb.configure(state="disabled",text="安装中...")
def w():
ok=False
try:
s.vm.inst(vid,tr=tr,ct=tk,lc=s.lg);ok=True
s.lg(f"✅ 版本 {vid} 安装完成")
s.root.after(0,s._ri);s.root.after(0,lambda:s._toast(f"版本 {vid} 安装完成","success"));s.root.after(0,s._flash_ver)
except CE:s.root.after(0,lambda:s._toast(f"已取消安装 {vid}","warn"))
except Exception as e:
er=str(e);s.lg(f"安装失败: {er}");s.root.after(0,lambda er=er:s._toast(f"安装失败: {er[:60]}","error"))
finally:
s.root.after(0,lambda:s._ep(ok))
s.root.after(0,lambda:s.dvb.configure(state="normal",text="下载选中版本"))
s._cct=None
threading.Thread(target=w,daemon=True).start()
# ---------- 删除版本(动效) ----------
def _delv(s):
v=s.vv.get()
if not v or v=="未选择":s._toast("请先选择一个已安装的版本","warn");return
s._confirm("删除版本",f"确定要删除版本「{v}」吗?\n\n这会删除 versions/{v} 目录下的全部文件,且无法恢复。",lambda:s._do_delv(v),yes_text="删除",danger=True)
def _do_delv(s,v):
s.lbtn.configure(state="disabled",text="删除中...")
def w():
try:
s.vm.dv(v)
if s.cfg.get("selected_version")==v:s.cfg.set("selected_version",None)
s.root.after(0,s._ri);s.root.after(0,s._rim)
s.root.after(0,lambda:s._toast(f"已删除版本 {v}","success"));s.root.after(0,s._flash_ver)
except Exception as e:
er=str(e);s.root.after(0,lambda er=er:s._toast(f"删除失败: {er[:60]}","error"))
finally:s.root.after(0,lambda:s.lbtn.configure(state="normal",text="▶ 启动游戏"))
threading.Thread(target=w,daemon=True).start()
def _oms(s):
q=s.msv.get().strip()
if not q:return
cv=s.cfg.get("selected_version");s.mr.clear();s._mi.clear()
def w():
try:
rs=s.mm.se(q,gv=cv);s.root.after(0,lambda:s._rmr(rs))
except Exception as e:
er=str(e);s.root.after(0,lambda er=er:s._toast(f"搜索 Mod 失败: {er[:60]}","error"))
threading.Thread(target=w,daemon=True).start()
def _rmr(s,rs):
s.mr.clear()
if not rs:s._toast("没有找到匹配的 Mod","info");return
for h in rs:
card=ctk.CTkFrame(s.mr,fg_color=C["cd"],corner_radius=10,border_width=1,border_color=C["bd"]);card.pack(fill="x",padx=4,pady=4)
il=ctk.CTkLabel(card,text="...",width=48,text_color=C["mu"]);il.pack(side="left",padx=(10,6),pady=10)
if h.get("icon_url"):threading.Thread(target=s._lmi,args=(h["icon_url"],il),daemon=True).start()
inf=ctk.CTkFrame(card,fg_color="transparent");inf.pack(side="left",fill="x",expand=True,pady=10)
ctk.CTkLabel(inf,text=h['title'],font=ctk.CTkFont(family=s.fn,size=13,weight="bold"),text_color=C["fg"],anchor="w").pack(fill="x")
ctk.CTkLabel(inf,text=(h.get('description')or'')[:80],text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=11),anchor="w",wraplength=620).pack(fill="x",pady=(2,0))
ctk.CTkButton(card,text="下载",width=72,height=32,fg_color=C["ac"],hover_color=C["ah"],text_color="#ffffff",corner_radius=8,font=ctk.CTkFont(family=s.fn,size=12),command=lambda pid=h["slug"]:s._odm(pid)).pack(side="right",padx=12,pady=10)
def _lmi(s,url,label):
try:
r=requests.get(url,timeout=10);data=r.content
def up():
img=Image.open(BytesIO(data)).resize((48,48),Image.Resampling.LANCZOS)
photo=ctk.CTkImage(light_image=img,dark_image=img,size=(48,48))
label.configure(image=photo,text="");label.image=photo;s._mi[url]=photo
s.root.after(0,up)
except Exception:s.root.after(0,lambda:label.configure(text="?"))
def _odm(s,ps):
if s._pa:s._toast("已有下载任务进行中","warn");return
cv=s.cfg.get("selected_version");tk=CT();tr=PT();s._cct=tk;s._sp(f"准备下载 {ps}",tr)
def w():
ok=False
try:
p=s.mm.dll(ps,gv=cv,tr=tr,ct=tk)
if p:
ok=True;s.lg(f"✅ 已保存到 {p}")
s.root.after(0,s._rim);s.root.after(0,lambda:s._toast(f"Mod 已安装: {p.name}","success"))
else:s.root.after(0,lambda:s._toast("没有找到匹配当前版本的 Mod","warn"))
except CE:s.root.after(0,lambda:s._toast("已取消 Mod 下载","warn"))
except Exception as e:
er=str(e);s.root.after(0,lambda er=er:s._toast(f"下载 Mod 失败: {er[:60]}","error"))
finally:s.root.after(0,lambda:s._ep(ok));s._cct=None
threading.Thread(target=w,daemon=True).start()
def _rim(s):
s.im.clear()
ms=s.mm.li()
if not ms:ctk.CTkLabel(s.im,text="(空)",text_color=C["mu"],font=ctk.CTkFont(family=s.fn,size=12)).pack(anchor="w",padx=12,pady=6);return
for nm in ms:ctk.CTkLabel(s.im,text=nm,text_color=C["fg"],font=ctk.CTkFont(family=s.fn,size=12)).pack(anchor="w",padx=12,pady=3)
def _ol(s):
un=s.uv.get().strip()or"Player";v=s.vv.get()
if not v or v=="未选择":s._toast("请先在「版本下载」页下载并选择一个版本","warn");return
xmx=s.xv.get()
def w():
try:
s.root.after(0,lambda:s.lbtn.configure(state="disabled",text="启动中..."));s.lg(f"正在启动 {v} (用户: {un}) ...")
vj=s.vm.vj(v);jp,jm_,rq=s.jm.rj(vj)
if not jp:s.root.after(0,lambda:s._adj(rq));return
if jm_ is not None and jm_<rq:s.root.after(0,lambda:s._auj(rq,jm_));return
s.lg(f"使用 Java: {jp} (major={jm_}, 需要={rq})")
pr=s.vm.lch(v,un,jp,xmx=xmx,lc=s.lg)
s.root.after(0,lambda:s._toast(f"游戏已启动: {v}","success"))
def ro():
try:
for line in pr.stdout:s.lg(line.rstrip())
except:pass
threading.Thread(target=ro,daemon=True).start()
except Exception as e:
er=str(e);s.lg(f"启动失败: {er}");s.root.after(0,lambda er=er:s._toast(f"启动失败: {er[:60]}","error"))
finally:s.root.after(0,lambda:s.lbtn.configure(state="normal",text="▶ 启动游戏"))
threading.Thread(target=w,daemon=True).start()
def _adj(s,rq):
s._confirm("缺少 Java",f"当前未安装 Java {rq},是否自动下载?\n(下载后会保存在 {D/'java'} 里)",lambda:s._odj(rq),yes_text="下载")
def _auj(s,rq,ac):
s._confirm("Java 版本过低",f"该版本需要 Java {rq}+,当前 Java 是 {ac}。\n是否自动下载 Java {rq}?",lambda:s._odj(rq),yes_text="下载")
def main():
root=ctk.CTk();App(root);root.mainloop()
if __name__=="__main__":main()
如果您没有下载任何游戏版本,先点版本下载,然后选择你要下载的版本,点击下载选中版本,下载好了之后回到启动界面,找到游戏版本选择,选择你下载的版本号,点击启动游戏,没下载Java会自动提示,不用手动下载,Java下载完毕后再点击一次启动游戏,静等3s~10s就可以启动成功了
目前这个启动器不支持卸载版本,如果您要卸载某个版本,请找到你的游戏目录的路径(设置里有),并找到一个 versions 文件夹,手动删除您的游戏版本
运行这段代码之前建议先Win+R运行
pip install requests pillow
AI还是太好用了哈哈哈哈哈哈
全部评论 8
彳亍,PCL也能用啊
1周前 来自 广东
4pcl不是被举报下架了吗
1周前 来自 重庆
2确实
3天前 来自 浙江
2
结果不会下载版本😅
1周前 来自 广东
3先点版本下载,然后选择你要下载的版本,点击下载选中版本,下载好了之后点击启动的游戏版本选择,选择你下载的版本号,点击启动游戏,没下载Java他会提示你自动下载,Java下完了就可以了
1周前 来自 重庆
3
dd
1周前 来自 湖北
2你在PCL提示更新时关掉再打开就行了
1周前 来自 广东
2什么c++还是python
1周前 来自 浙江
2Python
1周前 来自 重庆
2
这玩意怎么用?
1周前 来自 浙江
2这个代码怎么用

1周前 来自 浙江
2复制到Python编译器里(
1周前 来自 重庆
2
d
1周前 来自 重庆
1d
4天前 来自 重庆
1d
3天前 来自 重庆
1
《AI还是太好用了》,彳亍
3天前 来自 浙江
0Ds改的代码我懒得写了(
3天前 来自 重庆
0








































有帮助,赞一个