""" 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_.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()