713 lines
35 KiB
Python
713 lines
35 KiB
Python
"""
|
||
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()
|