diff --git a/scripts/build-agent.js b/scripts/build-agent.js index dc34585..b33577c 100644 --- a/scripts/build-agent.js +++ b/scripts/build-agent.js @@ -1,72 +1,73 @@ -'use strict'; - -/* - * build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent) - * ------------------------------------------------------------------------------ - * Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen - * gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über - * JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im - * Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab). - * Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version. - * - * Aufruf: node scripts/build-agent.js - */ - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { execFileSync } = require('child_process'); - -const WURZEL = path.join(__dirname, '..'); -const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar'); - -function findeJdk() { - const kandidaten = []; - if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME); - const adoptium = 'C:\\Program Files\\Eclipse Adoptium'; - if (fs.existsSync(adoptium)) { - for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d)); - } - const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac'))); - if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).'); - return jdk; -} - -function findeAsm(name) { - const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name); - if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`); - const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v)) - .sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0)); - const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`); - if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei); - return datei; -} - -function quellen(ordner) { - return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => { - const p = path.join(ordner, e.name); - return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []); - }); -} - -const jdk = findeJdk(); -const javac = path.join(jdk, 'bin', 'javac'); -const java = path.join(jdk, 'bin', 'java'); -const asm = findeAsm('asm'); -const asmCommons = findeAsm('asm-commons'); -const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-')); -const klassen = path.join(tmp, 'klassen'); -const werkzeug = path.join(tmp, 'werkzeug'); -console.log('JDK:', jdk); -console.log('ASM:', asm); -try { - execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen, - ...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' }); - execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug, - path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' }); - fs.mkdirSync(path.dirname(ZIEL), { recursive: true }); - execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' }); - console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`); -} finally { - fs.rmSync(tmp, { recursive: true, force: true }); -} +'use strict'; + +/* + * build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent) + * ------------------------------------------------------------------------------ + * Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen + * gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über + * JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im + * Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab). + * Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version. + * + * Aufruf: node scripts/build-agent.js + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const WURZEL = path.join(__dirname, '..'); +const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar'); + +function findeJdk() { + const kandidaten = []; + if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME); + const adoptium = 'C:\\Program Files\\Eclipse Adoptium'; + if (fs.existsSync(adoptium)) { + for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d)); + } + const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac'))); + if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).'); + return jdk; +} + +function findeAsm(name) { + const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name); + if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`); + const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v)) + .sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0)); + const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`); + if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei); + return datei; +} + +function quellen(ordner) { + return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => { + const p = path.join(ordner, e.name); + return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []); + }); +} + +const jdk = findeJdk(); +const javac = path.join(jdk, 'bin', 'javac'); +const java = path.join(jdk, 'bin', 'java'); +const asm = findeAsm('asm'); +const asmCommons = findeAsm('asm-commons'); +const asmTree = findeAsm('asm-tree'); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-')); +const klassen = path.join(tmp, 'klassen'); +const werkzeug = path.join(tmp, 'werkzeug'); +console.log('JDK:', jdk); +console.log('ASM:', asm); +try { + execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', [asm, asmTree].join(path.delimiter), '-d', klassen, + ...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' }); + execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug, + path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' }); + fs.mkdirSync(path.dirname(ZIEL), { recursive: true }); + execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm, asmTree], { stdio: 'inherit' }); + console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`); +} finally { + fs.rmSync(tmp, { recursive: true, force: true }); +} diff --git a/scripts/make-spieldesign.py b/scripts/make-spieldesign.py new file mode 100644 index 0000000..14d10c5 --- /dev/null +++ b/scripts/make-spieldesign.py @@ -0,0 +1,712 @@ +""" +make-spieldesign.py – erzeugt die Grafiken für den AeroMC-Look im Spiel +======================================================================= +Ausgabe nach src/assets/spieldesign/: + + titel_alt.png / titel_neu.png Titel-Logo (textures/gui/title/minecraft.png) + alt = bis 1.19.4 (zweigeteilt im 256×256-Atlas), + neu = ab 1.20 (ein Stück, 256×64) + edition_alt.png / edition_neu.png Untertitel "DEIN MINECRAFT ERLEBNIS" (edition.png) + ladelogo.png Ladebildschirm ab 1.16 als Vollbild (der Agent zieht + die Logo-Fläche auf Bildschirmgröße); im Format von + mojangstudios.png: linke Bildhälfte oben, rechte unten + ladelogo_alt.png Logo im Ladebildschirm 1.14/1.15 (ersetzt mojang.png) + panorama_0..5.png Hintergrund des Hauptmenüs (Würfelseiten) + pack.png, icon_.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() diff --git a/scripts/pruefe-version.js b/scripts/pruefe-version.js new file mode 100644 index 0000000..c3217fa --- /dev/null +++ b/scripts/pruefe-version.js @@ -0,0 +1,39 @@ +'use strict'; + +/* + * pruefe-version.js – läuft vor jedem Build (prebuild) + * ---------------------------------------------------- + * Bricht ab, wenn die Version aus package.json schon als Release in Gitea + * veröffentlicht ist. Ein neuer Build derselben Version ergäbe andere Dateien + * mit neuer Signatur – die bereits veröffentlichten EXEs würden dann nicht mehr + * dazu passen und kein Launcher nähme das Update an. + * + * Ausnahme (z. B. Testbuild, der nie hochgeladen wird): AEROMC_GLEICHE_VERSION=1 + */ + +const path = require('path'); + +const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases?limit=50'; +const version = require(path.join(__dirname, '..', 'package.json')).version; + +(async () => { + if (process.env.AEROMC_GLEICHE_VERSION === '1') return; + let releases; + try { + const res = await fetch(API, { headers: { 'User-Agent': 'AeroMC-Release', Accept: 'application/json' } }); + if (!res.ok) throw new Error('HTTP ' + res.status); + releases = await res.json(); + } catch (err) { + console.warn(`\n! Gitea nicht erreichbar (${err.message}) – Versionsprüfung übersprungen.`); + return; + } + const norm = (v) => String(v || '').trim().replace(/^v/i, ''); + const treffer = (releases || []).find((r) => !r.draft && norm(r.tag_name) === version); + if (treffer) { + console.error(`\n✖ Version ${version} ist bereits in Gitea veröffentlicht (${treffer.published_at}).`); + console.error(' Bitte zuerst die Versionsnummer in package.json erhöhen – sonst passen die'); + console.error(' veröffentlichten Dateien nicht mehr zur Signatur.'); + process.exit(1); + } + console.log(`» Version ${version} ist noch nicht veröffentlicht – Build läuft.`); +})(); diff --git a/scripts/sign-release.js b/scripts/sign-release.js new file mode 100644 index 0000000..5ea0355 --- /dev/null +++ b/scripts/sign-release.js @@ -0,0 +1,227 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const readline = require('readline'); +const { execFileSync } = require('child_process'); +const { signaturText, pruefeSignatur, holeSignatur, SIGNATUR_API } = require('../src/updater'); + +const ROOT = path.join(__dirname, '..'); +const DIST = path.join(ROOT, 'dist'); +const ORDNER = path.join(os.homedir(), '.aeromc-release'); +const OEFFENTLICH = path.join(ROOT, 'src', 'update-schluessel.pem'); +const PRIVAT = process.env.AEROMC_SIGN_KEY || path.join(ORDNER, 'release-key.pem'); +const ZUGANG = path.join(ORDNER, 'wordpress.json'); +const DATEIEN = { setup: 'AeroMC-Setup.exe', portable: 'AeroMC-Portable.exe' }; +const WP_STANDARD = new URL(SIGNATUR_API).origin; + +// --------------------------------------------------------------------------- +// Schlüssel +// --------------------------------------------------------------------------- + +function schluesselAnlegen() { + if (fs.existsSync(PRIVAT)) { + console.log(`Es gibt bereits einen Release-Schlüssel: ${PRIVAT}`); + console.log('Er wird nicht überschrieben – sonst nähmen bestehende Launcher keine Updates mehr an.'); + return; + } + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); + fs.mkdirSync(path.dirname(PRIVAT), { recursive: true }); + fs.writeFileSync(PRIVAT, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }); + fs.writeFileSync(OEFFENTLICH, publicKey.export({ type: 'spki', format: 'pem' })); + console.log(`Privater Schlüssel: ${PRIVAT} (SICHERN, niemals weitergeben!)`); + console.log(`Öffentlicher Schlüssel: ${OEFFENTLICH} (wird in den Launcher eingebaut)`); +} + + +function dpapi(skript, eingabe) { + return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], + { input: eingabe, encoding: 'utf8', windowsHide: true }).trim(); +} +function verschluesseln(text) { + return dpapi('$t = [Console]::In.ReadToEnd(); ConvertTo-SecureString $t -AsPlainText -Force | ConvertFrom-SecureString', text); +} +function entschluesseln(hex) { + return dpapi('$h = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $h; ' + + '[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', hex); +} + +function frage(text, verdeckt) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + if (verdeckt) { + rl._writeToOutput = (s) => { if (s.includes(text)) process.stdout.write(text); else if (s === '\r\n' || s === '\n') process.stdout.write(s); }; + } + rl.question(text, (antwort) => { rl.close(); if (verdeckt) process.stdout.write('\n'); resolve(antwort.trim()); }); + }); +} + +function ladeZugang() { + if (!fs.existsSync(ZUGANG)) return null; + const z = JSON.parse(fs.readFileSync(ZUGANG, 'utf8').replace(/^/, '')); + return { seite: z.seite, benutzer: z.benutzer, passwort: entschluesseln(z.passwort) }; +} + +function kopfzeilen(zugang) { + const daten = Buffer.from(`${zugang.benutzer}:${zugang.passwort}`).toString('base64'); + return { + Authorization: 'Basic ' + daten, + // Ersatz für Server, die "Authorization" nicht an PHP weitergeben (siehe signaturen.php) + 'X-AeroMC-Auth': daten, + 'Content-Type': 'application/json', + 'User-Agent': 'AeroMC-Release', + }; +} + +// Übersetzt die Diagnose des Plugins (ab 1.5.4) in eine klare Meldung +function zugangsFehler(d, benutzer) { + if (!d || !d.version) { + return 'Das Plugin auf der Seite ist zu alt – bitte "AeroMC Freunde" 1.5.4 oder neuer installieren.'; + } + if (!d.anwendungspasswoerter) return 'Anwendungspasswörter sind auf der Seite abgeschaltet (z. B. durch ein Sicherheits-Plugin).'; + if (!d.authorization_kommt_an && !d.eigener_kopf_kommt_an) { + return 'Der Server leitet keine Anmeldedaten an WordPress weiter (beide Header fehlen) – das muss der Hoster freigeben.'; + } + switch (d.grund) { + case 'invalid_username': return `Den Benutzer "${benutzer}" gibt es nicht (Benutzername, nicht E-Mail-Adresse).`; + case 'incorrect_password': + return 'Falsches Anwendungspasswort. Achtung: Gemeint ist NICHT das Login-Passwort, sondern das unter ' + + 'Benutzer → Profil → "Anwendungspasswörter" erzeugte (Form "abcd efgh ijkl …").'; + case 'application_passwords_disabled': + case 'application_passwords_disabled_for_user': + return `Für "${benutzer}" sind Anwendungspasswörter abgeschaltet.`; + default: break; + } + if (d.benutzer && !d.darf_hochladen) { + return `Anmeldung als "${d.benutzer}" klappt, aber der Benutzer hat nicht die Rolle "AeroMC Release" (Benutzer bearbeiten → Rolle).`; + } + return 'Anmeldung abgelehnt' + (d.grund ? ` (${d.grund})` : '') + '.'; +} + +async function pruefeZugang(zugang) { + const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur/zugang`, { headers: kopfzeilen(zugang) }); + if (res.status === 404) throw new Error('Die Seite kennt die Signatur-Schnittstelle nicht – ist das Plugin "AeroMC Freunde" (ab 1.5.4) installiert und aktiviert?'); + let d = null; + try { d = await res.json(); } catch { /* keine JSON-Antwort */ } + if (res.ok && d && d.ok) return d; + if (res.status === 401 || res.status === 403) throw new Error(zugangsFehler(d, zugang.benutzer)); + throw new Error('WordPress antwortet mit HTTP ' + res.status); +} + +async function wordpressEinrichten() { + console.log('WordPress-Zugang für das automatische Hochladen der Signaturen einrichten.'); + console.log('Vorher in WordPress: Benutzer mit der Rolle "AeroMC Release" anlegen, dann in dessen Profil'); + console.log('unter "Anwendungspasswörter" ein Passwort erzeugen (Name z. B. "AeroMC Build").\n'); + const seite = (await frage(`Adresse der WordPress-Seite [${WP_STANDARD}]: `)) || WP_STANDARD; + if (!/^https:\/\//i.test(seite)) throw new Error('Bitte eine https://-Adresse angeben.'); + const benutzer = await frage('WordPress-Benutzername: '); + const passwort = await frage('Anwendungspasswort (Eingabe bleibt unsichtbar): ', true); + if (!benutzer || !passwort) throw new Error('Benutzername und Passwort werden gebraucht.'); + const zugang = { seite: seite.replace(/\/+$/, ''), benutzer, passwort }; + const antwort = await pruefeZugang(zugang); + fs.mkdirSync(ORDNER, { recursive: true }); + fs.writeFileSync(ZUGANG, JSON.stringify({ seite: zugang.seite, benutzer, passwort: verschluesseln(passwort) }, null, 2), { mode: 0o600 }); + console.log(`\n✔ Zugang geprüft (angemeldet als ${antwort.benutzer}) und gespeichert: ${ZUGANG}`); + console.log(' Das Passwort ist mit Windows-DPAPI verschlüsselt.'); +} + + + +function sha256(datei) { + const h = crypto.createHash('sha256'); + const fd = fs.openSync(datei, 'r'); + const buf = Buffer.alloc(4 * 1024 * 1024); + let n; + while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n)); + fs.closeSync(fd); + return h.digest('hex'); +} + +function signieren(art) { + const name = DATEIEN[art]; + if (!name) throw new Error(`Unbekannte Art "${art}" – erwartet: setup, portable, schluessel, wordpress oder hochladen.`); + const datei = path.join(DIST, name); + if (!fs.existsSync(datei)) throw new Error(`${datei} fehlt – erst bauen.`); + if (!fs.existsSync(PRIVAT)) { + throw new Error(`Kein Release-Schlüssel unter ${PRIVAT}. Einmalig "node scripts/sign-release.js schluessel" ausführen.`); + } + const privat = crypto.createPrivateKey(fs.readFileSync(PRIVAT, 'utf8')); + + const eingebaut = crypto.createPublicKey(fs.readFileSync(OEFFENTLICH, 'utf8')).export({ type: 'spki', format: 'der' }); + const passend = crypto.createPublicKey(privat).export({ type: 'spki', format: 'der' }); + if (!eingebaut.equals(passend)) { + throw new Error('Der private Schlüssel passt nicht zu src/update-schluessel.pem – Signatur wäre für den Launcher wertlos.'); + } + + const version = require(path.join(ROOT, 'package.json')).version; + const hash = sha256(datei); + const sig = { + format: 'AeroMC-Update/1', + datei: name, + version, + sha256: hash, + signatur: crypto.sign(null, signaturText(name, version, hash), privat).toString('base64'), + }; + pruefeSignatur(sig, name, version, hash); + fs.writeFileSync(datei + '.sig', JSON.stringify(sig, null, 2) + '\n'); + console.log(`\n» ${name} ${version} signiert`); + return sig; +} + +async function hochladen(sig) { + const zugang = ladeZugang(); + if (!zugang) { + throw new Error('Kein WordPress-Zugang eingerichtet – Signatur NICHT hochgeladen.\n' + + ' Einmalig ausführen: node scripts/sign-release.js wordpress\n' + + ` danach: node scripts/sign-release.js hochladen ${sig.datei === DATEIEN.setup ? 'setup' : 'portable'}`); + } + // Nie die Signatur einer schon veröffentlichten Datei überschreiben: Dann passte + // die EXE im Gitea-Release nicht mehr, und kein Launcher nähme das Update an. + const vorhanden = await holeSignatur(sig.datei, sig.version); + if (vorhanden && vorhanden.sha256 !== sig.sha256 && process.env.AEROMC_SIG_ERSETZEN !== '1') { + throw new Error(`Für ${sig.datei} ${sig.version} liegt schon eine andere Signatur auf WordPress – ` + + 'diese Version ist vermutlich bereits veröffentlicht. Bitte die Versionsnummer in package.json erhöhen.\n' + + ' (Nur wenn die Version sicher NICHT veröffentlicht ist: AEROMC_SIG_ERSETZEN=1 setzen.)'); + } + if (vorhanden && vorhanden.sha256 === sig.sha256) { + console.log('» Signatur liegt bereits unverändert auf WordPress.'); + return; + } + const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur`, { + method: 'POST', headers: kopfzeilen(zugang), body: JSON.stringify(sig), + }); + let antwort = {}; + try { antwort = await res.json(); } catch { /* leer */ } + if (!res.ok || !antwort.ok) { + throw new Error(`Hochladen fehlgeschlagen (HTTP ${res.status}): ${antwort.message || 'unbekannter Fehler'}`); + } + // Gegenprobe: liefert die öffentliche Schnittstelle genau das, was der Launcher braucht? + const zurueck = await holeSignatur(sig.datei, sig.version); + if (!zurueck || zurueck.signatur !== sig.signatur || zurueck.sha256 !== sig.sha256) { + throw new Error('Signatur wurde hochgeladen, die Seite liefert aber etwas anderes zurück (Cache?).'); + } + console.log(`» Signatur auf ${zugang.seite} hochgeladen und geprüft${antwort.geprueft ? ' (vom Server gegengeprüft)' : ''} – ins Gitea-Release kommt nur die EXE.`); +} + +async function main() { + const [art, zusatz] = process.argv.slice(2); + if (art === 'schluessel') return schluesselAnlegen(); + if (art === 'wordpress') return wordpressEinrichten(); + if (art === 'hochladen') { + const name = DATEIEN[zusatz]; + if (!name) throw new Error('Erwartet: hochladen setup|portable'); + const sigDatei = path.join(DIST, name + '.sig'); + if (!fs.existsSync(sigDatei)) throw new Error(`${sigDatei} fehlt – erst bauen und signieren.`); + const sig = JSON.parse(fs.readFileSync(sigDatei, 'utf8')); + pruefeSignatur(sig, name, sig.version, sha256(path.join(DIST, name))); + return hochladen(sig); + } + return hochladen(signieren(art)); +} + +main().catch((err) => { + console.error('\n✖ ' + err.message); + process.exit(1); +}); diff --git a/scripts/spieldesign-quelle/hintergrund.png b/scripts/spieldesign-quelle/hintergrund.png new file mode 100644 index 0000000..10b20bd Binary files /dev/null and b/scripts/spieldesign-quelle/hintergrund.png differ