Files
2026-10-07 07:32:08 +00:00

713 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()