Upload via GUI (194 Dateien)
This commit is contained in:
@@ -54,18 +54,19 @@ const javac = path.join(jdk, 'bin', 'javac');
|
||||
const java = path.join(jdk, 'bin', 'java');
|
||||
const asm = findeAsm('asm');
|
||||
const asmCommons = findeAsm('asm-commons');
|
||||
const asmTree = findeAsm('asm-tree');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
|
||||
const klassen = path.join(tmp, 'klassen');
|
||||
const werkzeug = path.join(tmp, 'werkzeug');
|
||||
console.log('JDK:', jdk);
|
||||
console.log('ASM:', asm);
|
||||
try {
|
||||
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen,
|
||||
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', [asm, asmTree].join(path.delimiter), '-d', klassen,
|
||||
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
|
||||
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
|
||||
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
|
||||
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
|
||||
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' });
|
||||
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm, asmTree], { stdio: 'inherit' });
|
||||
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
"""
|
||||
make-spieldesign.py – erzeugt die Grafiken für den AeroMC-Look im Spiel
|
||||
=======================================================================
|
||||
Ausgabe nach src/assets/spieldesign/:
|
||||
|
||||
titel_alt.png / titel_neu.png Titel-Logo (textures/gui/title/minecraft.png)
|
||||
alt = bis 1.19.4 (zweigeteilt im 256×256-Atlas),
|
||||
neu = ab 1.20 (ein Stück, 256×64)
|
||||
edition_alt.png / edition_neu.png Untertitel "DEIN MINECRAFT ERLEBNIS" (edition.png)
|
||||
ladelogo.png Ladebildschirm ab 1.16 als Vollbild (der Agent zieht
|
||||
die Logo-Fläche auf Bildschirmgröße); im Format von
|
||||
mojangstudios.png: linke Bildhälfte oben, rechte unten
|
||||
ladelogo_alt.png Logo im Ladebildschirm 1.14/1.15 (ersetzt mojang.png)
|
||||
panorama_0..5.png Hintergrund des Hauptmenüs (Würfelseiten)
|
||||
pack.png, icon_<n>.rgba Paket- und Fenstersymbol
|
||||
|
||||
Eigene Bilder (optional) in scripts/spieldesign-quelle/:
|
||||
hintergrund.png|jpg Ladebildschirm-Hintergrund (am besten 16:9, z. B. 1920×1080)
|
||||
panorama.png|jpg Rundum-Panorama (360° × 180°, equirektangular, Seitenverhältnis 2:1)
|
||||
Fehlen sie, wird eine stilisierte Block-Landschaft in AeroMC-Blau gezeichnet.
|
||||
|
||||
Alle Grafiken sind eigene Entwürfe – kein Mojang-Material.
|
||||
Aufruf: python scripts/make-spieldesign.py
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
WURZEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
|
||||
ZIEL = os.path.join(WURZEL, 'src', 'assets', 'spieldesign')
|
||||
QUELLE = os.path.join(WURZEL, 'scripts', 'spieldesign-quelle')
|
||||
LOGO = os.path.join(WURZEL, 'src', 'assets', 'logo.png')
|
||||
SCHRIFT = 'C:/Windows/Fonts/seguisb.ttf'
|
||||
NACHTBLAU = (10, 14, 26) # Hintergrund des Ladebildschirms (muss zu Design.HINTERGRUND passen)
|
||||
KONTUR = (7, 14, 36)
|
||||
|
||||
# ---------- Block-Buchstaben (7 Zeilen hoch) ----------
|
||||
GLYPHEN = {
|
||||
'A': ['.####.', '##..##', '##..##', '######', '##..##', '##..##', '##..##'],
|
||||
'E': ['######', '##....', '##....', '#####.', '##....', '##....', '######'],
|
||||
'R': ['#####.', '##..##', '##..##', '#####.', '##.##.', '##..##', '##..##'],
|
||||
'O': ['.####.', '##..##', '##..##', '##..##', '##..##', '##..##', '.####.'],
|
||||
'M': ['##...##', '###.###', '#######', '##.#.##', '##...##', '##...##', '##...##'],
|
||||
'C': ['.#####', '##....', '##....', '##....', '##....', '##....', '.#####'],
|
||||
}
|
||||
# kleine Pixelschrift für den Untertitel (5 Zeilen hoch)
|
||||
KLEIN = {
|
||||
'D': ['###.', '#..#', '#..#', '#..#', '###.'], 'E': ['###', '#..', '##.', '#..', '###'],
|
||||
'I': ['#', '#', '#', '#', '#'], 'N': ['#..#', '##.#', '#.##', '#..#', '#..#'],
|
||||
'M': ['#...#', '##.##', '#.#.#', '#...#', '#...#'], 'C': ['.##', '#..', '#..', '#..', '.##'],
|
||||
'R': ['##.', '#.#', '##.', '#.#', '#.#'], 'A': ['.#.', '#.#', '###', '#.#', '#.#'],
|
||||
'F': ['###', '#..', '##.', '#..', '#..'], 'T': ['###', '.#.', '.#.', '.#.', '.#.'],
|
||||
'L': ['#..', '#..', '#..', '#..', '###'], 'B': ['##.', '#.#', '##.', '#.#', '##.'],
|
||||
'S': ['.##', '#..', '.#.', '..#', '##.'],
|
||||
}
|
||||
|
||||
|
||||
def raster(text, glyphen, abstand=1, wortabstand=3):
|
||||
punkte, x = [], 0
|
||||
for i, z in enumerate(text):
|
||||
if z == ' ':
|
||||
x += wortabstand
|
||||
continue
|
||||
g = glyphen[z]
|
||||
for y, zeile in enumerate(g):
|
||||
for dx, c in enumerate(zeile):
|
||||
if c == '#':
|
||||
punkte.append((x + dx, y, i))
|
||||
x += len(g[0]) + abstand
|
||||
return punkte, x - abstand, len(next(iter(glyphen.values())))
|
||||
|
||||
|
||||
def verlauf(h, w, oben, unten):
|
||||
t = np.linspace(0, 1, h)[:, None, None]
|
||||
return np.broadcast_to(np.array(oben, float) * (1 - t) + np.array(unten, float) * t, (h, w, 3)).copy()
|
||||
|
||||
|
||||
def rgba(arr, maske):
|
||||
a = np.dstack([np.clip(arr, 0, 255), maske]).astype(np.uint8)
|
||||
return Image.fromarray(a, 'RGBA')
|
||||
|
||||
|
||||
# ---------- Schriftzug ----------
|
||||
|
||||
def schriftzug(b, flach=False):
|
||||
"""'AERO MC' aus Blöcken der Größe b (Pixel): Vorderseite, Tiefe nach unten, Kontur."""
|
||||
punkte, breite, hoehe = raster('AERO MC', GLYPHEN, 1, 2)
|
||||
tiefe = 0 if flach else max(2, int(b * 0.55))
|
||||
k = 0 if flach else max(2, b // 5)
|
||||
W, H = breite * b + 2 * k, hoehe * b + tiefe + 2 * k
|
||||
front = np.zeros((H, W), bool)
|
||||
blau = np.zeros((H, W), bool)
|
||||
kante = np.zeros((H, W), float)
|
||||
belegt = {(x, y) for x, y, _ in punkte}
|
||||
rnd = np.random.RandomState(7)
|
||||
rauschen = np.zeros((H, W))
|
||||
zell = max(1, b // 4)
|
||||
for bx, by, i in punkte:
|
||||
x0, y0 = k + bx * b, k + by * b
|
||||
front[y0:y0 + b, x0:x0 + b] = True
|
||||
if i >= 5:
|
||||
blau[y0:y0 + b, x0:x0 + b] = True
|
||||
# Stein-Struktur in kleinen Zellen
|
||||
for yy in range(0, b, zell):
|
||||
for xx in range(0, b, zell):
|
||||
rauschen[y0 + yy:y0 + yy + zell, x0 + xx:x0 + xx + zell] = rnd.randint(-9, 6)
|
||||
if (bx, by - 1) not in belegt:
|
||||
kante[y0:y0 + max(1, b // 6), x0:x0 + b] = 34 # helle Oberkante
|
||||
if (bx + 1, by) not in belegt:
|
||||
kante[y0:y0 + b, x0 + b - max(1, b // 7):x0 + b] -= 26 # dunklere rechte Kante
|
||||
hell = verlauf(H, W, (255, 255, 255), (176, 190, 214))
|
||||
mc = verlauf(H, W, (170, 236, 255), (34, 108, 232))
|
||||
farbe = np.where(blau[..., None], mc, hell) + (rauschen + kante)[..., None]
|
||||
if flach:
|
||||
return rgba(farbe, front * 255)
|
||||
seite = np.zeros((H, W), bool)
|
||||
for t in range(1, tiefe + 1):
|
||||
seite[t:, :] |= front[:-t, :]
|
||||
seite &= ~front
|
||||
blau_seite = np.zeros((H, W), bool)
|
||||
for t in range(1, tiefe + 1):
|
||||
blau_seite[t:, :] |= blau[:-t, :]
|
||||
seite_farbe = np.where(blau_seite[..., None], verlauf(H, W, (30, 84, 190), (14, 44, 120)),
|
||||
verlauf(H, W, (112, 126, 156), (66, 78, 104)))
|
||||
alles = Image.fromarray(((front | seite) * 255).astype(np.uint8))
|
||||
kontur = np.array(alles.filter(ImageFilter.MaxFilter(2 * k + 1))) > 0
|
||||
bild = np.zeros((H, W, 3))
|
||||
bild[kontur] = KONTUR
|
||||
bild[seite] = seite_farbe[seite]
|
||||
bild[front] = farbe[front]
|
||||
return rgba(bild, kontur * 255)
|
||||
|
||||
|
||||
def pyramide(w, h):
|
||||
"""Blaue Pyramide (linke Seite hell, rechte dunkel, leuchtender Grat)."""
|
||||
w, h = int(w), int(h)
|
||||
links = Image.new('L', (w, h), 0)
|
||||
rechts = Image.new('L', (w, h), 0)
|
||||
spitze, mitte = (w * 0.5, 0), (w * 0.56, h)
|
||||
ImageDraw.Draw(links).polygon([spitze, (0, h), mitte], fill=255)
|
||||
ImageDraw.Draw(rechts).polygon([spitze, mitte, (w, h)], fill=255)
|
||||
hell = verlauf(h, w, (150, 232, 255), (40, 126, 236))
|
||||
dunkel = verlauf(h, w, (52, 130, 236), (14, 48, 140))
|
||||
l, r = np.array(links) > 0, np.array(rechts) > 0
|
||||
bild = np.zeros((h, w, 3))
|
||||
bild[l] = hell[l]
|
||||
bild[r] = dunkel[r]
|
||||
img = rgba(bild, np.maximum(np.array(links), np.array(rechts)))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.line([spitze, mitte], fill=(215, 248, 255, 255), width=max(2, w // 70))
|
||||
d.line([spitze, (0, h)], fill=(120, 215, 255, 255), width=max(1, w // 140))
|
||||
return img
|
||||
|
||||
|
||||
def leuchten(bild, radius, farbe=(70, 190, 255), staerke=0.75):
|
||||
a = bild.split()[3].filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(radius))
|
||||
a = a.point(lambda v: int(v * staerke))
|
||||
g = Image.new('RGBA', bild.size, farbe + (0,))
|
||||
g.putalpha(a)
|
||||
return g
|
||||
|
||||
|
||||
def logo(b, mit_leuchten=True):
|
||||
"""Pyramide hinter dem Schriftzug, wie im Entwurf. Rückgabe: Bild, Unterkante des Schriftzugs."""
|
||||
text = schriftzug(b)
|
||||
pw, ph = text.width * 0.46, text.height * 1.55
|
||||
pyr = pyramide(pw, ph)
|
||||
oben = int(ph - text.height * 0.62) # so weit ragt die Pyramide über den Text
|
||||
rand = int(b * 1.2) if mit_leuchten else 0
|
||||
W, H = text.width + 2 * rand, oben + text.height + 2 * rand
|
||||
bild = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
px = rand + (text.width - pyr.width) // 2 + int(text.width * 0.03)
|
||||
schicht = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
schicht.alpha_composite(pyr, (px, rand))
|
||||
schicht.alpha_composite(text, (rand, rand + oben))
|
||||
if mit_leuchten:
|
||||
bild.alpha_composite(leuchten(schicht, b * 0.9))
|
||||
bild.alpha_composite(schicht)
|
||||
return bild, rand + oben + text.height
|
||||
|
||||
|
||||
def einpassen(bild, w, h):
|
||||
f = min(w / bild.width, h / bild.height, 1.0)
|
||||
return bild.resize((max(1, int(bild.width * f)), max(1, int(bild.height * f))), Image.LANCZOS) if f < 1 else bild
|
||||
|
||||
|
||||
# ---------- Titel-Texturen ----------
|
||||
|
||||
def titel_art():
|
||||
"""Pyramide, Schriftzug und Untertitel zusammen (Titel im Hauptmenü)."""
|
||||
art, unterkante = logo(15)
|
||||
unter, _ = untertitel_bild(5)
|
||||
bild = Image.new('RGBA', (max(art.width, unter.width), unterkante + 8 + unter.height), (0, 0, 0, 0))
|
||||
bild.alpha_composite(art, ((bild.width - art.width) // 2, 0))
|
||||
bild.alpha_composite(unter, ((bild.width - unter.width) // 2, unterkante + 8))
|
||||
return bild
|
||||
|
||||
|
||||
def titel(alt):
|
||||
"""
|
||||
Der Agent erweitert den gezeichneten Streifen auf 64 Punkte Höhe (sonst 44).
|
||||
neu (ab 1.20): 256×64-Textur, ganz genutzt.
|
||||
alt (bis 1.19.4): Stücke (0,0,155,64) und (0,65,155,64) im 256×256-Atlas,
|
||||
sichtbare Mitte bei x=137 des 310 breiten Streifens.
|
||||
"""
|
||||
art = einpassen(titel_art(), (274 if alt else 256) * 4 - 8, 64 * 4)
|
||||
if not alt:
|
||||
t = Image.new('RGBA', (1024, 256), (0, 0, 0, 0))
|
||||
t.alpha_composite(art, ((1024 - art.width) // 2, 256 - art.height))
|
||||
return t
|
||||
streifen = Image.new('RGBA', (310 * 4, 64 * 4), (0, 0, 0, 0))
|
||||
streifen.alpha_composite(art, (137 * 4 - art.width // 2, 256 - art.height))
|
||||
t = Image.new('RGBA', (1024, 1024), (0, 0, 0, 0))
|
||||
t.alpha_composite(streifen.crop((0, 0, 620, 256)), (0, 0))
|
||||
t.alpha_composite(streifen.crop((620, 0, 1240, 256)), (0, 65 * 4))
|
||||
return t
|
||||
|
||||
|
||||
def untertitel_bild(skala, farbe=(236, 244, 255), schatten=(8, 16, 40)):
|
||||
punkte, breite, hoehe = raster('DEIN MINECRAFT ERLEBNIS', KLEIN, 1, 3)
|
||||
img = Image.new('RGBA', ((breite + 1) * skala, (hoehe + 1) * skala), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
for dx, dy, f in ((1, 1, schatten), (0, 0, farbe)):
|
||||
for x, y, _ in punkte:
|
||||
d.rectangle(((x + dx) * skala, (y + dy) * skala, (x + dx + 1) * skala - 1, (y + dy + 1) * skala - 1), fill=f + (255,))
|
||||
return img, breite
|
||||
|
||||
|
||||
def edition(breite_max):
|
||||
img, breite = untertitel_bild(4)
|
||||
if breite + 1 > breite_max:
|
||||
raise SystemExit(f'Untertitel zu breit: {breite} > {breite_max}')
|
||||
t = Image.new('RGBA', (512, 64), (0, 0, 0, 0))
|
||||
t.alpha_composite(img, ((breite_max * 4 - img.width) // 2, (56 - img.height) // 2))
|
||||
return t
|
||||
|
||||
|
||||
# ---------- stilisierte Block-Landschaft (Abendstimmung am Bergsee) ----------
|
||||
# Eine Szene, zwei Ansichten: als Rundum-Panorama (Menü) und als flacher
|
||||
# Ausschnitt nach vorne (Ladebildschirm). Gleicher Zufallswert -> gleiche Berge.
|
||||
|
||||
def _mix(a, b, t):
|
||||
return np.array(a, np.float32) * (1 - t) + np.array(b, np.float32) * t
|
||||
|
||||
|
||||
def _profil(phi, seed, schritt_rad):
|
||||
"""periodisches Höhenprofil 0..1, blockig gestuft"""
|
||||
r = random.Random(seed)
|
||||
stufe = np.floor(phi / schritt_rad) * schritt_rad + schritt_rad / 2
|
||||
werte = np.zeros_like(phi)
|
||||
for k in range(1, 16):
|
||||
werte += r.uniform(0.25, 1.0) / k ** 1.05 * np.sin(k * stufe + r.uniform(0, 6.3))
|
||||
return (werte + 2.2) / 4.4
|
||||
|
||||
|
||||
def szene(W, H, horizont, start, spanne, seed=2026):
|
||||
rnd = random.Random(seed)
|
||||
ppd = W / math.degrees(spanne) # Pixel je Grad
|
||||
blk = max(4, int(round(ppd * 0.9))) # Blockgröße der Silhouetten
|
||||
schritt = math.radians(blk / ppd) # ein Block als Winkel
|
||||
phi = (start + np.arange(W, dtype=np.float32) / W * spanne).astype(np.float32)
|
||||
y = np.arange(H, dtype=np.float32)[:, None]
|
||||
grad = (horizont - y) / ppd # Höhe über dem Horizont in Grad
|
||||
|
||||
sonne = math.pi + 0.66
|
||||
dphi = np.angle(np.exp(1j * (phi - sonne))).astype(np.float32)[None, :]
|
||||
|
||||
# Himmel: Blau oben, hell am Horizont, warmer Abendschein um die Sonne
|
||||
t = np.clip(grad / 75, 0, 1)[..., None]
|
||||
himmel = np.where(t < 0.3, _mix((168, 202, 238), (66, 124, 210), t / 0.3), _mix((66, 124, 210), (16, 36, 96), (t - 0.3) / 0.7))
|
||||
warm = (np.exp(-dphi ** 2 / 0.55) * np.exp(-(np.clip(grad, 0, None) / 14) ** 2))[..., None]
|
||||
himmel = himmel * (1 - 0.8 * warm) + np.array([255, 178, 120], np.float32) * 0.8 * warm
|
||||
bild = np.broadcast_to(himmel, (H, W, 3)).astype(np.float32).copy()
|
||||
|
||||
def maske_hoehe(h_grad):
|
||||
return (grad >= 0) & (grad <= h_grad[None, :])
|
||||
|
||||
def stufen(h_grad):
|
||||
return np.floor(h_grad * ppd / blk) * blk / ppd
|
||||
|
||||
# Sonne (eckig, wie im Spiel) mit Schein
|
||||
s_x = (sonne - start) / spanne * W
|
||||
s_y = horizont - 12 * ppd
|
||||
s_r = 2.6 * ppd
|
||||
rr = np.sqrt(((np.arange(W) - s_x) / ppd)[None, :] ** 2 + ((y - s_y) / ppd) ** 2)
|
||||
bild += (np.exp(-(rr / 6) ** 2) * 0.55)[..., None] * np.array([255, 210, 150], np.float32)
|
||||
quadrat = (np.abs(np.arange(W) - s_x)[None, :] < s_r) & (np.abs(y - s_y) < s_r)
|
||||
bild[quadrat] = (255, 244, 205)
|
||||
|
||||
# Wolken (flach und blockig)
|
||||
wolken = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(wolken)
|
||||
for _ in range(18):
|
||||
p = rnd.uniform(0, 2 * math.pi)
|
||||
for versatz in (-2 * math.pi, 0, 2 * math.pi):
|
||||
cx = (p + versatz - start) / spanne * W
|
||||
if -W * 0.2 < cx < W * 1.2:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
cy = horizont - rnd.uniform(20, 42) * ppd
|
||||
s = blk
|
||||
nah_sonne = abs(math.atan2(math.sin(p - sonne), math.cos(p - sonne))) < 0.7
|
||||
farbe = (255, 222, 196) if nah_sonne else (232, 240, 252)
|
||||
for _ in range(rnd.randint(2, 4)):
|
||||
w, h = rnd.randint(6, 16) * s, rnd.randint(1, 2) * s
|
||||
ox, oy = rnd.randint(-4, 4) * s, rnd.randint(-1, 0) * s
|
||||
d.rectangle((cx + ox, cy + oy, cx + ox + w, cy + oy + h), fill=farbe + (170,))
|
||||
d.rectangle((cx + ox, cy + oy + h - max(2, s // 3), cx + ox + w, cy + oy + h), fill=tuple(int(c * 0.82) for c in farbe) + (170,))
|
||||
|
||||
# ferne Berge mit Schnee
|
||||
h_fern = stufen(6 + 22 * _profil(phi, seed + 1, schritt))
|
||||
m = maske_hoehe(h_fern)
|
||||
tb = np.clip(grad / 28, 0, 1)[..., None]
|
||||
fels = _mix((46, 66, 112), (96, 118, 168), tb)
|
||||
schnee = (grad > h_fern[None, :] - 3 * blk / ppd) & (h_fern[None, :] > 15)
|
||||
fels = np.where(schnee[..., None], np.array([236, 242, 255], np.float32), fels)
|
||||
sonnenseite = (np.exp(-dphi ** 2 / 0.6))[..., None] * 0.35
|
||||
fels = fels * (1 - sonnenseite) + np.array([255, 190, 150], np.float32) * sonnenseite
|
||||
bild[m] = np.broadcast_to(fels, bild.shape)[m]
|
||||
# bewaldete Hügel
|
||||
h_huegel = stufen(2 + 7 * _profil(phi, seed + 2, schritt))
|
||||
m = maske_hoehe(h_huegel)
|
||||
huegel = _mix((26, 70, 58), (44, 98, 74), np.clip(grad / 7, 0, 1)[..., None])
|
||||
bild[m] = np.broadcast_to(huegel, bild.shape)[m]
|
||||
|
||||
img = Image.fromarray(np.clip(bild, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
|
||||
img.alpha_composite(wolken)
|
||||
# Fichten am Ufer
|
||||
wald = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(wald)
|
||||
s = max(3, blk // 2)
|
||||
for _ in range(int(W / s * 0.55)):
|
||||
x = rnd.randrange(W)
|
||||
hoehe = rnd.randint(6, 13)
|
||||
dunkel = rnd.choice(((14, 44, 34), (18, 52, 40), (12, 38, 32)))
|
||||
d.rectangle((x - s // 2, horizont - 2 * s, x + s // 2, horizont), fill=(40, 28, 20, 255))
|
||||
for stufe in range(hoehe):
|
||||
breite = max(0, (hoehe - stufe) // 2 - (stufe % 2))
|
||||
y0 = horizont - (stufe + 2) * s
|
||||
d.rectangle((x - breite * s - s // 2, y0, x + breite * s + s // 2, y0 + s), fill=dunkel + (255,))
|
||||
d.rectangle((x + breite * s - s // 2, y0, x + breite * s + s // 2, y0 + s), fill=tuple(c + 14 for c in dunkel) + (255,))
|
||||
img.alpha_composite(wald)
|
||||
bild = np.array(img.convert('RGB'), np.float32)
|
||||
|
||||
# See: Spiegelung, nach unten dunkler, Wellen und Sonnenglitzern
|
||||
unten = H - horizont
|
||||
zeilen = np.clip(horizont - 1 - np.arange(unten), 0, H - 1)
|
||||
spiegel = bild[zeilen]
|
||||
tiefe = np.linspace(0, 1, unten, dtype=np.float32)[:, None, None]
|
||||
wellen = ((np.sin(np.arange(unten, dtype=np.float32)[:, None] * (0.55 / max(1, ppd / 10)) + np.sin(phi[None, :] * 37) * 1.6) > 0.86) * 14)[..., None]
|
||||
see = spiegel * (0.62 - 0.32 * tiefe) + np.array([10, 30, 62], np.float32) * (0.45 + 0.55 * tiefe) + wellen * np.array([0.5, 0.8, 1.0], np.float32)
|
||||
glitzer = (np.exp(-(dphi[0] / 0.07) ** 2)[None, :] * (np.sin(np.arange(unten)[:, None] * 1.7 + phi[None, :] * 900) > 0.6) * np.exp(-tiefe[..., 0] * 3))[..., None]
|
||||
see += glitzer * np.array([255, 200, 140], np.float32) * 0.8
|
||||
bild[horizont:] = see
|
||||
|
||||
# leuchtende Pixel (AeroMC)
|
||||
img = Image.fromarray(np.clip(bild, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
|
||||
pixel = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(pixel)
|
||||
for _ in range(int(W / 60)):
|
||||
x = rnd.randrange(W)
|
||||
yy = horizont - rnd.uniform(-8, 30) * ppd
|
||||
g = rnd.choice((0.6, 0.8, 1.0, 1.3)) * ppd
|
||||
d.rectangle((x, yy, x + g, yy + g), fill=(90, 210, 255, rnd.randint(150, 235)))
|
||||
img.alpha_composite(leuchten(pixel, max(4, ppd * 0.8), (60, 170, 255), 1.0))
|
||||
img.alpha_composite(pixel)
|
||||
return img.convert('RGB')
|
||||
|
||||
|
||||
def landschaft(W=4096, H=2048):
|
||||
"""Rundum-Panorama (equirektangular): Spalte W/2 = Blick nach vorne."""
|
||||
return szene(W, H, H // 2, 0.0, 2 * math.pi)
|
||||
|
||||
|
||||
def landschaft_flach(W=1920, H=1080):
|
||||
"""Ausschnitt nach vorne (ca. 110°) für den Ladebildschirm."""
|
||||
spanne = math.radians(110)
|
||||
return szene(W, H, int(H * 0.6), math.pi - spanne / 2, spanne)
|
||||
|
||||
|
||||
def wuerfelseiten(equi, groesse=1024):
|
||||
"""Equirektangulares Panorama -> 6 Würfelseiten (0 vorne, 1 rechts, 2 hinten, 3 links, 4 oben, 5 unten)."""
|
||||
q = np.array(equi.convert('RGB'), float)
|
||||
H, W = q.shape[:2]
|
||||
a = (np.arange(groesse) + 0.5) / groesse * 2 - 1
|
||||
u, v = np.meshgrid(a, a)
|
||||
eins = np.ones_like(u)
|
||||
richtungen = [(u, -v, eins), (eins, -v, -u), (-u, -v, -eins), (-eins, -v, u), (u, eins, v), (u, -eins, -v)]
|
||||
seiten = []
|
||||
for dx, dy, dz in richtungen:
|
||||
lon = np.arctan2(dx, dz)
|
||||
lat = np.arctan2(dy, np.sqrt(dx * dx + dz * dz))
|
||||
x = (lon / (2 * math.pi) + 0.5) * W - 0.5
|
||||
y = (0.5 - lat / math.pi) * H - 0.5
|
||||
x0 = np.floor(x).astype(int); y0 = np.clip(np.floor(y).astype(int), 0, H - 2)
|
||||
fx, fy = (x - x0)[..., None], np.clip(y - y0, 0, 1)[..., None]
|
||||
x1 = (x0 + 1) % W; x0 %= W
|
||||
p = (q[y0, x0] * (1 - fx) * (1 - fy) + q[y0, x1] * fx * (1 - fy) + q[y0 + 1, x0] * (1 - fx) * fy + q[y0 + 1, x1] * fx * fy)
|
||||
seiten.append(Image.fromarray(np.clip(p, 0, 255).astype(np.uint8), 'RGB'))
|
||||
return seiten
|
||||
|
||||
|
||||
def seiten_aus_bild(bild, g=1024):
|
||||
"""
|
||||
Aus einem flachen Bild (16:9) einen nahtlosen Würfel:
|
||||
– vorne = linke Bildhälfte, rechts = rechte Hälfte, hinten/links = dieselben
|
||||
gespiegelt, so stoßen alle senkrechten Kanten passend aneinander;
|
||||
– oben/unten: die vier Seiten werden in ein Rundum-Panorama umgerechnet, dort
|
||||
nach unten (Wasser) bzw. oben (Himmel) gespiegelt fortgesetzt und weich
|
||||
auslaufen gelassen, daraus dann die obere und untere Würfelseite gerechnet.
|
||||
So gehen die Kanten nahtlos über – keine einfarbigen Flächen.
|
||||
"""
|
||||
b = bild.convert('RGB').resize((2 * g, g), Image.LANCZOS)
|
||||
vorne, rechts = b.crop((0, 0, g, g)), b.crop((g, 0, 2 * g, g))
|
||||
hinten, links = rechts.transpose(Image.FLIP_LEFT_RIGHT), vorne.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
seiten = [np.array(s, np.float32) for s in (vorne, rechts, hinten, links)]
|
||||
|
||||
# 1) Seiten -> equirektangulares Panorama (oben/unten zunächst leer)
|
||||
W, H = 4 * g, 2 * g
|
||||
lon = ((np.arange(W) + 0.5) / W - 0.5) * 2 * math.pi
|
||||
lat = (0.5 - (np.arange(H) + 0.5) / H) * math.pi
|
||||
LON, LAT = np.meshgrid(lon, lat)
|
||||
dx, dy, dz = np.cos(LAT) * np.sin(LON), np.sin(LAT), np.cos(LAT) * np.cos(LON)
|
||||
equi = np.zeros((H, W, 3), np.float32)
|
||||
gueltig = np.zeros((H, W), bool)
|
||||
seitlich = np.abs(dy) < np.maximum(np.abs(dx), np.abs(dz))
|
||||
# (Maske, s, u, v) passend zu wuerfelseiten(): vorne (u,-v,1), rechts (1,-v,-u), hinten (-u,-v,-1), links (-1,-v,u)
|
||||
faelle = [
|
||||
(seitlich & (dz >= np.abs(dx)), dz, dx, -dy),
|
||||
(seitlich & (dx > np.abs(dz)), dx, -dz, -dy),
|
||||
(seitlich & (-dz >= np.abs(dx)), -dz, -dx, -dy),
|
||||
(seitlich & (-dx > np.abs(dz)), -dx, dz, -dy),
|
||||
]
|
||||
for (maske, s, u, v), seite in zip(faelle, seiten):
|
||||
su, sv = u[maske] / s[maske], v[maske] / s[maske]
|
||||
px = np.clip(((su + 1) / 2 * g).astype(int), 0, g - 1)
|
||||
py = np.clip(((sv + 1) / 2 * g).astype(int), 0, g - 1)
|
||||
equi[maske] = seite[py, px]
|
||||
gueltig |= maske
|
||||
|
||||
# 2) Lücken spiegeln: unten das Wasser (dunkler werdend), oben der Himmel
|
||||
zeilen = np.arange(H)[:, None]
|
||||
unterste = np.where(gueltig.any(0), H - 1 - np.argmax(gueltig[::-1], axis=0), H // 2)
|
||||
oberste = np.where(gueltig.any(0), np.argmax(gueltig, axis=0), H // 2)
|
||||
spalten = np.arange(W)[None, :]
|
||||
unten = zeilen > unterste[None, :]
|
||||
oben = zeilen < oberste[None, :]
|
||||
quelle_unten = np.clip(2 * unterste[None, :] - zeilen, oberste[None, :], H - 1)
|
||||
quelle_oben = np.broadcast_to(np.clip(oberste[None, :] + 2, 0, H - 1), (H, W)) # Himmel: oberste Zeile nach oben verlängern
|
||||
gespiegelt = equi.copy()
|
||||
gespiegelt[unten] = equi[quelle_unten, np.broadcast_to(spalten, (H, W))][unten]
|
||||
gespiegelt[oben] = equi[quelle_oben, np.broadcast_to(spalten, (H, W))][oben]
|
||||
# zum Pol hin weich in eine ruhige Farbe auslaufen lassen (keine Kreuznaht im Würfel)
|
||||
abstand_unten = np.clip((zeilen - unterste[None, :]) / (H * 0.34), 0, 1)[..., None] ** 1.3
|
||||
abstand_oben = np.clip((oberste[None, :] - zeilen) / (H * 0.34), 0, 1)[..., None] ** 1.3
|
||||
# Wasserfarbe aus der Bildmitte (am unteren Rand stehen Pflanzen), Himmel vom oberen Rand
|
||||
roh = np.array(b, np.float32)
|
||||
wasser = np.median(roh[int(g * 0.62):int(g * 0.82), int(g * 0.5):int(g * 1.5)].reshape(-1, 3), axis=0)
|
||||
himmel = equi[np.clip(oberste + 4, 0, H - 1), np.arange(W)].mean(0)
|
||||
gespiegelt = np.where(unten[..., None], gespiegelt * (1 - abstand_unten) * 0.9 + wasser * 0.8 * abstand_unten, gespiegelt)
|
||||
gespiegelt = np.where(oben[..., None], gespiegelt * (1 - abstand_oben) + himmel * abstand_oben, gespiegelt)
|
||||
bild_equi = Image.fromarray(np.clip(gespiegelt, 0, 255).astype(np.uint8), 'RGB')
|
||||
weich = bild_equi.filter(ImageFilter.GaussianBlur(16))
|
||||
maske_luecke = Image.fromarray(((unten | oben) * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(6))
|
||||
bild_equi = Image.composite(weich, bild_equi, maske_luecke)
|
||||
|
||||
# 3) daraus oben/unten; die vier Seiten bleiben die scharfen Originale
|
||||
neu = wuerfelseiten(bild_equi, g)
|
||||
return [vorne, rechts, hinten, links, neu[4], neu[5]]
|
||||
|
||||
|
||||
def quelle(name):
|
||||
for endung in ('png', 'jpg', 'jpeg', 'webp'):
|
||||
p = os.path.join(QUELLE, f'{name}.{endung}')
|
||||
if os.path.exists(p):
|
||||
return Image.open(p).convert('RGB')
|
||||
return None
|
||||
|
||||
|
||||
def fuellen(bild, w, h):
|
||||
f = max(w / bild.width, h / bild.height)
|
||||
b = bild.resize((int(bild.width * f + 0.5), int(bild.height * f + 0.5)), Image.LANCZOS)
|
||||
x, y = (b.width - w) // 2, (b.height - h) // 2
|
||||
return b.crop((x, y, x + w, y + h))
|
||||
|
||||
|
||||
# ---------- Ladebildschirm (Vollbild) ----------
|
||||
|
||||
def ladebild(mit_text=True):
|
||||
W, H = 1920, 1080
|
||||
eigenes = quelle('hintergrund')
|
||||
if eigenes is not None:
|
||||
grund = fuellen(eigenes, W, H)
|
||||
else: # gleiche Landschaft wie im Menü, als Blick nach vorne
|
||||
grund = landschaft_flach(W, H)
|
||||
bild = grund.convert('RGBA')
|
||||
# leichte Abdunklung zur Mitte und unten, damit Logo und Balken lesbar sind
|
||||
schatten = Image.new('L', (W, H), 0)
|
||||
ImageDraw.Draw(schatten).ellipse((W * 0.18, H * 0.12, W * 0.82, H * 0.72), fill=95)
|
||||
schatten = schatten.filter(ImageFilter.GaussianBlur(120))
|
||||
unten = Image.fromarray((np.clip((np.arange(H) - H * 0.62) / (H * 0.38), 0, 1) * 120).astype(np.uint8)[:, None].repeat(W, 1))
|
||||
dunkel = Image.new('RGBA', (W, H), (4, 8, 20, 0))
|
||||
dunkel.putalpha(ImageChops.lighter(schatten, unten))
|
||||
bild.alpha_composite(dunkel)
|
||||
art, _ = logo(30)
|
||||
art = einpassen(art, int(W * 0.62), int(H * 0.56))
|
||||
lx, ly = (W - art.width) // 2, int(H * 0.40 - art.height / 2)
|
||||
bild.alpha_composite(art, (lx, ly))
|
||||
unter, _ = untertitel_bild(6)
|
||||
bild.alpha_composite(unter, ((W - unter.width) // 2, ly + art.height + 6))
|
||||
if not mit_text: # Neustart-Bildschirm des Launchers setzt Text und Balken selbst darüber
|
||||
return bild.convert('RGB')
|
||||
# "Wird geladen …" unter dem Balken (Minecraft zeichnet den Balken bei 83,25 % der Höhe)
|
||||
schrift = ImageFont.truetype(SCHRIFT, 34)
|
||||
text = 'Wird geladen …'
|
||||
tb = ImageDraw.Draw(bild).textbbox((0, 0), text, font=schrift)
|
||||
tw, th = tb[2] - tb[0], tb[3] - tb[1]
|
||||
kasten = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
kx, ky = (W - tw) // 2, int(H * 0.885)
|
||||
ImageDraw.Draw(kasten).rounded_rectangle((kx - 26, ky - 14, kx + tw + 26, ky + th + 22), radius=10, fill=(6, 12, 28, 175))
|
||||
bild.alpha_composite(kasten)
|
||||
ImageDraw.Draw(bild).text((kx - tb[0], ky - tb[1] + 2), text, font=schrift, fill=(236, 244, 255, 255))
|
||||
return bild.convert('RGB')
|
||||
|
||||
|
||||
def als_logotextur(bild):
|
||||
"""
|
||||
Minecraft zeichnet das Logo additiv auf den Hintergrund und teilt es: linke
|
||||
Hälfte aus der oberen Texturhälfte, rechte aus der unteren. Darum: Nachtblau
|
||||
abziehen (wird beim Zeichnen wieder addiert) und die Hälften gestreckt stapeln.
|
||||
"""
|
||||
a = np.clip(np.array(bild, float) - np.array(NACHTBLAU, float), 0, 255).astype(np.uint8)
|
||||
b = Image.fromarray(a, 'RGB')
|
||||
w, h = b.size
|
||||
t = Image.new('RGB', (2048, 2048))
|
||||
t.paste(b.crop((0, 0, w // 2, h)).resize((2048, 1024), Image.LANCZOS), (0, 0))
|
||||
t.paste(b.crop((w // 2, 0, w, h)).resize((2048, 1024), Image.LANCZOS), (0, 1024))
|
||||
return t.convert('RGBA')
|
||||
|
||||
|
||||
# ---------- Knöpfe und Symbole (wie im Entwurf) ----------
|
||||
|
||||
SYMBOL_ZEICHEN = ['', '', '', '', '', ''] # Spitzhacke, Personen, Globus, Zahnrad, Ein/Aus, Puzzle (Mods)
|
||||
|
||||
|
||||
def knopf(hell, f):
|
||||
"""Knopf 200×20 Punkte in f-facher Auflösung: dunkles Schiefer bzw. leuchtendes Blau."""
|
||||
w, h = 200 * f, 20 * f
|
||||
if hell:
|
||||
oben, unten, rand, licht = (64, 166, 248), (20, 92, 200), (130, 232, 255), (170, 225, 255)
|
||||
else:
|
||||
oben, unten, rand, licht = (46, 56, 74), (24, 30, 42), (124, 142, 172), (78, 92, 118)
|
||||
a = verlauf(h, w, oben, unten)
|
||||
bild = rgba(a, np.full((h, w), 255, np.uint8))
|
||||
d = ImageDraw.Draw(bild)
|
||||
s = max(1, f // 2)
|
||||
d.rectangle((0, 0, w - 1, h - 1), outline=(6, 10, 20, 255), width=s) # dunkle Außenkante
|
||||
d.rectangle((s, s, w - 1 - s, h - 1 - s), outline=rand + (255,), width=s) # heller Rand
|
||||
d.line((2 * s, 2 * s, w - 1 - 2 * s, 2 * s), fill=licht + (255,), width=s) # Lichtkante oben
|
||||
for x0, y0 in ((0, 0), (w - s, 0), (0, h - s), (w - s, h - s)): # Ecken leicht abgerundet
|
||||
d.rectangle((x0, y0, x0 + s - 1, y0 + s - 1), fill=(0, 0, 0, 0))
|
||||
return bild
|
||||
|
||||
|
||||
def puzzle(farbe=(238, 246, 255, 255), groesse=64):
|
||||
"""Puzzleteil (Symbol für Mods)."""
|
||||
b = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(b)
|
||||
d.rounded_rectangle((8, 18, 46, 56), radius=5, fill=farbe)
|
||||
d.ellipse((19, 4, 35, 22), fill=farbe) # Nase oben
|
||||
d.ellipse((42, 29, 60, 45), fill=farbe) # Nase rechts
|
||||
d.ellipse((4, 29, 18, 43), fill=(0, 0, 0, 0)) # Kerbe links
|
||||
return b if groesse == 64 else b.resize((groesse, groesse), Image.LANCZOS)
|
||||
|
||||
|
||||
def symbole():
|
||||
"""6 Symbole, je 16×16 (aus 64×64 verkleinert), weiß – als Schriftzeichen."""
|
||||
groesse, f = 64, 4
|
||||
leinwand = Image.new('RGBA', (16 * 6, 16), (0, 0, 0, 0))
|
||||
farbe = (238, 246, 255, 255)
|
||||
|
||||
def neu():
|
||||
return Image.new('RGBA', (groesse, groesse), (0, 0, 0, 0))
|
||||
|
||||
bilder = []
|
||||
b = neu(); d = ImageDraw.Draw(b) # Spitzhacke: aufrecht zeichnen, dann kippen
|
||||
d.line((32, 16, 32, 62), fill=farbe, width=10)
|
||||
d.arc((2, 12, 62, 56), 198, 342, fill=farbe, width=12)
|
||||
d.polygon([(4, 30), (0, 40), (10, 34)], fill=farbe); d.polygon([(60, 30), (64, 40), (54, 34)], fill=farbe)
|
||||
bilder.append(b.rotate(-40, resample=Image.BICUBIC))
|
||||
b = neu(); d = ImageDraw.Draw(b) # zwei Personen
|
||||
d.ellipse((30, 8, 50, 28), fill=farbe); d.pieslice((22, 30, 58, 70), 180, 360, fill=farbe)
|
||||
d.ellipse((10, 14, 28, 32), fill=farbe); d.pieslice((2, 34, 36, 68), 180, 360, fill=farbe)
|
||||
bilder.append(b)
|
||||
b = neu(); d = ImageDraw.Draw(b) # Globus
|
||||
d.ellipse((4, 4, 60, 60), outline=farbe, width=8)
|
||||
d.ellipse((20, 4, 44, 60), outline=farbe, width=7)
|
||||
d.line((4, 32, 60, 32), fill=farbe, width=7)
|
||||
d.arc((10, -10, 54, 26), 30, 150, fill=farbe, width=3); d.arc((10, 38, 54, 74), 210, 330, fill=farbe, width=3)
|
||||
bilder.append(b)
|
||||
b = neu(); d = ImageDraw.Draw(b) # Zahnrad
|
||||
for k in range(8):
|
||||
w = k * math.pi / 4
|
||||
cx, cy = 32 + math.cos(w) * 23, 32 + math.sin(w) * 23
|
||||
pts = [(cx + math.cos(w + p) * 7, cy + math.sin(w + p) * 7) for p in (0.8, 2.35, 3.9, 5.5)]
|
||||
d.polygon(pts, fill=farbe)
|
||||
d.ellipse((12, 12, 52, 52), fill=farbe)
|
||||
d.ellipse((26, 26, 38, 38), fill=(0, 0, 0, 0))
|
||||
bilder.append(b)
|
||||
b = neu(); d = ImageDraw.Draw(b) # Ein/Aus
|
||||
d.arc((6, 8, 58, 60), -60, 240, fill=farbe, width=10)
|
||||
d.line((32, 2, 32, 30), fill=farbe, width=10)
|
||||
bilder.append(b)
|
||||
bilder.append(puzzle(farbe))
|
||||
for i, b in enumerate(bilder):
|
||||
leinwand.alpha_composite(b.resize((16, 16), Image.LANCZOS), (16 * i, 0))
|
||||
return leinwand
|
||||
|
||||
|
||||
def sprachdateien():
|
||||
"""Symbole vor die Beschriftungen der Menüknöpfe setzen (de_de und en_us)."""
|
||||
import json
|
||||
sp, mp, rl, op, qu, md = SYMBOL_ZEICHEN
|
||||
texte = {
|
||||
'de_de': {'menu.singleplayer': 'Einzelspieler', 'menu.multiplayer': 'Mehrspieler', 'menu.online': 'Minecraft Realms',
|
||||
'menu.options': 'Optionen …', 'menu.quit': 'Spiel beenden'},
|
||||
'en_us': {'menu.singleplayer': 'Singleplayer', 'menu.multiplayer': 'Multiplayer', 'menu.online': 'Minecraft Realms',
|
||||
'menu.options': 'Options...', 'menu.quit': 'Quit Game'},
|
||||
}
|
||||
zeichen = {'menu.singleplayer': sp, 'menu.multiplayer': mp, 'menu.online': rl, 'menu.options': op, 'menu.quit': qu}
|
||||
aus = {}
|
||||
for sprache, t in texte.items():
|
||||
aus[sprache] = json.dumps({k: zeichen[k] + ' ' + v for k, v in t.items()}, ensure_ascii=True, indent=2)
|
||||
mods = {
|
||||
'de_de': {'menu.sendFeedback': md + ' Mods verwalten …', 'menu.feedback': md + ' Mods & Feedback …'},
|
||||
'en_us': {'menu.sendFeedback': md + ' Manage Mods...', 'menu.feedback': md + ' Mods & Feedback...'},
|
||||
}
|
||||
for sprache, t in mods.items():
|
||||
aus['mods_' + sprache] = json.dumps(t, ensure_ascii=True, indent=2)
|
||||
schrift = json.dumps({'providers': [{'type': 'bitmap', 'file': 'aeromc:font/symbole.png', 'ascent': 7, 'height': 8,
|
||||
'chars': [''.join(SYMBOL_ZEICHEN)]}]}, ensure_ascii=True, indent=2)
|
||||
return aus, schrift
|
||||
|
||||
|
||||
def knopf_dateien():
|
||||
import json
|
||||
knopf(False, 2).save(os.path.join(ZIEL, 'knopf.png'))
|
||||
knopf(True, 2).save(os.path.join(ZIEL, 'knopf_hell.png'))
|
||||
knopf(False, 4).save(os.path.join(ZIEL, 'knopf_4x.png'))
|
||||
knopf(True, 4).save(os.path.join(ZIEL, 'knopf_hell_4x.png'))
|
||||
meta = json.dumps({'gui': {'scaling': {'type': 'nine_slice', 'width': 200, 'height': 20, 'border': 3}}}, indent=2)
|
||||
with open(os.path.join(ZIEL, 'knopf.png.mcmeta'), 'w', encoding='utf-8') as f:
|
||||
f.write(meta)
|
||||
symbole().save(os.path.join(ZIEL, 'symbole.png'))
|
||||
puzzle(groesse=40).save(os.path.join(ZIEL, 'mods_knopf.png')) # Symbol-Knopf im Pausemenü (26.x)
|
||||
sprachen, schrift = sprachdateien()
|
||||
for sprache, text in sprachen.items():
|
||||
with open(os.path.join(ZIEL, f'lang_{sprache}.json'), 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
with open(os.path.join(ZIEL, 'font_default.json'), 'w', encoding='utf-8') as f:
|
||||
f.write(schrift)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(ZIEL, exist_ok=True)
|
||||
titel(False).save(os.path.join(ZIEL, 'titel_neu.png'))
|
||||
titel(True).save(os.path.join(ZIEL, 'titel_alt.png'))
|
||||
# Der Untertitel steckt jetzt im Titel-Logo – edition.png bleibt leer
|
||||
leer = Image.new('RGBA', (512, 64), (0, 0, 0, 0))
|
||||
leer.save(os.path.join(ZIEL, 'edition_neu.png'))
|
||||
leer.save(os.path.join(ZIEL, 'edition_alt.png'))
|
||||
|
||||
equi, flach = quelle('panorama'), quelle('hintergrund')
|
||||
if equi is not None:
|
||||
seiten = wuerfelseiten(equi) # echtes Rundum-Panorama
|
||||
elif flach is not None:
|
||||
seiten = seiten_aus_bild(flach) # flaches Bild, nahtlos gespiegelt
|
||||
else:
|
||||
seiten = wuerfelseiten(landschaft()) # gezeichnete Block-Landschaft
|
||||
for i, s in enumerate(seiten):
|
||||
s.save(os.path.join(ZIEL, f'panorama_{i}.png'), optimize=True)
|
||||
|
||||
lade = ladebild()
|
||||
lade.save(os.path.join(QUELLE if os.path.isdir(QUELLE) else ZIEL, '_vorschau_ladebildschirm.png'))
|
||||
als_logotextur(lade).save(os.path.join(ZIEL, 'ladelogo.png'), optimize=True)
|
||||
# derselbe Ladebildschirm ohne Text – für den Neustart über den Mod-Schalter
|
||||
ladebild(mit_text=False).save(os.path.join(ZIEL, 'neustart.jpg'), quality=88)
|
||||
|
||||
# 1.14/1.15: festes 256×256-Feld in der Mitte, Logo auf durchsichtigem Grund
|
||||
alt = Image.new('RGBA', (1024, 1024), (0, 0, 0, 0))
|
||||
art, _ = logo(26)
|
||||
art = einpassen(art, 980, 600)
|
||||
alt.alpha_composite(art, ((1024 - art.width) // 2, (1024 - art.height) // 2))
|
||||
alt.save(os.path.join(ZIEL, 'ladelogo_alt.png'))
|
||||
|
||||
knopf_dateien()
|
||||
|
||||
symbol = Image.open(LOGO).convert('RGBA')
|
||||
symbol.resize((128, 128), Image.LANCZOS).save(os.path.join(ZIEL, 'pack.png'))
|
||||
for n in (16, 32, 48, 64, 128, 256):
|
||||
with open(os.path.join(ZIEL, f'icon_{n}.rgba'), 'wb') as f:
|
||||
f.write(symbol.resize((n, n), Image.LANCZOS).tobytes())
|
||||
print('Fertig:', ZIEL, '| Hintergrund:', 'eigenes Bild' if quelle('hintergrund') else 'Panorama-Ausschnitt',
|
||||
'| Panorama:', 'eigenes Panorama' if quelle('panorama') else ('aus dem Hintergrundbild' if quelle('hintergrund') else 'Block-Landschaft'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* pruefe-version.js – läuft vor jedem Build (prebuild)
|
||||
* ----------------------------------------------------
|
||||
* Bricht ab, wenn die Version aus package.json schon als Release in Gitea
|
||||
* veröffentlicht ist. Ein neuer Build derselben Version ergäbe andere Dateien
|
||||
* mit neuer Signatur – die bereits veröffentlichten EXEs würden dann nicht mehr
|
||||
* dazu passen und kein Launcher nähme das Update an.
|
||||
*
|
||||
* Ausnahme (z. B. Testbuild, der nie hochgeladen wird): AEROMC_GLEICHE_VERSION=1
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases?limit=50';
|
||||
const version = require(path.join(__dirname, '..', 'package.json')).version;
|
||||
|
||||
(async () => {
|
||||
if (process.env.AEROMC_GLEICHE_VERSION === '1') return;
|
||||
let releases;
|
||||
try {
|
||||
const res = await fetch(API, { headers: { 'User-Agent': 'AeroMC-Release', Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
releases = await res.json();
|
||||
} catch (err) {
|
||||
console.warn(`\n! Gitea nicht erreichbar (${err.message}) – Versionsprüfung übersprungen.`);
|
||||
return;
|
||||
}
|
||||
const norm = (v) => String(v || '').trim().replace(/^v/i, '');
|
||||
const treffer = (releases || []).find((r) => !r.draft && norm(r.tag_name) === version);
|
||||
if (treffer) {
|
||||
console.error(`\n✖ Version ${version} ist bereits in Gitea veröffentlicht (${treffer.published_at}).`);
|
||||
console.error(' Bitte zuerst die Versionsnummer in package.json erhöhen – sonst passen die');
|
||||
console.error(' veröffentlichten Dateien nicht mehr zur Signatur.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`» Version ${version} ist noch nicht veröffentlicht – Build läuft.`);
|
||||
})();
|
||||
@@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const readline = require('readline');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { signaturText, pruefeSignatur, holeSignatur, SIGNATUR_API } = require('../src/updater');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const ORDNER = path.join(os.homedir(), '.aeromc-release');
|
||||
const OEFFENTLICH = path.join(ROOT, 'src', 'update-schluessel.pem');
|
||||
const PRIVAT = process.env.AEROMC_SIGN_KEY || path.join(ORDNER, 'release-key.pem');
|
||||
const ZUGANG = path.join(ORDNER, 'wordpress.json');
|
||||
const DATEIEN = { setup: 'AeroMC-Setup.exe', portable: 'AeroMC-Portable.exe' };
|
||||
const WP_STANDARD = new URL(SIGNATUR_API).origin;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schlüssel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function schluesselAnlegen() {
|
||||
if (fs.existsSync(PRIVAT)) {
|
||||
console.log(`Es gibt bereits einen Release-Schlüssel: ${PRIVAT}`);
|
||||
console.log('Er wird nicht überschrieben – sonst nähmen bestehende Launcher keine Updates mehr an.');
|
||||
return;
|
||||
}
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
||||
fs.mkdirSync(path.dirname(PRIVAT), { recursive: true });
|
||||
fs.writeFileSync(PRIVAT, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
|
||||
fs.writeFileSync(OEFFENTLICH, publicKey.export({ type: 'spki', format: 'pem' }));
|
||||
console.log(`Privater Schlüssel: ${PRIVAT} (SICHERN, niemals weitergeben!)`);
|
||||
console.log(`Öffentlicher Schlüssel: ${OEFFENTLICH} (wird in den Launcher eingebaut)`);
|
||||
}
|
||||
|
||||
|
||||
function dpapi(skript, eingabe) {
|
||||
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript],
|
||||
{ input: eingabe, encoding: 'utf8', windowsHide: true }).trim();
|
||||
}
|
||||
function verschluesseln(text) {
|
||||
return dpapi('$t = [Console]::In.ReadToEnd(); ConvertTo-SecureString $t -AsPlainText -Force | ConvertFrom-SecureString', text);
|
||||
}
|
||||
function entschluesseln(hex) {
|
||||
return dpapi('$h = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $h; '
|
||||
+ '[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', hex);
|
||||
}
|
||||
|
||||
function frage(text, verdeckt) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
if (verdeckt) {
|
||||
rl._writeToOutput = (s) => { if (s.includes(text)) process.stdout.write(text); else if (s === '\r\n' || s === '\n') process.stdout.write(s); };
|
||||
}
|
||||
rl.question(text, (antwort) => { rl.close(); if (verdeckt) process.stdout.write('\n'); resolve(antwort.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
function ladeZugang() {
|
||||
if (!fs.existsSync(ZUGANG)) return null;
|
||||
const z = JSON.parse(fs.readFileSync(ZUGANG, 'utf8').replace(/^/, ''));
|
||||
return { seite: z.seite, benutzer: z.benutzer, passwort: entschluesseln(z.passwort) };
|
||||
}
|
||||
|
||||
function kopfzeilen(zugang) {
|
||||
const daten = Buffer.from(`${zugang.benutzer}:${zugang.passwort}`).toString('base64');
|
||||
return {
|
||||
Authorization: 'Basic ' + daten,
|
||||
// Ersatz für Server, die "Authorization" nicht an PHP weitergeben (siehe signaturen.php)
|
||||
'X-AeroMC-Auth': daten,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'AeroMC-Release',
|
||||
};
|
||||
}
|
||||
|
||||
// Übersetzt die Diagnose des Plugins (ab 1.5.4) in eine klare Meldung
|
||||
function zugangsFehler(d, benutzer) {
|
||||
if (!d || !d.version) {
|
||||
return 'Das Plugin auf der Seite ist zu alt – bitte "AeroMC Freunde" 1.5.4 oder neuer installieren.';
|
||||
}
|
||||
if (!d.anwendungspasswoerter) return 'Anwendungspasswörter sind auf der Seite abgeschaltet (z. B. durch ein Sicherheits-Plugin).';
|
||||
if (!d.authorization_kommt_an && !d.eigener_kopf_kommt_an) {
|
||||
return 'Der Server leitet keine Anmeldedaten an WordPress weiter (beide Header fehlen) – das muss der Hoster freigeben.';
|
||||
}
|
||||
switch (d.grund) {
|
||||
case 'invalid_username': return `Den Benutzer "${benutzer}" gibt es nicht (Benutzername, nicht E-Mail-Adresse).`;
|
||||
case 'incorrect_password':
|
||||
return 'Falsches Anwendungspasswort. Achtung: Gemeint ist NICHT das Login-Passwort, sondern das unter '
|
||||
+ 'Benutzer → Profil → "Anwendungspasswörter" erzeugte (Form "abcd efgh ijkl …").';
|
||||
case 'application_passwords_disabled':
|
||||
case 'application_passwords_disabled_for_user':
|
||||
return `Für "${benutzer}" sind Anwendungspasswörter abgeschaltet.`;
|
||||
default: break;
|
||||
}
|
||||
if (d.benutzer && !d.darf_hochladen) {
|
||||
return `Anmeldung als "${d.benutzer}" klappt, aber der Benutzer hat nicht die Rolle "AeroMC Release" (Benutzer bearbeiten → Rolle).`;
|
||||
}
|
||||
return 'Anmeldung abgelehnt' + (d.grund ? ` (${d.grund})` : '') + '.';
|
||||
}
|
||||
|
||||
async function pruefeZugang(zugang) {
|
||||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur/zugang`, { headers: kopfzeilen(zugang) });
|
||||
if (res.status === 404) throw new Error('Die Seite kennt die Signatur-Schnittstelle nicht – ist das Plugin "AeroMC Freunde" (ab 1.5.4) installiert und aktiviert?');
|
||||
let d = null;
|
||||
try { d = await res.json(); } catch { /* keine JSON-Antwort */ }
|
||||
if (res.ok && d && d.ok) return d;
|
||||
if (res.status === 401 || res.status === 403) throw new Error(zugangsFehler(d, zugang.benutzer));
|
||||
throw new Error('WordPress antwortet mit HTTP ' + res.status);
|
||||
}
|
||||
|
||||
async function wordpressEinrichten() {
|
||||
console.log('WordPress-Zugang für das automatische Hochladen der Signaturen einrichten.');
|
||||
console.log('Vorher in WordPress: Benutzer mit der Rolle "AeroMC Release" anlegen, dann in dessen Profil');
|
||||
console.log('unter "Anwendungspasswörter" ein Passwort erzeugen (Name z. B. "AeroMC Build").\n');
|
||||
const seite = (await frage(`Adresse der WordPress-Seite [${WP_STANDARD}]: `)) || WP_STANDARD;
|
||||
if (!/^https:\/\//i.test(seite)) throw new Error('Bitte eine https://-Adresse angeben.');
|
||||
const benutzer = await frage('WordPress-Benutzername: ');
|
||||
const passwort = await frage('Anwendungspasswort (Eingabe bleibt unsichtbar): ', true);
|
||||
if (!benutzer || !passwort) throw new Error('Benutzername und Passwort werden gebraucht.');
|
||||
const zugang = { seite: seite.replace(/\/+$/, ''), benutzer, passwort };
|
||||
const antwort = await pruefeZugang(zugang);
|
||||
fs.mkdirSync(ORDNER, { recursive: true });
|
||||
fs.writeFileSync(ZUGANG, JSON.stringify({ seite: zugang.seite, benutzer, passwort: verschluesseln(passwort) }, null, 2), { mode: 0o600 });
|
||||
console.log(`\n✔ Zugang geprüft (angemeldet als ${antwort.benutzer}) und gespeichert: ${ZUGANG}`);
|
||||
console.log(' Das Passwort ist mit Windows-DPAPI verschlüsselt.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sha256(datei) {
|
||||
const h = crypto.createHash('sha256');
|
||||
const fd = fs.openSync(datei, 'r');
|
||||
const buf = Buffer.alloc(4 * 1024 * 1024);
|
||||
let n;
|
||||
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n));
|
||||
fs.closeSync(fd);
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
function signieren(art) {
|
||||
const name = DATEIEN[art];
|
||||
if (!name) throw new Error(`Unbekannte Art "${art}" – erwartet: setup, portable, schluessel, wordpress oder hochladen.`);
|
||||
const datei = path.join(DIST, name);
|
||||
if (!fs.existsSync(datei)) throw new Error(`${datei} fehlt – erst bauen.`);
|
||||
if (!fs.existsSync(PRIVAT)) {
|
||||
throw new Error(`Kein Release-Schlüssel unter ${PRIVAT}. Einmalig "node scripts/sign-release.js schluessel" ausführen.`);
|
||||
}
|
||||
const privat = crypto.createPrivateKey(fs.readFileSync(PRIVAT, 'utf8'));
|
||||
|
||||
const eingebaut = crypto.createPublicKey(fs.readFileSync(OEFFENTLICH, 'utf8')).export({ type: 'spki', format: 'der' });
|
||||
const passend = crypto.createPublicKey(privat).export({ type: 'spki', format: 'der' });
|
||||
if (!eingebaut.equals(passend)) {
|
||||
throw new Error('Der private Schlüssel passt nicht zu src/update-schluessel.pem – Signatur wäre für den Launcher wertlos.');
|
||||
}
|
||||
|
||||
const version = require(path.join(ROOT, 'package.json')).version;
|
||||
const hash = sha256(datei);
|
||||
const sig = {
|
||||
format: 'AeroMC-Update/1',
|
||||
datei: name,
|
||||
version,
|
||||
sha256: hash,
|
||||
signatur: crypto.sign(null, signaturText(name, version, hash), privat).toString('base64'),
|
||||
};
|
||||
pruefeSignatur(sig, name, version, hash);
|
||||
fs.writeFileSync(datei + '.sig', JSON.stringify(sig, null, 2) + '\n');
|
||||
console.log(`\n» ${name} ${version} signiert`);
|
||||
return sig;
|
||||
}
|
||||
|
||||
async function hochladen(sig) {
|
||||
const zugang = ladeZugang();
|
||||
if (!zugang) {
|
||||
throw new Error('Kein WordPress-Zugang eingerichtet – Signatur NICHT hochgeladen.\n'
|
||||
+ ' Einmalig ausführen: node scripts/sign-release.js wordpress\n'
|
||||
+ ` danach: node scripts/sign-release.js hochladen ${sig.datei === DATEIEN.setup ? 'setup' : 'portable'}`);
|
||||
}
|
||||
// Nie die Signatur einer schon veröffentlichten Datei überschreiben: Dann passte
|
||||
// die EXE im Gitea-Release nicht mehr, und kein Launcher nähme das Update an.
|
||||
const vorhanden = await holeSignatur(sig.datei, sig.version);
|
||||
if (vorhanden && vorhanden.sha256 !== sig.sha256 && process.env.AEROMC_SIG_ERSETZEN !== '1') {
|
||||
throw new Error(`Für ${sig.datei} ${sig.version} liegt schon eine andere Signatur auf WordPress – `
|
||||
+ 'diese Version ist vermutlich bereits veröffentlicht. Bitte die Versionsnummer in package.json erhöhen.\n'
|
||||
+ ' (Nur wenn die Version sicher NICHT veröffentlicht ist: AEROMC_SIG_ERSETZEN=1 setzen.)');
|
||||
}
|
||||
if (vorhanden && vorhanden.sha256 === sig.sha256) {
|
||||
console.log('» Signatur liegt bereits unverändert auf WordPress.');
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur`, {
|
||||
method: 'POST', headers: kopfzeilen(zugang), body: JSON.stringify(sig),
|
||||
});
|
||||
let antwort = {};
|
||||
try { antwort = await res.json(); } catch { /* leer */ }
|
||||
if (!res.ok || !antwort.ok) {
|
||||
throw new Error(`Hochladen fehlgeschlagen (HTTP ${res.status}): ${antwort.message || 'unbekannter Fehler'}`);
|
||||
}
|
||||
// Gegenprobe: liefert die öffentliche Schnittstelle genau das, was der Launcher braucht?
|
||||
const zurueck = await holeSignatur(sig.datei, sig.version);
|
||||
if (!zurueck || zurueck.signatur !== sig.signatur || zurueck.sha256 !== sig.sha256) {
|
||||
throw new Error('Signatur wurde hochgeladen, die Seite liefert aber etwas anderes zurück (Cache?).');
|
||||
}
|
||||
console.log(`» Signatur auf ${zugang.seite} hochgeladen und geprüft${antwort.geprueft ? ' (vom Server gegengeprüft)' : ''} – ins Gitea-Release kommt nur die EXE.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [art, zusatz] = process.argv.slice(2);
|
||||
if (art === 'schluessel') return schluesselAnlegen();
|
||||
if (art === 'wordpress') return wordpressEinrichten();
|
||||
if (art === 'hochladen') {
|
||||
const name = DATEIEN[zusatz];
|
||||
if (!name) throw new Error('Erwartet: hochladen setup|portable');
|
||||
const sigDatei = path.join(DIST, name + '.sig');
|
||||
if (!fs.existsSync(sigDatei)) throw new Error(`${sigDatei} fehlt – erst bauen und signieren.`);
|
||||
const sig = JSON.parse(fs.readFileSync(sigDatei, 'utf8'));
|
||||
pruefeSignatur(sig, name, sig.version, sha256(path.join(DIST, name)));
|
||||
return hochladen(sig);
|
||||
}
|
||||
return hochladen(signieren(art));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n✖ ' + err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Reference in New Issue
Block a user