3 Commits
Author SHA1 Message Date
M_Viper e5ccf0b4cc Upload via GUI (123 Dateien) 2026-10-03 07:09:09 +00:00
M_Viper 10ee45cbc2 Upload via GUI (123 Dateien) 2026-10-03 07:09:08 +00:00
M_Viper bc68395437 Upload via GUI (123 Dateien) 2026-10-03 07:09:06 +00:00
47 changed files with 4849 additions and 1000 deletions
+22
View File
@@ -2,6 +2,28 @@
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
## [1.1.6] - 2026-10-02
### Neu
- Skin-Editor: Skins direkt im Launcher pixeln – erreichbar im Skin-Fenster über „Im Editor bearbeiten…“
- Zwei Schichten: Grundschicht und 2. Schicht getrennt bemalen – die 2. Schicht liegt wie eine Hülle über der Figur und lässt Haare, Kapuzen oder Ärmel plastisch wirken
- Wahlweise auf der aufgeklappten 2D-Vorlage oder direkt auf der drehbaren 3D-Figur malen – die andere Ansicht läuft als Live-Vorschau mit und ist ebenfalls bemalbar
- Werkzeuge: Stift, Radierer (nur 2. Schicht), Pipette (auch Alt+Klick), Füllen, Farbwähler mit den zuletzt benutzten Farben
- Rückgängig/Wiederholen mit Strg+Z und Strg+Y
- Spiegeln: malt gleichzeitig auf der anderen Körperseite (linker ↔ rechter Arm/Bein)
- Ebenen-Werkzeuge: 2. Schicht ein-/ausblenden, Grundschicht durchscheinen lassen, 2. Schicht leeren
- Aktuellen Skin laden, PNG öffnen oder speichern und das Ergebnis mit einem Klick als Skin übernehmen – klassische und schmale Arme, alte 64×32-Skins werden automatisch umgewandelt
- Skin-Hintergrund: Armform wählbar (Optionen → Darstellung) – „Automatisch“ übernimmt die Armform des Kontos, alternativ fest „Normal“ oder „Schmal“
- Designs zur Auswahl (Optionen → Darstellung → Design, mit Vorschau-Kacheln): „Klassisch“ hell oder dunkel wie bisher, „AeroMC“ im Look der Webseite (Nachtblau, Cyan-Verläufe, runde Knöpfe), „Nachtlila“ (wie AeroMC, nur in dunklem Lila) und „Minecraft“ mit Block-Optik, Stein-Knöpfen, grünem Starten-Knopf und eigener Pixel-Schrift
- Ein gewähltes Design gilt sofort in allen offenen Fenstern
- Eigene Titelleiste im Design statt der weißen Windows-Leiste – Minimieren, Maximieren und Schließen bleiben echte Windows-Knöpfe (Andocken, Fensterlayouts) und werden passend eingefärbt
- Import aus der Modrinth App (Importieren → „Modrinth App“, auch im Installer): übernimmt Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen – auch bei verlegtem Datenordner und während die Modrinth App läuft
- Aus der Modrinth App übernommene Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC aktualisieren
### Behoben
- Das Symbol für „Optionen“ sah aus wie eine Sonne – jetzt ein Zahnrad
- Der Skin-Hintergrund zeigte schmale (Alex-)Skins mit breiten Armen – dadurch erschienen falsche Streifen an den Armen
## [1.1.5] - 2026-10-02 ## [1.1.5] - 2026-10-02
### Neu ### Neu
+48 -11
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung. Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
Aktueller Stand: Version 1.1.5 Aktueller Stand: Version 1.1.6
<img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" /> <img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" />
@@ -41,7 +41,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Warnung beim Entfernen eines Mods, der noch als Pflicht-Abhängigkeit gebraucht wird - Warnung beim Entfernen eines Mods, der noch als Pflicht-Abhängigkeit gebraucht wird
**Import** **Import**
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, MultiMC/Prism sowie Technic - Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, **Modrinth App**, MultiMC/Prism sowie Technic
- Schon bei der Installation: der Installer erkennt andere Launcher und AeroMC bietet deren Instanzen beim ersten Start zum Übernehmen an - Schon bei der Installation: der Installer erkennt andere Launcher und AeroMC bietet deren Instanzen beim ersten Start zum Übernehmen an
- Von Freunden geteilte Instanzen per Code einlösen - Von Freunden geteilte Instanzen per Code einlösen
@@ -64,7 +64,8 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
**Konten, Welten und Backups** **Konten, Welten und Backups**
- Microsoft-Login mit mehreren Konten - Microsoft-Login mit mehreren Konten
- Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen - Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, einstellbare Deckkraft, optional wechselnd) - **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten - Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt) - Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
- Welten mit einem Klick in eine andere Instanz kopieren - Welten mit einem Klick in eine andere Instanz kopieren
@@ -80,7 +81,22 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Update-Suche sofort beim Start, Updates installieren sich ohne Rückfragen - Update-Suche sofort beim Start, Updates installieren sich ohne Rückfragen
- Optionaler Autostart mit Windows (im Infobereich statt als Fenster) - Optionaler Autostart mit Windows (im Infobereich statt als Fenster)
- Rechtsklick-Menü in allen Textfeldern (Ausschneiden, Kopieren, Einfügen) - Rechtsklick-Menü in allen Textfeldern (Ausschneiden, Kopieren, Einfügen)
- Design Hell/Dunkel, optionaler HTTP-Proxy und Wrapper-Befehl - **Fünf Designs**: Klassisch hell oder dunkel, „AeroMC“ im Look der Webseite, „Nachtlila“ (dasselbe in dunklem Lila) und „Minecraft“ mit Block-Optik und Pixel-Schrift – samt passender Titelleiste
- Optionaler HTTP-Proxy und Wrapper-Befehl
## Designs
Unter **Optionen → Darstellung → Design** stehen fünf Designs zur Wahl. Ein Klick auf eine Vorschau-Kachel zeigt es sofort an, mit **Speichern** gilt es in allen Fenstern – inklusive Titelleiste und Fensterknöpfen.
<table>
<tr>
<td align="center"><img src="docs/design-aeromc.png" alt="AeroMC-Design: Nachtblau mit Cyan-Verläufen wie die Webseite" width="270" /><br /><b>AeroMC</b><br /><sub>Nachtblau, Cyan-Verläufe, runde Knöpfe – wie die Webseite</sub></td>
<td align="center"><img src="docs/design-nachtlila.png" alt="Nachtlila-Design: wie AeroMC in dunklem Lila" width="270" /><br /><b>Nachtlila</b><br /><sub>wie AeroMC, nur in dunklem Lila</sub></td>
<td align="center"><img src="docs/design-minecraft.png" alt="Minecraft-Design: Block-Optik mit Grasnarbe, Erde und Stein-Knöpfen" width="270" /><br /><b>Minecraft</b><br /><sub>Grasnarbe, Erde, Stein-Knöpfe und Pixel-Schrift</sub></td>
</tr>
</table>
Dazu **Klassisch Dunkel** (Bild ganz oben) und **Klassisch Hell** im bewährten Launcher-Look.
## Installation ## Installation
@@ -105,7 +121,7 @@ Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable
## Erste Schritte ## Erste Schritte
1. AeroMC installieren oder die portable Datei starten. 1. AeroMC installieren oder die portable Datei starten.
2. Über **Konten** ein Microsoft-Konto hinzufügen. 2. Über den Konto-Knopf oben rechts (oder **Optionen → Konten**) ein Microsoft-Konto hinzufügen.
3. Eine neue Instanz anlegen, Minecraft-Version und optional einen Loader wählen – bei Fabric, Quilt oder NeoForge auf Wunsch gleich mit Performance-Paket. 3. Eine neue Instanz anlegen, Minecraft-Version und optional einen Loader wählen – bei Fabric, Quilt oder NeoForge auf Wunsch gleich mit Performance-Paket.
4. Optional Mods/Ressourcenpakete/Shader hinzufügen oder bestehende Setups über **Importieren** übernehmen. 4. Optional Mods/Ressourcenpakete/Shader hinzufügen oder bestehende Setups über **Importieren** übernehmen.
5. Instanz starten (online oder offline). 5. Instanz starten (online oder offline).
@@ -172,6 +188,7 @@ Im Spiel (solange ein über AeroMC gestartetes Minecraft läuft):
| Modpack | Modrinth- oder CurseForge-Katalog direkt durchsuchen und installieren, oder Datei importieren: `.mrpack` sowie CurseForge-ZIP (`manifest.json`) | | Modpack | Modrinth- oder CurseForge-Katalog direkt durchsuchen und installieren, oder Datei importieren: `.mrpack` sowie CurseForge-ZIP (`manifest.json`) |
| Minecraft Launcher | Profile aus dem offiziellen Launcher | | Minecraft Launcher | Profile aus dem offiziellen Launcher |
| CurseForge | Instanzen aus dem CurseForge-Ordner | | CurseForge | Instanzen aus dem CurseForge-Ordner |
| Modrinth App | Instanzen aus der Modrinth App (`%APPDATA%\ModrinthApp`) inkl. Einstellungen, Spielzeit und Modpack-Verknüpfung |
| MultiMC / Prism | Vorhandene MultiMC- oder Prism-Instanzen | | MultiMC / Prism | Vorhandene MultiMC- oder Prism-Instanzen |
| Technic | Technic-Katalog direkt durchsuchen und installieren, oder eine vorhandene Technic-Installation importieren (`%APPDATA%\.technic\modpacks`) | | Technic | Technic-Katalog direkt durchsuchen und installieren, oder eine vorhandene Technic-Installation importieren (`%APPDATA%\.technic\modpacks`) |
| Per Code | Von einem Freund geteilte Instanz einlösen (siehe **Instanzen → Instanz teilen**) | | Per Code | Von einem Freund geteilte Instanz einlösen (siehe **Instanzen → Instanz teilen**) |
@@ -180,7 +197,9 @@ Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache. Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache.
**Schon bei der Installation:** Der Installer sucht nach Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic – ein portables MultiMC auch über seine Verknüpfung auf dem Desktop oder im Startmenü. Was dort angekreuzt wird, öffnet AeroMC beim ersten Start fertig durchsucht im Import; ausgewählt wird dann nur noch, was übernommen werden soll. In den anderen Launchern wird nichts verändert. **Modrinth App:** Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich weiter aktualisieren. Ein in der App verlegter Datenordner wird automatisch gefunden, und die Modrinth App darf dabei geöffnet bleiben – AeroMC liest auch ihr Schreibprotokoll mit. An den Daten der Modrinth App wird nichts verändert.
**Schon bei der Installation:** Der Installer sucht nach Minecraft Launcher, CurseForge, Modrinth App, MultiMC, Prism Launcher, PolyMC und Technic – ein portables MultiMC auch über seine Verknüpfung auf dem Desktop oder im Startmenü. Was dort angekreuzt wird, öffnet AeroMC beim ersten Start fertig durchsucht im Import; ausgewählt wird dann nur noch, was übernommen werden soll. In den anderen Launchern wird nichts verändert.
Alternativ: `.jar`-, `.mrpack`- oder `.zip`-Dateien direkt aufs Launcher-Fenster ziehen. AeroMC erkennt anhand des Inhalts, ob es sich um einen Mod (landet in der gerade ausgewählten Instanz), ein Modpack oder einen Instanz-Export handelt. Alternativ: `.jar`-, `.mrpack`- oder `.zip`-Dateien direkt aufs Launcher-Fenster ziehen. AeroMC erkennt anhand des Inhalts, ob es sich um einen Mod (landet in der gerade ausgewählten Instanz), ein Modpack oder einen Instanz-Export handelt.
@@ -222,7 +241,6 @@ Ressourcenpakete und Shader funktionieren auch in Vanilla-Instanzen. Mods benöt
- Meldung im Spiel mit 3D-Spielerkopf, sobald ein Freund online kommt - Meldung im Spiel mit 3D-Spielerkopf, sobald ein Freund online kommt
- **Beitreten** startet eine passende eigene Instanz (gleiche Minecraft-Version, bevorzugt mit dem Server in der Liste) und verbindet direkt; gibt es keine, wird eine Vanilla-Instanz angelegt - **Beitreten** startet eine passende eigene Instanz (gleiche Minecraft-Version, bevorzugt mit dem Server in der Liste) und verbindet direkt; gibt es keine, wird eine Vanilla-Instanz angelegt
- **Einladen** schickt Freunden den eigenen aktuellen Server. Im Launcher erscheint die Einladung mit „Beitreten“, im Spiel als Meldung – dort kopiert **Strg+Alt+J** die Adresse für „Direkt verbinden“ - **Einladen** schickt Freunden den eigenen aktuellen Server. Im Launcher erscheint die Einladung mit „Beitreten“, im Spiel als Meldung – dort kopiert **Strg+Alt+J** die Adresse für „Direkt verbinden“
- **Kurznachrichten**: 💬 beim Freund öffnet eine kleine Unterhaltung mit Schnellantworten („Bin in 10 Min. online“, „Komm online!“ …) oder eigenem Text bis 200 Zeichen. Neue Nachrichten erscheinen als Hinweis im Launcher und – beim Spielen – als Meldung im Spiel; der Zähler am „Freunde“-Knopf zählt offene Anfragen und ungelesene Nachrichten. Nachrichten werden nach 7 Tagen gelöscht. - **Kurznachrichten**: 💬 beim Freund öffnet eine kleine Unterhaltung mit Schnellantworten („Bin in 10 Min. online“, „Komm online!“ …) oder eigenem Text bis 200 Zeichen. Neue Nachrichten erscheinen als Hinweis im Launcher und – beim Spielen – als Meldung im Spiel; der Zähler am „Freunde“-Knopf zählt offene Anfragen und ungelesene Nachrichten. Nachrichten werden nach 7 Tagen gelöscht.
- **Screenshots verschicken**: In **Bildschirmfotos verwalten** schickt 📤 ein Bild (verkleinert auf höchstens 1600 Pixel Breite) samt optionalem Text an einen Freund; er sieht es im Chat. - **Screenshots verschicken**: In **Bildschirmfotos verwalten** schickt 📤 ein Bild (verkleinert auf höchstens 1600 Pixel Breite) samt optionalem Text an einen Freund; er sieht es im Chat.
@@ -234,15 +252,34 @@ Das Freundesystem funktioniert nur zwischen Spielern, die AeroMC nutzen und mind
## Skin ändern ## Skin ändern
Über **Konten → Skin ändern…** lässt sich der Skin des aktiven Microsoft-Kontos direkt im Launcher ändern: Über den **eigenen Kontonamen oben rechts → Skin ändern…** lässt sich der Skin des aktiven Microsoft-Kontos direkt im Launcher ändern (der Knopf **Skin** in der Werkzeugleiste schaltet dagegen nur den Skin-Hintergrund ein/aus):
- PNG-Datei auswählen, sofortige Prüfung auf gültige Maße - PNG-Datei auswählen, sofortige Prüfung auf gültige Maße
- Live-Vorschau am 3D-Modell vor dem Anwenden - Live-Vorschau am 3D-Modell vor dem Anwenden
- Modell wählen: klassisch oder schlank (slim) - Modell wählen: klassisch oder schlank (slim)
- Auf einen früheren Standard-Skin zurücksetzen - Auf den Standard-Skin (Steve/Alex) zurücksetzen – einen früheren eigenen Skin holt das nicht zurück; dafür vorher im Skin-Editor „Als PNG speichern…“ nutzen
Die Änderung wird direkt bei Mojang für das angemeldete Konto hinterlegt und gilt somit auch außerhalb von AeroMC. Die Änderung wird direkt bei Mojang für das angemeldete Konto hinterlegt und gilt somit auch außerhalb von AeroMC.
### Skin-Editor
**Im Editor bearbeiten…** öffnet einen eigenen Pixel-Editor – mit der gewählten PNG-Datei oder dem aktuellen Skin des Kontos:
- **Zwei Schichten:** Grundschicht und 2. Schicht (die Hülle über der Figur für Haare, Jacken, Ärmel …) getrennt bemalen; 2. Schicht ein-/ausblenden, Grundschicht darunter durchscheinen lassen, 2. Schicht leeren
- **2D oder 3D:** auf der aufgeklappten Vorlage oder direkt auf der drehbaren Figur malen – die andere Ansicht läuft als Live-Vorschau mit und ist ebenfalls bemalbar
- **Werkzeuge:** Stift, Radierer (nur 2. Schicht), Pipette (auch Alt+Klick), Füllen, Farbwähler mit zuletzt benutzten Farben
- **Spiegeln:** malt gleichzeitig auf der anderen Körperseite
- Rückgängig/Wiederholen, klassische oder schmale Arme, alte 64×32-Skins werden automatisch umgewandelt
- Neu beginnen, aktuellen Skin laden oder eine PNG-Datei öffnen
- **Als Skin übernehmen** (direkt hochladen) oder **Als PNG speichern…**
| Taste | Wirkung |
|---|---|
| B / E / I / G | Stift / Radierer / Pipette / Füllen |
| M | Spiegeln ein/aus |
| L | Schicht wechseln |
| Strg+Z / Strg+Y | Rückgängig / Wiederholen |
## Backup und Wiederherstellung ## Backup und Wiederherstellung
Unter **Optionen → Backup**: Unter **Optionen → Backup**:
@@ -261,7 +298,6 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
## Einstellungen ## Einstellungen
### Allgemein ### Allgemein
- Design (Dunkel / Hell)
- Instanzen sortieren (nach letztem Start / nach Name) - Instanzen sortieren (nach letztem Start / nach Name)
- Instanz-Verzeichnis - Instanz-Verzeichnis
- Bereiche: Bedrock-Bereich anzeigen - Bereiche: Bedrock-Bereich anzeigen
@@ -270,8 +306,9 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
- Autostart: mit Windows starten, im Infobereich statt als Fenster – solange aktiv, versteckt „Schließen“ (X) den Launcher nur. Das Symbol im Infobereich gibt es immer; per Rechtsklick startest du dort Instanzen. - Autostart: mit Windows starten, im Infobereich statt als Fenster – solange aktiv, versteckt „Schließen“ (X) den Launcher nur. Das Symbol im Infobereich gibt es immer; per Rechtsklick startest du dort Instanzen.
### Darstellung ### Darstellung
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
- Block-Symbole räumlich (3D) - Block-Symbole räumlich (3D)
- Skin-Hintergrund: Quelle, Pose, Deckkraft, optional automatischer Wechsel zwischen mehreren Konten - Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten
### Spiel ### Spiel
- Standard-RAM (min./max.) - Standard-RAM (min./max.)
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+19 -1
View File
@@ -5,7 +5,7 @@ using System.Web.Script.Serialization;
namespace AeroMCSetup; namespace AeroMCSetup;
/// <summary>Ein gefundener anderer Launcher, dessen Instanzen AeroMC übernehmen kann.</summary> /// <summary>Ein gefundener anderer Launcher, dessen Instanzen AeroMC übernehmen kann.</summary>
/// <param name="kind">Kennung wie im Import-Dialog des Launchers: mc, cf, mmc, technic</param> /// <param name="kind">Kennung wie im Import-Dialog des Launchers: mc, cf, mr, mmc, technic</param>
public sealed class FoundLauncher(string kind, string name, string path, int count, string unit) public sealed class FoundLauncher(string kind, string name, string path, int count, string unit)
{ {
public string Kind { get; } = kind; public string Kind { get; } = kind;
@@ -37,6 +37,7 @@ public static class LauncherImport
} }
Add(DetectMinecraftLauncher); Add(DetectMinecraftLauncher);
Add(DetectCurseForge); Add(DetectCurseForge);
Add(DetectModrinthApp);
Add(() => DetectMultiMc("Prism Launcher", System.IO.Path.Combine(AppData, "PrismLauncher"))); Add(() => DetectMultiMc("Prism Launcher", System.IO.Path.Combine(AppData, "PrismLauncher")));
Add(() => DetectMultiMc("PolyMC", System.IO.Path.Combine(AppData, "PolyMC"))); Add(() => DetectMultiMc("PolyMC", System.IO.Path.Combine(AppData, "PolyMC")));
// MultiMC (und portable Prism/PolyMC) liegen irgendwo – über ihre Verknüpfungen finden // MultiMC (und portable Prism/PolyMC) liegen irgendwo – über ihre Verknüpfungen finden
@@ -92,6 +93,23 @@ public static class LauncherImport
return new FoundLauncher("cf", "CurseForge", dir, count, count == 1 ? "Instanz" : "Instanzen"); return new FoundLauncher("cf", "CurseForge", dir, count, count == 1 ? "Instanz" : "Instanzen");
} }
// ---------- Modrinth App ----------
private static FoundLauncher? DetectModrinthApp()
{
// neuere Fassungen: ModrinthApp, sehr alte: com.modrinth.theseus (siehe src/mrimport.js)
var root = new[]
{
System.IO.Path.Combine(AppData, "ModrinthApp"),
System.IO.Path.Combine(AppData, "com.modrinth.theseus"),
}.FirstOrDefault(d => Directory.Exists(System.IO.Path.Combine(d, "profiles")));
if (root is null) return null;
string[] markers = ["profile.json", "mods", "config", "resourcepacks", "shaderpacks", "saves", "options.txt"];
int count = Directory.GetDirectories(System.IO.Path.Combine(root, "profiles")).Count(d => markers.Any(m => Exists(d, m)));
return new FoundLauncher("mr", "Modrinth App", root, count, count == 1 ? "Instanz" : "Instanzen");
}
// ---------- Prism Launcher / PolyMC (MultiMC-Format) ---------- // ---------- Prism Launcher / PolyMC (MultiMC-Format) ----------
private static FoundLauncher? DetectMultiMc(string name, string root) private static FoundLauncher? DetectMultiMc(string name, string root)
@@ -5,7 +5,7 @@
}, },
"projects": { "projects": {
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": { "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
"version": "1.1.5", "version": "1.1.6",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")]
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
</configuration>
@@ -0,0 +1,20 @@
AeroMC-Setup
0.0.0.0
winexe
C#
.cs
C:\Users\Viper\Desktop\AeroMC-Launcher\installer\obj\Debug\net48\
AeroMCSetup
none
false
TRACE;DEBUG;NETFRAMEWORK;NET48;NET30_OR_GREATER;NET35_OR_GREATER;NET40_OR_GREATER;NET45_OR_GREATER;NET451_OR_GREATER;NET452_OR_GREATER;NET46_OR_GREATER;NET461_OR_GREATER;NET462_OR_GREATER;NET47_OR_GREATER;NET471_OR_GREATER;NET472_OR_GREATER;NET48_OR_GREATER
C:\Users\Viper\Desktop\AeroMC-Launcher\installer\App.xaml
41549299691
919373854
251587687494
MainWindow.xaml;Themes\Colors.Dark.xaml;Themes\Colors.Light.xaml;Themes\Controls.xaml;
False
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("M_Viper")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("0.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("0.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("0.0.0.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -0,0 +1 @@
f169547649332c2f1c7da272087eb95fe510c6a52616eac752d180c8c6fe1d73
@@ -0,0 +1,14 @@
is_global = true
build_property.ApplicationManifest = app.manifest
build_property.StartupObject =
build_property.ApplicationDefaultFont =
build_property.ApplicationHighDpiMode =
build_property.ApplicationUseCompatibleTextRendering =
build_property.ApplicationVisualStyles =
build_property.RootNamespace = AeroMCSetup
build_property.ProjectDir = C:\Users\Viper\Desktop\AeroMC-Launcher\installer\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle =
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,6 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
Binary file not shown.
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "85CCB5F529C7F3695DF076BCDBCCA84D8F4ED0DE"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace AeroMCSetup {
/// <summary>
/// App
/// </summary>
public partial class App : System.Windows.Application {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/AeroMC-Setup;V0.0.0.0;component/app.xaml", System.UriKind.Relative);
#line 1 "..\..\..\App.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
/// <summary>
/// Application Entry Point.
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
public static void Main() {
AeroMCSetup.App app = new AeroMCSetup.App();
app.InitializeComponent();
app.Run();
}
}
}
Binary file not shown.
+842
View File
@@ -0,0 +1,842 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "8CB5D6EC1BE0A9A19A04D9A92B2B0961370843E0"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace AeroMCSetup {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : System.Windows.Window, System.Windows.Markup.IComponentConnector {
#line 140 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock VersionText;
#line default
#line hidden
#line 142 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel Steps;
#line default
#line hidden
#line 144 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot0;
#line default
#line hidden
#line 144 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num0;
#line default
#line hidden
#line 145 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label0;
#line default
#line hidden
#line 147 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line0;
#line default
#line hidden
#line 149 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot1;
#line default
#line hidden
#line 149 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num1;
#line default
#line hidden
#line 150 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label1;
#line default
#line hidden
#line 152 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line1;
#line default
#line hidden
#line 154 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot2;
#line default
#line hidden
#line 154 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num2;
#line default
#line hidden
#line 155 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label2;
#line default
#line hidden
#line 157 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line2;
#line default
#line hidden
#line 159 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel ImportStepRow;
#line default
#line hidden
#line 160 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot3;
#line default
#line hidden
#line 160 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num3;
#line default
#line hidden
#line 161 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label3;
#line default
#line hidden
#line 163 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line3;
#line default
#line hidden
#line 165 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot4;
#line default
#line hidden
#line 165 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num4;
#line default
#line hidden
#line 166 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label4;
#line default
#line hidden
#line 179 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock WindowTitle;
#line default
#line hidden
#line 185 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button CloseButton;
#line default
#line hidden
#line 193 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageWelcome;
#line default
#line hidden
#line 240 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border UpdateHint;
#line default
#line hidden
#line 245 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock UpdateHintText;
#line default
#line hidden
#line 248 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border RunningHint;
#line default
#line hidden
#line 260 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.DockPanel PageLicense;
#line default
#line hidden
#line 264 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox AcceptLicense;
#line default
#line hidden
#line 271 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel LicensePanel;
#line default
#line hidden
#line 277 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageOptions;
#line default
#line hidden
#line 286 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBox DirBox;
#line default
#line hidden
#line 295 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox DesktopLinkBox;
#line default
#line hidden
#line 301 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageImport;
#line default
#line hidden
#line 306 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel ImportList;
#line default
#line hidden
#line 313 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageProgress;
#line default
#line hidden
#line 314 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border SuccessIcon;
#line default
#line hidden
#line 316 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Media.ScaleTransform SuccessScale;
#line default
#line hidden
#line 321 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock ProgressTitle;
#line default
#line hidden
#line 323 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel ProgressBarPanel;
#line default
#line hidden
#line 324 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock PercentText;
#line default
#line hidden
#line 326 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border ProgressTrack;
#line default
#line hidden
#line 327 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border ProgressFill;
#line default
#line hidden
#line 330 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock ProgressText;
#line default
#line hidden
#line 332 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel DonePanel;
#line default
#line hidden
#line 333 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox LaunchBox;
#line default
#line hidden
#line 338 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageUninstall;
#line default
#line hidden
#line 349 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DataInfo;
#line default
#line hidden
#line 350 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox RemoveDataBox;
#line default
#line hidden
#line 352 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock RemoveDataWarning;
#line default
#line hidden
#line 362 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BackButton;
#line default
#line hidden
#line 363 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button NextButton;
#line default
#line hidden
#line 364 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button CancelButton;
#line default
#line hidden
#line 368 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Grid DialogOverlay;
#line default
#line hidden
#line 369 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border DialogCard;
#line default
#line hidden
#line 372 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Media.ScaleTransform DialogScale;
#line default
#line hidden
#line 376 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogIcon;
#line default
#line hidden
#line 379 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogTitle;
#line default
#line hidden
#line 382 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogText;
#line default
#line hidden
#line 385 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button DialogSecondary;
#line default
#line hidden
#line 386 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button DialogPrimary;
#line default
#line hidden
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/AeroMC-Setup;V0.0.0.0;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
switch (connectionId)
{
case 1:
this.VersionText = ((System.Windows.Controls.TextBlock)(target));
return;
case 2:
this.Steps = ((System.Windows.Controls.StackPanel)(target));
return;
case 3:
this.Dot0 = ((System.Windows.Controls.Border)(target));
return;
case 4:
this.Num0 = ((System.Windows.Controls.TextBlock)(target));
return;
case 5:
this.Label0 = ((System.Windows.Controls.TextBlock)(target));
return;
case 6:
this.Line0 = ((System.Windows.Controls.Border)(target));
return;
case 7:
this.Dot1 = ((System.Windows.Controls.Border)(target));
return;
case 8:
this.Num1 = ((System.Windows.Controls.TextBlock)(target));
return;
case 9:
this.Label1 = ((System.Windows.Controls.TextBlock)(target));
return;
case 10:
this.Line1 = ((System.Windows.Controls.Border)(target));
return;
case 11:
this.Dot2 = ((System.Windows.Controls.Border)(target));
return;
case 12:
this.Num2 = ((System.Windows.Controls.TextBlock)(target));
return;
case 13:
this.Label2 = ((System.Windows.Controls.TextBlock)(target));
return;
case 14:
this.Line2 = ((System.Windows.Controls.Border)(target));
return;
case 15:
this.ImportStepRow = ((System.Windows.Controls.StackPanel)(target));
return;
case 16:
this.Dot3 = ((System.Windows.Controls.Border)(target));
return;
case 17:
this.Num3 = ((System.Windows.Controls.TextBlock)(target));
return;
case 18:
this.Label3 = ((System.Windows.Controls.TextBlock)(target));
return;
case 19:
this.Line3 = ((System.Windows.Controls.Border)(target));
return;
case 20:
this.Dot4 = ((System.Windows.Controls.Border)(target));
return;
case 21:
this.Num4 = ((System.Windows.Controls.TextBlock)(target));
return;
case 22:
this.Label4 = ((System.Windows.Controls.TextBlock)(target));
return;
case 23:
this.WindowTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 24:
#line 183 "..\..\..\MainWindow.xaml"
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.Minimize_Click);
#line default
#line hidden
return;
case 25:
this.CloseButton = ((System.Windows.Controls.Button)(target));
#line 185 "..\..\..\MainWindow.xaml"
this.CloseButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
#line default
#line hidden
return;
case 26:
this.PageWelcome = ((System.Windows.Controls.StackPanel)(target));
return;
case 27:
this.UpdateHint = ((System.Windows.Controls.Border)(target));
return;
case 28:
this.UpdateHintText = ((System.Windows.Controls.TextBlock)(target));
return;
case 29:
this.RunningHint = ((System.Windows.Controls.Border)(target));
return;
case 30:
this.PageLicense = ((System.Windows.Controls.DockPanel)(target));
return;
case 31:
this.AcceptLicense = ((System.Windows.Controls.CheckBox)(target));
#line 265 "..\..\..\MainWindow.xaml"
this.AcceptLicense.Checked += new System.Windows.RoutedEventHandler(this.AcceptLicense_Click);
#line default
#line hidden
#line 265 "..\..\..\MainWindow.xaml"
this.AcceptLicense.Unchecked += new System.Windows.RoutedEventHandler(this.AcceptLicense_Click);
#line default
#line hidden
return;
case 32:
this.LicensePanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 33:
this.PageOptions = ((System.Windows.Controls.StackPanel)(target));
return;
case 34:
#line 285 "..\..\..\MainWindow.xaml"
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.ChangeDir_Click);
#line default
#line hidden
return;
case 35:
this.DirBox = ((System.Windows.Controls.TextBox)(target));
return;
case 36:
this.DesktopLinkBox = ((System.Windows.Controls.CheckBox)(target));
return;
case 37:
this.PageImport = ((System.Windows.Controls.StackPanel)(target));
return;
case 38:
this.ImportList = ((System.Windows.Controls.StackPanel)(target));
return;
case 39:
this.PageProgress = ((System.Windows.Controls.StackPanel)(target));
return;
case 40:
this.SuccessIcon = ((System.Windows.Controls.Border)(target));
return;
case 41:
this.SuccessScale = ((System.Windows.Media.ScaleTransform)(target));
return;
case 42:
this.ProgressTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 43:
this.ProgressBarPanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 44:
this.PercentText = ((System.Windows.Controls.TextBlock)(target));
return;
case 45:
this.ProgressTrack = ((System.Windows.Controls.Border)(target));
#line 326 "..\..\..\MainWindow.xaml"
this.ProgressTrack.SizeChanged += new System.Windows.SizeChangedEventHandler(this.ProgressTrack_SizeChanged);
#line default
#line hidden
return;
case 46:
this.ProgressFill = ((System.Windows.Controls.Border)(target));
return;
case 47:
this.ProgressText = ((System.Windows.Controls.TextBlock)(target));
return;
case 48:
this.DonePanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 49:
this.LaunchBox = ((System.Windows.Controls.CheckBox)(target));
return;
case 50:
this.PageUninstall = ((System.Windows.Controls.StackPanel)(target));
return;
case 51:
this.DataInfo = ((System.Windows.Controls.TextBlock)(target));
return;
case 52:
this.RemoveDataBox = ((System.Windows.Controls.CheckBox)(target));
#line 351 "..\..\..\MainWindow.xaml"
this.RemoveDataBox.Checked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
#line default
#line hidden
#line 351 "..\..\..\MainWindow.xaml"
this.RemoveDataBox.Unchecked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
#line default
#line hidden
return;
case 53:
this.RemoveDataWarning = ((System.Windows.Controls.TextBlock)(target));
return;
case 54:
this.BackButton = ((System.Windows.Controls.Button)(target));
#line 362 "..\..\..\MainWindow.xaml"
this.BackButton.Click += new System.Windows.RoutedEventHandler(this.Back_Click);
#line default
#line hidden
return;
case 55:
this.NextButton = ((System.Windows.Controls.Button)(target));
#line 363 "..\..\..\MainWindow.xaml"
this.NextButton.Click += new System.Windows.RoutedEventHandler(this.Next_Click);
#line default
#line hidden
return;
case 56:
this.CancelButton = ((System.Windows.Controls.Button)(target));
#line 364 "..\..\..\MainWindow.xaml"
this.CancelButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
#line default
#line hidden
return;
case 57:
this.DialogOverlay = ((System.Windows.Controls.Grid)(target));
return;
case 58:
this.DialogCard = ((System.Windows.Controls.Border)(target));
return;
case 59:
this.DialogScale = ((System.Windows.Media.ScaleTransform)(target));
return;
case 60:
this.DialogIcon = ((System.Windows.Controls.TextBlock)(target));
return;
case 61:
this.DialogTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 62:
this.DialogText = ((System.Windows.Controls.TextBlock)(target));
return;
case 63:
this.DialogSecondary = ((System.Windows.Controls.Button)(target));
#line 385 "..\..\..\MainWindow.xaml"
this.DialogSecondary.Click += new System.Windows.RoutedEventHandler(this.DialogSecondary_Click);
#line default
#line hidden
return;
case 64:
this.DialogPrimary = ((System.Windows.Controls.Button)(target));
#line 386 "..\..\..\MainWindow.xaml"
this.DialogPrimary.Click += new System.Windows.RoutedEventHandler(this.DialogPrimary_Click);
#line default
#line hidden
return;
}
this._contentLoaded = true;
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")] [assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")] [assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.5.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.6.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.5")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.6")]
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.1.5.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.1.6.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")] [assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert. // Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -1 +1 @@
6fa3eb63bf9e7c9a77cca67d608dff49330e3a93e70e18548b24192d4ef82422 5b6f28fb41c3f0e5fe29f9489cfc618a7d6593efb71d420030e4a1d4fdb4066e
+1 -1
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {} "C:\\Users\\Viper\\.nuget\\packages\\": {}
}, },
"project": { "project": {
"version": "1.1.5", "version": "1.1.6",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "za8GIOA75zI=", "dgSpecHash": "lJiGEWCFg6M=",
"success": true, "success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
+9 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aeromc", "name": "aeromc",
"version": "1.1.5", "version": "1.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aeromc", "name": "aeromc",
"version": "1.1.5", "version": "1.1.6",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@xhayper/discord-rpc": "^1.3.4", "@xhayper/discord-rpc": "^1.3.4",
@@ -15,6 +15,7 @@
"loopback-capture": "^3.0.2", "loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4", "node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2", "skinview3d": "^3.4.2",
"sql.js": "^1.14.2",
"undici": "^6.28.0", "undici": "^6.28.0",
"unzipper": "^0.12.5" "unzipper": "^0.12.5"
}, },
@@ -4346,6 +4347,12 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true "optional": true
}, },
"node_modules/sql.js": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz",
"integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==",
"license": "MIT"
},
"node_modules/stat-mode": { "node_modules/stat-mode": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.1.5", "version": "1.1.6",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de", "homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net", "launcherWebsite": "https://aeromc.viper.ipv64.net",
@@ -47,6 +47,7 @@
"loopback-capture": "^3.0.2", "loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4", "node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2", "skinview3d": "^3.4.2",
"sql.js": "^1.14.2",
"undici": "^6.28.0", "undici": "^6.28.0",
"unzipper": "^0.12.5" "unzipper": "^0.12.5"
} }
+86
View File
@@ -0,0 +1,86 @@
"""
make-mc-textures.py – erzeugt die 16×16-Kacheln für das Minecraft-Design
(src/assets/design/*.png). Eigene, zufällig erzeugte Muster im Block-Stil –
keine Original-Texturen aus dem Spiel.
Aufruf: python scripts/make-mc-textures.py
"""
import os
import random
from PIL import Image
ZIEL = os.path.join(os.path.dirname(__file__), '..', 'src', 'assets', 'design')
os.makedirs(ZIEL, exist_ok=True)
def klemme(v):
return max(0, min(255, int(v)))
def rauschen(basis, streuung, seed, flecken=None):
"""Grundfarbe mit Pixelrauschen; flecken = (anteil, farbe, streuung)."""
rnd = random.Random(seed)
img = Image.new('RGB', (16, 16))
px = img.load()
for y in range(16):
for x in range(16):
d = rnd.randint(-streuung, streuung)
farbe = basis
if flecken and rnd.random() < flecken[0]:
farbe = flecken[1]
d = rnd.randint(-flecken[2], flecken[2])
px[x, y] = tuple(klemme(c + d) for c in farbe)
return img
def speichere(img, name):
img.save(os.path.join(ZIEL, name))
print(name)
# Hintergrund der Instanzliste: dunkle Erde (wie hinter Menüs im Spiel)
speichere(rauschen((44, 32, 23), 6, 11, (0.16, (31, 22, 15), 4)), 'erde-dunkel.png')
# Leisten (Werkzeugleiste, Seitenleiste, Statusleiste): dunkler Schiefer
schiefer = rauschen((46, 46, 50), 6, 23, (0.12, (34, 34, 38), 3))
px = schiefer.load()
rnd = random.Random(5)
for _ in range(5): # ein paar waagerechte Bruchkanten wie bei Tiefenschiefer
y = rnd.randrange(16)
x0 = rnd.randrange(16)
for x in range(x0, min(16, x0 + rnd.randint(3, 7))):
px[x, y] = (30, 30, 33)
speichere(schiefer, 'schiefer.png')
# Dialoge: Stein
stein = rauschen((78, 78, 80), 8, 37, (0.10, (60, 60, 62), 4))
speichere(stein, 'stein.png')
# Holzbretter (z. B. Kopf der Dialoge): vier Bretter zu je 4 Pixeln
bretter = Image.new('RGB', (16, 16))
px = bretter.load()
rnd = random.Random(41)
farben = [(122, 92, 56), (114, 86, 52), (128, 97, 60), (118, 89, 54)]
fugen = [3, 11, 7, 14]
for brett in range(4):
for y in range(brett * 4, brett * 4 + 4):
for x in range(16):
c = farben[brett]
d = rnd.randint(-5, 5)
if y == brett * 4 + 3: # dunkle Fuge unter jedem Brett
c, d = (74, 55, 33), rnd.randint(-2, 2)
elif x == fugen[brett]: # Stoßfuge
c, d = (88, 66, 40), 0
px[x, y] = tuple(klemme(v + d) for v in c)
speichere(bretter, 'bretter.png')
# Grasnarbe: oberer Rand der Werkzeugleiste (16×4, grün mit "Tropfen")
gras = Image.new('RGBA', (16, 4), (0, 0, 0, 0))
px = gras.load()
rnd = random.Random(53)
tiefe = [2, 3, 2, 2, 4, 3, 2, 3, 2, 2, 3, 4, 2, 3, 2, 3]
for x in range(16):
for y in range(tiefe[x]):
g = rnd.randint(-10, 10)
px[x, y] = (klemme(84 + g), klemme(140 + g), klemme(52 + g // 2), 255)
speichere(gras, 'gras.png')
+270
View File
@@ -0,0 +1,270 @@
'use strict';
/*
* make-pixelfont.js – erzeugt src/assets/aeropixel.ttf
* -----------------------------------------------------
* Eigene Pixel-Schrift für das Minecraft-Design (keine fremde Schrift, also
* keine Lizenzfragen). Jede Glyphe ist ein Raster aus '#' (Pixel) und ' ':
* Zeile 0 ist die Oberkante der Großbuchstaben, Zeile 6 sitzt auf der
* Grundlinie, Zeilen 7–8 sind Unterlängen (g, j, p, q, y, Komma).
* Fehlende Zeichen fallen im Launcher automatisch auf Segoe UI zurück.
*
* Aufruf: node scripts/make-pixelfont.js
*/
const fs = require('fs');
const path = require('path');
const P = 125; // ein Pixel in Font-Einheiten (8 Pixel = 1 em)
const EM = 1000;
const ASC = 1000; // 7 Zeilen Großbuchstaben + 1 Zeile Luft
const DESC = 250; // 2 Zeilen Unterlänge
const G = {
A: [' ### ', '# #', '# #', '#####', '# #', '# #', '# #'],
B: ['#### ', '# #', '# #', '#### ', '# #', '# #', '#### '],
C: [' ### ', '# #', '# ', '# ', '# ', '# #', ' ### '],
D: ['#### ', '# #', '# #', '# #', '# #', '# #', '#### '],
E: ['#####', '# ', '# ', '#### ', '# ', '# ', '#####'],
F: ['#####', '# ', '# ', '#### ', '# ', '# ', '# '],
G: [' ####', '# ', '# ', '# ##', '# #', '# #', ' ####'],
H: ['# #', '# #', '# #', '#####', '# #', '# #', '# #'],
I: ['###', ' # ', ' # ', ' # ', ' # ', ' # ', '###'],
J: [' #', ' #', ' #', ' #', ' #', '# #', ' ### '],
K: ['# #', '# # ', '# # ', '## ', '# # ', '# # ', '# #'],
L: ['# ', '# ', '# ', '# ', '# ', '# ', '#####'],
M: ['# #', '## ##', '# # #', '# #', '# #', '# #', '# #'],
N: ['# #', '## #', '# # #', '# ##', '# #', '# #', '# #'],
O: [' ### ', '# #', '# #', '# #', '# #', '# #', ' ### '],
P: ['#### ', '# #', '# #', '#### ', '# ', '# ', '# '],
Q: [' ### ', '# #', '# #', '# #', '# #', '# # ', ' ## #'],
R: ['#### ', '# #', '# #', '#### ', '# # ', '# #', '# #'],
S: [' ####', '# ', '# ', ' ### ', ' #', ' #', '#### '],
T: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
U: ['# #', '# #', '# #', '# #', '# #', '# #', ' ### '],
V: ['# #', '# #', '# #', '# #', '# #', ' # # ', ' # '],
W: ['# #', '# #', '# #', '# #', '# # #', '## ##', '# #'],
X: ['# #', '# #', ' # # ', ' # ', ' # # ', '# #', '# #'],
Y: ['# #', '# #', ' # # ', ' # ', ' # ', ' # ', ' # '],
Z: ['#####', ' #', ' # ', ' # ', ' # ', '# ', '#####'],
a: [' ', ' ', ' ### ', ' #', ' ####', '# #', ' ####'],
b: ['# ', '# ', '# ## ', '## #', '# #', '# #', '#### '],
c: [' ', ' ', ' ### ', '# #', '# ', '# #', ' ### '],
d: [' #', ' #', ' ## #', '# ##', '# #', '# #', ' ####'],
e: [' ', ' ', ' ### ', '# #', '#####', '# ', ' ####'],
f: [' ##', ' # ', '####', ' # ', ' # ', ' # ', ' # '],
g: [' ', ' ', ' ####', '# #', '# #', '# #', ' ####', ' #', ' ### '],
h: ['# ', '# ', '# ## ', '## #', '# #', '# #', '# #'],
i: ['#', ' ', '#', '#', '#', '#', '#'],
j: [' #', ' ', ' #', ' #', ' #', ' #', ' #', '# #', ' ## '],
k: ['# ', '# ', '# #', '# # ', '## ', '# # ', '# #'],
l: ['# ', '# ', '# ', '# ', '# ', '# ', ' #'],
m: [' ', ' ', '## # ', '# # #', '# # #', '# #', '# #'],
n: [' ', ' ', '#### ', '# #', '# #', '# #', '# #'],
o: [' ', ' ', ' ### ', '# #', '# #', '# #', ' ### '],
p: [' ', ' ', '# ## ', '## #', '# #', '# #', '#### ', '# ', '# '],
q: [' ', ' ', ' ## #', '# ##', '# #', '# #', ' ####', ' #', ' #'],
r: [' ', ' ', '# ## ', '## #', '# ', '# ', '# '],
s: [' ', ' ', ' ####', '# ', ' ### ', ' #', '#### '],
t: [' # ', ' # ', '###', ' # ', ' # ', ' # ', ' #'],
u: [' ', ' ', '# #', '# #', '# #', '# #', ' ####'],
v: [' ', ' ', '# #', '# #', '# #', ' # # ', ' # '],
w: [' ', ' ', '# #', '# #', '# # #', '# # #', ' ####'],
x: [' ', ' ', '# #', ' # # ', ' # ', ' # # ', '# #'],
y: [' ', ' ', '# #', '# #', '# #', '# #', ' ####', ' #', '#### '],
z: [' ', ' ', '#####', ' # ', ' # ', ' # ', '#####'],
0: [' ### ', '# #', '# ##', '# # #', '## #', '# #', ' ### '],
1: [' # ', ' ## ', ' # ', ' # ', ' # ', ' # ', '#####'],
2: [' ### ', '# #', ' #', ' ## ', ' # ', '# ', '#####'],
3: [' ### ', '# #', ' #', ' ## ', ' #', '# #', ' ### '],
4: [' ##', ' # #', ' # #', '# #', '#####', ' #', ' #'],
5: ['#####', '# ', '#### ', ' #', ' #', '# #', ' ### '],
6: [' ## ', ' # ', '# ', '#### ', '# #', '# #', ' ### '],
7: ['#####', '# #', ' #', ' # ', ' # ', ' # ', ' # '],
8: [' ### ', '# #', '# #', ' ### ', '# #', '# #', ' ### '],
9: [' ### ', '# #', '# #', ' ####', ' #', ' # ', ' ## '],
'Ä': ['# #', ' ### ', '# #', '# #', '#####', '# #', '# #'],
'Ö': ['# #', ' ### ', '# #', '# #', '# #', '# #', ' ### '],
'Ü': ['# #', ' ', '# #', '# #', '# #', '# #', ' ### '],
'ä': [' # # ', ' ', ' ### ', ' #', ' ####', '# #', ' ####'],
'ö': [' # # ', ' ', ' ### ', '# #', '# #', '# #', ' ### '],
'ü': [' # # ', ' ', '# #', '# #', '# #', '# #', ' ####'],
'ß': [' ### ', '# #', '# # ', '# # ', '# # ', '# #', '# ## '],
'é': [' # ', ' # ', ' ### ', '# #', '#####', '# ', ' ####'],
'.': [' ', ' ', ' ', ' ', ' ', ' ', '#'],
',': [' ', ' ', ' ', ' ', ' ', ' ', '#', '#'],
':': [' ', ' ', '#', ' ', ' ', ' ', '#'],
';': [' ', ' ', '#', ' ', ' ', ' ', '#', '#'],
'!': ['#', '#', '#', '#', '#', ' ', '#'],
'?': [' ### ', '# #', ' #', ' # ', ' # ', ' ', ' # '],
'-': [' ', ' ', ' ', '####', ' ', ' ', ' '],
'–': [' ', ' ', ' ', '#####', ' ', ' ', ' '],
'_': [' ', ' ', ' ', ' ', ' ', ' ', ' ', '#####'],
'(': [' #', ' # ', '# ', '# ', '# ', ' # ', ' #'],
')': ['# ', ' # ', ' #', ' #', ' #', ' # ', '# '],
'[': ['##', '# ', '# ', '# ', '# ', '# ', '##'],
']': ['##', ' #', ' #', ' #', ' #', ' #', '##'],
'/': [' #', ' # ', ' # ', ' # ', ' # ', ' # ', '# '],
'\\': ['# ', ' # ', ' # ', ' # ', ' # ', ' # ', ' #'],
'+': [' ', ' # ', ' # ', '#####', ' # ', ' # ', ' '],
'=': [' ', ' ', '#####', ' ', '#####', ' ', ' '],
'*': [' ', ' ', '# # #', ' ### ', '# # #', ' ', ' '],
'%': ['# #', ' #', ' # ', ' # ', ' # ', '# ', '# #'],
'&': [' ## ', '# # ', ' ## ', ' ## #', '# # ', '# # ', ' ## #'],
'#': [' # # ', '#####', ' # # ', ' # # ', ' # # ', '#####', ' # # '],
'<': [' #', ' # ', ' # ', '# ', ' # ', ' # ', ' #'],
'>': ['# ', ' # ', ' # ', ' #', ' # ', ' # ', '# '],
"'": ['#', '#'],
'"': ['# #', '# #'],
'|': ['#', '#', '#', '#', '#', '#', '#', '#', '#'],
'·': [' ', ' ', ' ', '#', ' ', ' ', ' '],
'…': [' ', ' ', ' ', ' ', ' ', ' ', '# # #'],
'@': [' ### ', '# #', '# ###', '# # #', '# ###', '# ', ' ####'],
};
// ---------- Glyphen in Umrisse umwandeln (waagerechte Pixel-Läufe als Rechtecke) ----------
function umrisse(zeilen) {
const konturen = [];
zeilen.forEach((zeile, r) => {
const yOben = (7 - r) * P;
const yUnten = yOben - P;
let x = 0;
while (x < zeile.length) {
if (zeile[x] !== '#') { x++; continue; }
let ende = x;
while (ende < zeile.length && zeile[ende] === '#') ende++;
// im Uhrzeigersinn (TrueType-Außenkontur)
konturen.push([[x * P, yUnten], [x * P, yOben], [ende * P, yOben], [ende * P, yUnten]]);
x = ende;
}
});
return konturen;
}
const glyphen = [{ name: '.notdef', code: null, breite: 4, konturen: [[[0, 0], [0, 875], [500, 875], [500, 0]], [[125, 125], [375, 125], [375, 750], [125, 750]]] },
{ name: 'space', code: 32, breite: 3, konturen: [] }];
for (const [zeichen, zeilen] of Object.entries(G)) {
const breite = Math.max(...zeilen.map((z) => z.length));
glyphen.push({ code: zeichen.codePointAt(0), breite, konturen: umrisse(zeilen) });
}
const sortiert = [glyphen[0], glyphen[1], ...glyphen.slice(2).sort((a, b) => a.code - b.code)];
// ---------- Hilfen zum Schreiben ----------
class Puffer {
constructor() { this.teile = []; this.laenge = 0; }
u8(v) { const b = Buffer.alloc(1); b.writeUInt8(v); return this.roh(b); }
u16(v) { const b = Buffer.alloc(2); b.writeUInt16BE(v & 0xffff); return this.roh(b); }
i16(v) { const b = Buffer.alloc(2); b.writeInt16BE(v); return this.roh(b); }
u32(v) { const b = Buffer.alloc(4); b.writeUInt32BE(v >>> 0); return this.roh(b); }
i64(v) { const b = Buffer.alloc(8); b.writeBigInt64BE(BigInt(v)); return this.roh(b); }
roh(b) { this.teile.push(b); this.laenge += b.length; return this; }
fertig() { return Buffer.concat(this.teile); }
}
const auf4 = (b) => (b.length % 4 ? Buffer.concat([b, Buffer.alloc(4 - (b.length % 4))]) : b);
function pruefsumme(b) {
const p = auf4(b);
let s = 0;
for (let i = 0; i < p.length; i += 4) s = (s + p.readUInt32BE(i)) >>> 0;
return s;
}
// ---------- glyf + loca + hmtx ----------
const glyf = new Puffer();
const loca = [];
const hmtx = new Puffer();
let maxPunkte = 0, maxKonturen = 0;
let gxMin = 0, gyMin = 0, gxMax = 0, gyMax = 0;
let maxVorschub = 0, minLsb = 1e9, minRsb = 1e9, maxXAusdehnung = 0;
for (const g of sortiert) {
loca.push(glyf.laenge);
const vorschub = (g.breite + 1) * P;
maxVorschub = Math.max(maxVorschub, vorschub);
if (!g.konturen.length) { hmtx.u16(vorschub).i16(0); continue; }
const punkte = g.konturen.flat();
const xs = punkte.map((p) => p[0]), ys = punkte.map((p) => p[1]);
const xMin = Math.min(...xs), xMax = Math.max(...xs), yMin = Math.min(...ys), yMax = Math.max(...ys);
gxMin = Math.min(gxMin, xMin); gyMin = Math.min(gyMin, yMin); gxMax = Math.max(gxMax, xMax); gyMax = Math.max(gyMax, yMax);
minLsb = Math.min(minLsb, xMin); minRsb = Math.min(minRsb, vorschub - xMax); maxXAusdehnung = Math.max(maxXAusdehnung, xMax);
maxPunkte = Math.max(maxPunkte, punkte.length); maxKonturen = Math.max(maxKonturen, g.konturen.length);
const b = new Puffer();
b.i16(g.konturen.length).i16(xMin).i16(yMin).i16(xMax).i16(yMax);
let ende = -1;
for (const k of g.konturen) { ende += k.length; b.u16(ende); }
b.u16(0); // keine Hinting-Anweisungen
for (let i = 0; i < punkte.length; i++) b.u8(0x01); // alle Punkte auf der Kurve, Koordinaten als int16
let px = 0, py = 0;
for (const [x] of punkte) { b.i16(x - px); px = x; }
for (const [, y] of punkte) { b.i16(y - py); py = y; }
glyf.roh(auf4(b.fertig()));
hmtx.u16(vorschub).i16(xMin);
}
loca.push(glyf.laenge);
const anzahl = sortiert.length;
const locaB = new Puffer();
for (const o of loca) locaB.u32(o);
// ---------- cmap (Format 4, ein Segment je Zeichen) ----------
const abbildung = sortiert.map((g, i) => [g.code, i]).filter(([c]) => c !== null).sort((a, b) => a[0] - b[0]);
const segmente = abbildung.map(([c, i]) => ({ start: c, ende: c, delta: (i - c) & 0xffff }));
segmente.push({ start: 0xffff, ende: 0xffff, delta: 1 });
const segX2 = segmente.length * 2;
const suchbereich = 2 * 2 ** Math.floor(Math.log2(segmente.length));
const sub = new Puffer();
sub.u16(4).u16(16 + segmente.length * 8).u16(0).u16(segX2).u16(suchbereich)
.u16(Math.log2(suchbereich / 2)).u16(segX2 - suchbereich);
for (const s of segmente) sub.u16(s.ende);
sub.u16(0);
for (const s of segmente) sub.u16(s.start);
for (const s of segmente) sub.u16(s.delta);
for (const s of segmente) sub.u16(0);
const cmap = new Puffer().u16(0).u16(1).u16(3).u16(1).u32(12).roh(sub.fertig());
// ---------- head / hhea / maxp / OS/2 / name / post ----------
const zeit = Math.floor(Date.UTC(2026, 9, 3) / 1000) + 2082844800; // Sekunden seit 1904
const head = new Puffer().u32(0x00010000).u32(0x00010000).u32(0).u32(0x5f0f3cf5).u16(0x000b).u16(EM)
.i64(zeit).i64(zeit).i16(gxMin).i16(gyMin).i16(gxMax).i16(gyMax).u16(0).u16(8).i16(2).i16(1).i16(0);
const hhea = new Puffer().u32(0x00010000).i16(ASC).i16(-DESC).i16(0).u16(maxVorschub).i16(minLsb).i16(minRsb)
.i16(maxXAusdehnung).i16(1).i16(0).i16(0).i16(0).i16(0).i16(0).i16(0).i16(0).u16(anzahl);
const maxp = new Puffer().u32(0x00010000).u16(anzahl).u16(maxPunkte).u16(maxKonturen).u16(0).u16(0)
.u16(2).u16(0).u16(0).u16(0).u16(0).u16(0).u16(0).u16(0).u16(0);
const codes = abbildung.map(([c]) => c);
const os2 = new Puffer().u16(4).i16(5 * P).u16(400).u16(5).u16(0)
.i16(650).i16(600).i16(0).i16(75).i16(650).i16(600).i16(0).i16(350).i16(P).i16(3 * P)
.i16(0).roh(Buffer.alloc(10)).u32(3).u32(0).u32(0).u32(0).roh(Buffer.from('AERO'))
.u16(0x40).u16(Math.min(...codes)).u16(Math.min(0xffff, Math.max(...codes)))
.i16(ASC).i16(-DESC).i16(0).u16(ASC).u16(DESC).u32(1).u32(0).i16(5 * P).i16(7 * P).u16(0).u16(32).u16(1);
const namen = { 1: 'AeroPixel', 2: 'Regular', 3: 'AeroPixel Regular', 4: 'AeroPixel', 5: 'Version 1.0', 6: 'AeroPixel-Regular' };
const nameDaten = Object.entries(namen).map(([id, text]) => [Number(id), Buffer.from(text, 'utf16le').swap16()]);
const name = new Puffer().u16(0).u16(nameDaten.length).u16(6 + nameDaten.length * 12);
let versatz = 0;
for (const [id, b] of nameDaten) { name.u16(3).u16(1).u16(0x409).u16(id).u16(b.length).u16(versatz); versatz += b.length; }
for (const [, b] of nameDaten) name.roh(b);
const post = new Puffer().u32(0x00030000).u32(0).i16(-P).i16(P).u32(0).u32(0).u32(0).u32(0).u32(0);
// ---------- zusammensetzen ----------
const tabellen = [
['OS/2', os2], ['cmap', cmap], ['glyf', glyf], ['head', head], ['hhea', hhea],
['hmtx', hmtx], ['loca', locaB], ['maxp', maxp], ['name', name], ['post', post],
].map(([tag, p]) => [tag, p.fertig()]);
const n = tabellen.length;
const sb = 16 * 2 ** Math.floor(Math.log2(n));
const kopf = new Puffer().u32(0x00010000).u16(n).u16(sb).u16(Math.log2(sb / 16)).u16(n * 16 - sb);
let offset = 12 + n * 16;
const verzeichnis = new Puffer();
for (const [tag, daten] of tabellen) {
verzeichnis.roh(Buffer.from(tag, 'latin1')).u32(pruefsumme(daten)).u32(offset).u32(daten.length);
offset += auf4(daten).length;
}
let schrift = Buffer.concat([kopf.fertig(), verzeichnis.fertig(), ...tabellen.map(([, d]) => auf4(d))]);
// checkSumAdjustment im head-Eintrag setzen
const headOffset = 12 + n * 16 + tabellen.slice(0, 3).reduce((s, [, d]) => s + auf4(d).length, 0);
schrift.writeUInt32BE((0xb1b0afba - pruefsumme(schrift)) >>> 0, headOffset + 8);
const ziel = path.join(__dirname, '..', 'src', 'assets', 'aeropixel.ttf');
fs.writeFileSync(ziel, schrift);
console.log(`${ziel}: ${anzahl} Glyphen, ${schrift.length} Bytes`);
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

+424
View File
@@ -0,0 +1,424 @@
/*
* designs.css – wählbare Designs (Optionen → Darstellung → Design)
* ---------------------------------------------------------------
* Klassisch (hell/dunkel) steht in styles.css. Die Designs hier bauen
* auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über
* data-design – gleiche Spezifität, deshalb muss diese Datei NACH styles.css
* geladen werden.
*/
/* ===================== Design-Auswahl in den Optionen ===================== */
.design-raster{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.settings-card-wide .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
@media (max-width:900px){.settings-card-wide .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
.design-wahl{display:flex;flex-direction:column;gap:5px;padding:6px;border:1px solid var(--border);border-radius:8px;background:none;color:var(--text);font-family:inherit;font-size:12px;cursor:pointer;text-align:left}
.design-wahl:hover{border-color:var(--sel-br)}
.design-wahl.on{border-color:var(--sel-br);box-shadow:0 0 0 1px var(--sel-br);background:var(--sel)}
.design-wahl small{font-size:12px;font-weight:600;padding:0 2px}
.dv{position:relative;display:block;width:100%;align-self:stretch;height:58px;border-radius:5px;overflow:hidden;border:1px solid rgba(0,0,0,.25)}
.dv i,.dv b,.dv em{position:absolute;display:block}
.dv i{left:0;right:0;top:0;height:11px}
.dv em{top:11px;right:0;bottom:0;width:24%}
.dv b{left:10px;top:20px;width:22px;height:22px;border-radius:3px}
.dv-dark{background:#242426}.dv-dark i{background:#333337}.dv-dark em{background:#2d2d30;border-left:1px solid #3f3f46}.dv-dark b{background:#0e639c;border:1px solid #1177bb}
.dv-light{background:#e2e2e2}.dv-light i{background:#f0f0f0;border-bottom:1px solid #c4c7cc}.dv-light em{background:#f0f0f0;border-left:1px solid #c4c7cc}.dv-light b{background:#dce8f6;border:1px solid #3a6ea5}
.dv-aero{background:radial-gradient(60px 30px at 35% 10px,rgba(64,126,232,.55),transparent 70%),linear-gradient(to right,rgba(148,175,255,.09) 1px,transparent 1px) 0 0/9px 9px,linear-gradient(to bottom,rgba(148,175,255,.09) 1px,transparent 1px) 0 0/9px 9px,#05070d}
.dv-aero i{background:rgba(10,14,26,.95);border-bottom:1px solid rgba(148,175,255,.18)}
.dv-aero em{background:#0a0e1a;border-left:1px solid rgba(148,175,255,.18)}
.dv-aero b{border-radius:7px;background:linear-gradient(135deg,#8fd8ff,#407ee8 60%,#2658be);box-shadow:0 4px 12px -3px rgba(64,126,232,.9)}
.dv-lila{background:radial-gradient(60px 30px at 35% 10px,rgba(139,92,246,.6),transparent 70%),linear-gradient(to right,rgba(196,165,255,.09) 1px,transparent 1px) 0 0/9px 9px,linear-gradient(to bottom,rgba(196,165,255,.09) 1px,transparent 1px) 0 0/9px 9px,#08050f}
.dv-lila i{background:rgba(15,10,26,.95);border-bottom:1px solid rgba(196,165,255,.18)}
.dv-lila em{background:#0f0a1a;border-left:1px solid rgba(196,165,255,.18)}
.dv-lila b{border-radius:7px;background:linear-gradient(135deg,#e8beff,#8b5cf6 60%,#6d28d9);box-shadow:0 4px 12px -3px rgba(139,92,246,.9)}
.dv-mc{background:url(assets/design/erde-dunkel.png) 0 0/24px 24px;image-rendering:pixelated}
.dv-mc i{height:12px;background:url(assets/design/gras.png) 0 0/16px 4px repeat-x,url(assets/design/schiefer.png) 0 0/24px 24px;border-bottom:1px solid #000}
.dv-mc em{background:url(assets/design/schiefer.png) 0 0/24px 24px;border-left:1px solid #000}
.dv-mc b{border-radius:0;background:#3c8527;border:1px solid #000;box-shadow:inset 1px 1px 0 #6cc349,inset -1px -1px 0 #1d4d13}
/* ===================================================================== */
/* ============ Designs: AeroMC (Blau, wie die Webseite) & Nachtlila ===== */
/* ===================================================================== */
/* Beide Designs teilen sich alle Regeln unten; sie unterscheiden sich nur in
der Palette. --p-* ohne "#" sind RGB-Tripel, damit rgba(var(--p-…),.5) geht. */
:root[data-design="aero"]{
--p-akzent:64,126,232; --p-tief:38,88,190; --p-akzent2:104,196,250; --p-hell:143,216,255;
--p-linie:148,175,255; --p-win:10,14,26; --p-surface:13,20,40; --p-surface2:16,26,53; --p-schatten:2,4,10;
--p-akzent-hover:#4f8df0; --p-tief-hover:#2c63cc; --p-aktiv:#0b1226; --p-konsole:#03050a;
--win:#0a0e1a; --area:#05070d; --bar:#0d1428; --surface:#0d1428; --surface2:#101a35; --input:#070b16;
--text:#e8ecfb; --dim:#93a0c4; --link:#8fd8ff;
}
:root[data-design="lila"]{
--p-akzent:139,92,246; --p-tief:109,40,217; --p-akzent2:192,132,252; --p-hell:232,190,255;
--p-linie:196,165,255; --p-win:15,10,26; --p-surface:24,15,41; --p-surface2:32,20,56; --p-schatten:6,3,12;
--p-akzent-hover:#9d72ff; --p-tief-hover:#7c3aed; --p-aktiv:#140c24; --p-konsole:#050309;
--win:#0f0a1a; --area:#08050f; --bar:#180f29; --surface:#180f29; --surface2:#201438; --input:#0b0715;
--text:#f0eafb; --dim:#a99cc6; --link:#e8beff;
}
:root:is([data-design="aero"],[data-design="lila"]){
--border:rgba(var(--p-linie),.14); --border2:rgba(var(--p-linie),.09);
--sel:rgba(var(--p-akzent),.20); --sel-br:var(--p-akzent-hover); --accent:rgb(var(--p-akzent));
--danger:#ff7a8a; --play:#4ade80;
--cyan:rgb(var(--p-hell)); --cyan-soft:rgb(var(--p-akzent2));
--stat-bar:rgb(var(--p-akzent2));
--titel-bg:var(--win); --titel-fg:var(--text);
}
:root:is([data-design="aero"],[data-design="lila"]) ::selection{background:rgb(var(--p-akzent));color:#fff}
:root:is([data-design="aero"],[data-design="lila"]) .titel-text{font-weight:600;background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 70%);-webkit-background-clip:text;background-clip:text;color:transparent}
:root:is([data-design="aero"],[data-design="lila"]) body{background:var(--area)}
/* Werkzeugleiste */
:root:is([data-design="aero"],[data-design="lila"]) .toolbar{background:rgba(var(--p-win),.94);border-bottom:1px solid var(--border);padding:7px 8px;gap:2px}
:root:is([data-design="aero"],[data-design="lila"]) .tbtn{border-radius:999px;padding:6px 10px;color:var(--text)}
:root:is([data-design="aero"],[data-design="lila"]) .tbtn:hover{background:var(--surface);border-color:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .tbtn:active{background:var(--surface2)}
:root:is([data-design="aero"],[data-design="lila"]) .tbtn svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-new svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-import svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-open-dir svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-console svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-account svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-settings svg,
:root:is([data-design="aero"],[data-design="lila"]) .acct-av svg{color:var(--cyan-soft)}
:root:is([data-design="aero"],[data-design="lila"]) .tbsep{background:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px;padding:5px 12px;width:150px}
/* Instanzliste: Raster und blaues Leuchten wie im Kopf der Webseite */
:root:is([data-design="aero"],[data-design="lila"]) .workspace{background:var(--area)}
:root:is([data-design="aero"],[data-design="lila"]) .list-area{background:
radial-gradient(520px 260px at 32% -60px,rgba(var(--p-akzent),.30),transparent 70%),
linear-gradient(to right,rgba(var(--p-linie),.05) 1px,transparent 1px) 0 0/44px 44px,
linear-gradient(to bottom,rgba(var(--p-linie),.05) 1px,transparent 1px) 0 0/44px 44px,
var(--area)}
:root:is([data-design="aero"],[data-design="lila"]) .group-header{color:var(--text);padding:8px 4px 4px}
:root:is([data-design="aero"],[data-design="lila"]) .group-header .chev{color:var(--cyan-soft)}
:root:is([data-design="aero"],[data-design="lila"]) .group-header::after{background:linear-gradient(to right,rgba(var(--p-linie),.22),rgba(var(--p-linie),.04))}
:root:is([data-design="aero"],[data-design="lila"]) .group-grid{gap:6px}
:root:is([data-design="aero"],[data-design="lila"]) .card{border-radius:14px;padding:8px 4px 6px;transition:background .15s,border-color .15s,box-shadow .15s}
:root:is([data-design="aero"],[data-design="lila"]) .card:hover{background:rgba(var(--p-surface),.85);border-color:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .card.sel{background:linear-gradient(180deg,rgba(var(--p-akzent),.24),rgba(var(--p-tief),.14));border-color:rgba(var(--p-akzent2),.55);box-shadow:0 10px 28px -14px rgba(var(--p-akzent),.95),0 0 0 1px rgba(var(--p-hell),.08) inset}
:root:is([data-design="aero"],[data-design="lila"]) .card.sel .card-name{background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));color:#fff;border-radius:999px}
:root:is([data-design="aero"],[data-design="lila"]) .card-icon-img{border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .card-taste{border-radius:999px;background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)))}
:root:is([data-design="aero"],[data-design="lila"]) .empty h2{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));-webkit-background-clip:text;background-clip:text;color:transparent;font-size:26px}
/* Seitenleiste */
:root:is([data-design="aero"],[data-design="lila"]) .side{background:var(--win);border-left:1px solid var(--border);padding:10px 6px}
:root:is([data-design="aero"],[data-design="lila"]) .side-ico img{border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .side-grp{color:var(--cyan-soft)}
:root:is([data-design="aero"],[data-design="lila"]) .hr{background:var(--border);margin:8px 4px}
:root:is([data-design="aero"],[data-design="lila"]) .sbtn{border-radius:999px;margin-bottom:1px;padding:5px 2px}
:root:is([data-design="aero"],[data-design="lila"]) .sbtn:hover{background:var(--surface);border-color:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play{color:#fff;background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)));border-color:transparent;box-shadow:0 0 0 1px rgba(255,255,255,.08) inset,0 10px 24px -12px rgba(var(--p-akzent),.95);padding:7px 4px}
:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play:hover{background:linear-gradient(180deg,var(--p-akzent-hover),var(--p-tief-hover))}
:root:is([data-design="aero"],[data-design="lila"]) .sbtn.del:hover{color:var(--danger);border-color:rgba(255,122,138,.35)}
/* Statusleiste */
:root:is([data-design="aero"],[data-design="lila"]) .statusbar{background:var(--win);border-top:1px solid var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .st-row{color:var(--dim)}
:root:is([data-design="aero"],[data-design="lila"]) .st-row2{background:var(--input);border-top-color:var(--border2)}
/* Dialoge */
:root:is([data-design="aero"],[data-design="lila"]) .modal{background:rgba(var(--p-schatten),.68);backdrop-filter:blur(3px)}
:root:is([data-design="aero"],[data-design="lila"]) .modal-card{background:var(--win);border:1px solid var(--border);border-radius:16px;box-shadow:0 30px 80px -20px rgba(0,0,0,.85),0 0 0 1px rgba(var(--p-hell),.04)}
:root:is([data-design="aero"],[data-design="lila"]) .modal-head{background:linear-gradient(180deg,rgba(var(--p-surface2),.9),rgba(var(--p-win),.9));border-bottom:1px solid var(--border);padding:12px 16px}
:root:is([data-design="aero"],[data-design="lila"]) .modal-body{background:var(--win)}
:root:is([data-design="aero"],[data-design="lila"]) .modal-foot{background:var(--win);border-top:1px solid var(--border);padding:12px 16px}
:root:is([data-design="aero"],[data-design="lila"]) .modal-close{color:var(--dim)}
:root:is([data-design="aero"],[data-design="lila"]) .modal-close:hover{color:var(--cyan)}
/* Eingabefelder */
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input,
:root:is([data-design="aero"],[data-design="lila"]) .field input,
:root:is([data-design="aero"],[data-design="lila"]) .field select,
:root:is([data-design="aero"],[data-design="lila"]) .field textarea,
:root:is([data-design="aero"],[data-design="lila"]) .mods-search input,
:root:is([data-design="aero"],[data-design="lila"]) .mods-search select,
:root:is([data-design="aero"],[data-design="lila"]) .mods-filters select,
:root:is([data-design="aero"],[data-design="lila"]) .mmc-bar select,
:root:is([data-design="aero"],[data-design="lila"]) .clip-length select,
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,
:root:is([data-design="aero"],[data-design="lila"]) .se-farbzeile input[type=text],
:root:is([data-design="aero"],[data-design="lila"]) .se-feld select{background:var(--input);color:var(--text);border-color:var(--border);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input:focus,
:root:is([data-design="aero"],[data-design="lila"]) .field input:focus,
:root:is([data-design="aero"],[data-design="lila"]) .field select:focus,
:root:is([data-design="aero"],[data-design="lila"]) .field textarea:focus,
:root:is([data-design="aero"],[data-design="lila"]) .mods-search input:focus,
:root:is([data-design="aero"],[data-design="lila"]) .path-row input:focus{border-color:var(--cyan-soft);box-shadow:0 0 0 3px rgba(var(--p-akzent2),.15)}
:root:is([data-design="aero"],[data-design="lila"]) input[type=checkbox],:root:is([data-design="aero"],[data-design="lila"]) input[type=radio],:root:is([data-design="aero"],[data-design="lila"]) input[type=range]{accent-color:rgb(var(--p-akzent))}
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
/* Knöpfe – rund wie auf der Webseite */
:root:is([data-design="aero"],[data-design="lila"]) .btn{border-radius:999px;padding:7px 15px;background:var(--surface);border-color:var(--border);color:var(--text);transition:background .15s,border-color .15s,transform .15s}
:root:is([data-design="aero"],[data-design="lila"]) .btn:hover{background:var(--surface2);border-color:rgba(var(--p-linie),.32)}
:root:is([data-design="aero"],[data-design="lila"]) .btn:active{background:var(--p-aktiv)}
:root:is([data-design="aero"],[data-design="lila"]) .btn-primary{background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)));border-color:transparent;color:#fff;box-shadow:0 0 0 1px rgba(255,255,255,.08) inset,0 10px 28px -12px rgba(var(--p-akzent),.9)}
:root:is([data-design="aero"],[data-design="lila"]) .btn-primary:hover{background:linear-gradient(180deg,var(--p-akzent-hover),var(--p-tief-hover));transform:translateY(-1px)}
:root:is([data-design="aero"],[data-design="lila"]) .btn-primary:disabled{transform:none}
:root:is([data-design="aero"],[data-design="lila"]) .btn-danger{color:var(--danger);border-color:rgba(255,122,138,.35)}
:root:is([data-design="aero"],[data-design="lila"]) .btn-danger:hover{background:rgba(255,122,138,.10);border-color:rgba(255,122,138,.6)}
:root:is([data-design="aero"],[data-design="lila"]) .btn-mini{padding:4px 10px}
:root:is([data-design="aero"],[data-design="lila"]) .badge{background:var(--surface2);color:var(--dim);border-color:var(--border);border-radius:999px}
/* Listen, Kacheln, Tabs */
:root:is([data-design="aero"],[data-design="lila"]) .mods-list,
:root:is([data-design="aero"],[data-design="lila"]) .account-item,
:root:is([data-design="aero"],[data-design="lila"]) .screen-thumb{background:var(--input);border-color:var(--border);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .mod-icon,
:root:is([data-design="aero"],[data-design="lila"]) .account-av,
:root:is([data-design="aero"],[data-design="lila"]) .icon-preview{background:var(--surface2);border-radius:8px}
:root:is([data-design="aero"],[data-design="lila"]) .mod-item{border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .mod-item+.mod-item{border-color:var(--border2)}
:root:is([data-design="aero"],[data-design="lila"]) .mod-item:hover{background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .mtab{border-radius:10px 10px 0 0}
:root:is([data-design="aero"],[data-design="lila"]) .mtab.on{background:var(--surface);border-color:var(--border);color:var(--cyan)}
:root:is([data-design="aero"],[data-design="lila"]) .advanced{background:var(--surface);border-color:var(--border);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .console-out{background:var(--p-konsole);border-color:var(--border);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .stat-tile,
:root:is([data-design="aero"],[data-design="lila"]) .crash-fund,
:root:is([data-design="aero"],[data-design="lila"]) .share-code-box,
:root:is([data-design="aero"],[data-design="lila"]) .share-preview,
:root:is([data-design="aero"],[data-design="lila"]) .clip-bar,
:root:is([data-design="aero"],[data-design="lila"]) .wahl-option{border-radius:12px;background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .chat-verlauf{background:var(--input);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .chat-blase{background:var(--surface2);border-radius:14px}
:root:is([data-design="aero"],[data-design="lila"]) .chat-blase.ich{background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)));border-color:transparent}
:root:is([data-design="aero"],[data-design="lila"]) .stat-bar{border-radius:0 6px 6px 0;background:linear-gradient(90deg,rgb(var(--p-akzent)),rgb(var(--p-akzent2)))}
/* Einstellungen */
:root:is([data-design="aero"],[data-design="lila"]) .settings-body{background:var(--area)}
:root:is([data-design="aero"],[data-design="lila"]) .settings-nav{background:var(--win);border-right-color:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .snav{border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .snav:hover{background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .snav.on{background:linear-gradient(120deg,rgba(var(--p-akzent),.30),rgba(var(--p-tief),.18));border-color:rgba(var(--p-akzent2),.40);box-shadow:none}
:root:is([data-design="aero"],[data-design="lila"]) .settings-card{background:var(--surface);border-color:var(--border);border-radius:16px}
:root:is([data-design="aero"],[data-design="lila"]) .settings-kv-row,
:root:is([data-design="aero"],[data-design="lila"]) .about-brand{border-bottom-color:var(--border2)}
:root:is([data-design="aero"],[data-design="lila"]) .appinfo-item{background:rgba(var(--p-linie),.04);border-color:var(--border2);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .info-meta-link{color:var(--cyan)}
:root:is([data-design="aero"],[data-design="lila"]) .settings-hero h4{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 70%);-webkit-background-clip:text;background-clip:text;color:transparent}
/* Kontextmenü, Hinweis, Bildlaufleisten */
:root:is([data-design="aero"],[data-design="lila"]) .ctx{background:var(--surface);border-color:var(--border);border-radius:12px;box-shadow:0 18px 40px -12px rgba(0,0,0,.8)}
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item{border-radius:8px}
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)}
:root:is([data-design="aero"],[data-design="lila"]) .toast{background:var(--surface2);border:1px solid var(--border);border-radius:999px;padding:10px 18px}
:root:is([data-design="aero"],[data-design="lila"]) ::-webkit-scrollbar-thumb{background:rgba(var(--p-linie),.18);border-color:var(--area)}
:root:is([data-design="aero"],[data-design="lila"]) ::-webkit-scrollbar-thumb:hover{background:rgba(var(--p-linie),.3)}
/* Skin-Editor */
:root:is([data-design="aero"],[data-design="lila"]) .se-modus{border-radius:999px;background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .se-modus-btn.on{background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)))}
:root:is([data-design="aero"],[data-design="lila"]) .se-tool,:root:is([data-design="aero"],[data-design="lila"]) .se-ebene{background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .se-tool{border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .se-ebenen{border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .se-buehne,:root:is([data-design="aero"],[data-design="lila"]) .se-mini{border-radius:14px;background:var(--input)}
:root:is([data-design="aero"],[data-design="lila"]) .design-wahl{border-radius:12px}
/* ===================================================================== */
/* ===================== Design: Minecraft ============================= */
/* ===================================================================== */
@font-face{font-family:"AeroPixel";src:url(assets/aeropixel.ttf) format("truetype");font-display:block}
:root[data-design="minecraft"]{
--win:#313233; --area:#1e1e1f; --bar:#48494a;
--border:#000; --border2:#1a1a1b;
--text:#ffffff; --dim:#b8b9bc;
--sel:#2f6b1f; --sel-br:#6cc349; --link:#86d75e; --accent:#3c8527;
--danger:#ff5555; --play:#6cc349; --input:#000;
--stat-bar:#6cc349;
--pixel:"AeroPixel","Segoe UI",sans-serif;
--mc-schatten:2px 2px 0 #3f3f3f;
}
:root[data-design="minecraft"] body{background:var(--area)}
:root[data-design="minecraft"] ::selection{background:#3c8527;color:#fff}
:root[data-design="minecraft"]{--titel-bg:#1e1e1f;--titel-fg:#ffffff}
:root[data-design="minecraft"] .titel-text{font-family:var(--pixel);font-size:16px;text-shadow:var(--mc-schatten);padding-bottom:2px}
/* Wiederkehrender Block-Knopf: grauer Stein mit hellem/dunklem Rand */
:root[data-design="minecraft"] .btn,
:root[data-design="minecraft"] .sbtn,
:root[data-design="minecraft"] .snav,
:root[data-design="minecraft"] .se-tool,
:root[data-design="minecraft"] .wahl-option{
background:#58595b;color:#fff;border:2px solid #000;border-radius:0;
box-shadow:inset 2px 2px 0 #8b8c8f,inset -2px -2px 0 #36373a;
text-shadow:1px 1px 0 #2a2a2a}
:root[data-design="minecraft"] .btn:hover,
:root[data-design="minecraft"] .sbtn:hover,
:root[data-design="minecraft"] .snav:hover,
:root[data-design="minecraft"] .se-tool:hover:not(:disabled),
:root[data-design="minecraft"] .wahl-option:hover{background:#6a6b6f;border-color:#fff;box-shadow:inset 2px 2px 0 #a3a4a8,inset -2px -2px 0 #3e3f42}
:root[data-design="minecraft"] .btn:active,
:root[data-design="minecraft"] .sbtn:active{background:#48494b;box-shadow:inset -2px -2px 0 #8b8c8f,inset 2px 2px 0 #36373a}
:root[data-design="minecraft"] .btn:disabled,
:root[data-design="minecraft"] .sbtn:disabled{background:#2b2b2c;color:#8b8b8b;box-shadow:none;border-color:#000;text-shadow:none;opacity:1}
:root[data-design="minecraft"] .sbtn:disabled:hover{background:#2b2b2c;border-color:#000}
/* grün wie der Spielen-Knopf */
:root[data-design="minecraft"] .btn-primary,
:root[data-design="minecraft"] .sbtn.play,
:root[data-design="minecraft"] .snav.on,
:root[data-design="minecraft"] .se-tool.on,
:root[data-design="minecraft"] .wahl-option.an{background:#3c8527;color:#fff;box-shadow:inset 2px 2px 0 #6cc349,inset -2px -2px 0 #1d4d13}
:root[data-design="minecraft"] .btn-primary:hover,
:root[data-design="minecraft"] .sbtn.play:hover{background:#4a9a33;box-shadow:inset 2px 2px 0 #82d65c,inset -2px -2px 0 #24601a;border-color:#fff}
:root[data-design="minecraft"] .btn-danger{background:#8c2b2b;box-shadow:inset 2px 2px 0 #c55050,inset -2px -2px 0 #561616}
:root[data-design="minecraft"] .btn-danger:hover{background:#a33434;border-color:#fff;box-shadow:inset 2px 2px 0 #d86262,inset -2px -2px 0 #651b1b}
:root[data-design="minecraft"] .sbtn.del:hover{background:#8c2b2b;color:#fff;box-shadow:inset 2px 2px 0 #c55050,inset -2px -2px 0 #561616}
/* Werkzeugleiste: Schiefer mit Grasnarbe */
:root[data-design="minecraft"] .toolbar{
background:url(assets/design/gras.png) 0 0/32px 8px repeat-x,url(assets/design/schiefer.png) 0 0/64px 64px;
image-rendering:pixelated;border-bottom:2px solid #000;padding:11px 8px 6px}
:root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto}
:root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff}
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
:root[data-design="minecraft"] .tbtn svg,:root[data-design="minecraft"] #btn-console svg,:root[data-design="minecraft"] .acct-av svg{color:#e6e6e6}
:root[data-design="minecraft"] #btn-new svg,:root[data-design="minecraft"] #btn-account svg{color:#7fd858}
:root[data-design="minecraft"] #btn-import svg{color:#6db8ff}
:root[data-design="minecraft"] #btn-open-dir svg{color:#f2c94c}
:root[data-design="minecraft"] #btn-settings svg{color:#d0d0d0}
:root[data-design="minecraft"] .tbsep{background:#000;width:2px}
:root[data-design="minecraft"] .tb-badge{border-radius:0;background:#ff5555;box-shadow:inset -1px -1px 0 #a32a2a}
/* Eingabefelder: schwarz mit grauem Rand wie im Spiel */
:root[data-design="minecraft"] .search-wrap input,
:root[data-design="minecraft"] .field input,
:root[data-design="minecraft"] .field select,
:root[data-design="minecraft"] .field textarea,
:root[data-design="minecraft"] .mods-search input,
:root[data-design="minecraft"] .mods-search select,
:root[data-design="minecraft"] .mods-filters select,
:root[data-design="minecraft"] .mmc-bar select,
:root[data-design="minecraft"] .clip-length select,
:root[data-design="minecraft"] .path-row input,
:root[data-design="minecraft"] .login-usercode,
:root[data-design="minecraft"] .upd-notes,
:root[data-design="minecraft"] .se-farbzeile input[type=text],
:root[data-design="minecraft"] .se-feld select{background:#000;color:#fff;border:1px solid #a0a0a0;border-radius:0}
:root[data-design="minecraft"] .search-wrap input:focus,
:root[data-design="minecraft"] .field input:focus,
:root[data-design="minecraft"] .field select:focus,
:root[data-design="minecraft"] .field textarea:focus,
:root[data-design="minecraft"] .mods-search input:focus,
:root[data-design="minecraft"] .path-row input:focus{border-color:#fff;box-shadow:none}
:root[data-design="minecraft"] input[type=checkbox],:root[data-design="minecraft"] input[type=radio],:root[data-design="minecraft"] input[type=range]{accent-color:#3c8527}
/* Instanzliste auf dunkler Erde */
:root[data-design="minecraft"] .workspace{background:var(--area)}
:root[data-design="minecraft"] .list-area{background:url(assets/design/erde-dunkel.png) 0 0/64px 64px;image-rendering:pixelated}
:root[data-design="minecraft"] .list-area::after,
:root[data-design="minecraft"] .list{image-rendering:auto}
:root[data-design="minecraft"] .group-header{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten);padding:8px 4px 4px}
:root[data-design="minecraft"] .group-count{color:#b8b9bc}
:root[data-design="minecraft"] .group-header .chev{color:#fff;font-family:"Segoe UI",sans-serif;font-size:10px}
:root[data-design="minecraft"] .group-header::after{height:2px;background:rgba(0,0,0,.55);box-shadow:0 1px 0 rgba(255,255,255,.08)}
:root[data-design="minecraft"] .card{border-radius:0}
:root[data-design="minecraft"] .card-name{color:#fff;text-shadow:1px 1px 0 #1a1a1a}
:root[data-design="minecraft"] .card:hover{background:rgba(0,0,0,.35);border-color:rgba(255,255,255,.35)}
:root[data-design="minecraft"] .card.sel{background:rgba(0,0,0,.6);border-color:#fff;outline:1px solid #fff;outline-offset:-2px}
:root[data-design="minecraft"] .card.sel .card-name{background:none;color:#ffff7f;border-radius:0}
:root[data-design="minecraft"] .card-taste{border-radius:0;background:#3c8527;box-shadow:inset -1px -1px 0 #1d4d13}
:root[data-design="minecraft"] .card-icon-img{border-radius:0}
:root[data-design="minecraft"] .empty h2{font-family:var(--pixel);font-weight:400;font-size:24px;color:#fff;text-shadow:3px 3px 0 #3f3f3f}
:root[data-design="minecraft"] .empty p{color:#d0d0d0}
/* Seitenleiste: Knöpfe gestapelt wie im Pausenmenü */
:root[data-design="minecraft"] .side{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-left:2px solid #000;padding:10px 6px}
:root[data-design="minecraft"] .side>*{image-rendering:auto}
:root[data-design="minecraft"] .side-title{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
:root[data-design="minecraft"] .side-grp{color:#86d75e}
:root[data-design="minecraft"] .side-ico img{border-radius:0}
:root[data-design="minecraft"] .side-empty{color:#d0d0d0}
:root[data-design="minecraft"] .hr{background:#000;height:2px;margin:6px 0}
:root[data-design="minecraft"] .sbtn{margin-bottom:3px;padding:5px 2px}
:root[data-design="minecraft"] .sbtn.play{font-family:var(--pixel);font-size:16px;font-weight:400;padding:6px 4px 7px;text-shadow:var(--mc-schatten)}
/* Statusleiste */
:root[data-design="minecraft"] .statusbar{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-top:2px solid #000}
:root[data-design="minecraft"] .st-row{color:#e6e6e6;text-shadow:1px 1px 0 #1a1a1a}
:root[data-design="minecraft"] .st-row2{background:rgba(0,0,0,.4);border-top:2px solid #000}
/* Dialoge: dunkles Steinfenster mit Holzbalken als Kopf */
:root[data-design="minecraft"] .modal{background:rgba(0,0,0,.62)}
:root[data-design="minecraft"] .modal-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f,0 10px 30px rgba(0,0,0,.6)}
:root[data-design="minecraft"] .modal-head{background:url(assets/design/bretter.png) 0 0/64px 64px;image-rendering:pixelated;border-bottom:2px solid #000;margin:2px 2px 0}
:root[data-design="minecraft"] .modal-head h3{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten)}
:root[data-design="minecraft"] .modal-head>*{image-rendering:auto}
:root[data-design="minecraft"] .modal-body{background:#313233;margin:0 2px}
:root[data-design="minecraft"] .modal-foot{background:#262627;border-top:2px solid #000;margin:0 2px 2px}
:root[data-design="minecraft"] .modal-close{color:#fff;text-shadow:1px 1px 0 #3f3f3f;font-size:16px}
:root[data-design="minecraft"] .modal-close:hover{color:#ffff7f}
:root[data-design="minecraft"] .popout-host .modal-card{box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f}
/* Listen, Kacheln, Tabs */
:root[data-design="minecraft"] .mods-list,
:root[data-design="minecraft"] .account-item,
:root[data-design="minecraft"] .screen-thumb,
:root[data-design="minecraft"] .chat-verlauf,
:root[data-design="minecraft"] .console-out{background:#000;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #3a3a3c}
:root[data-design="minecraft"] .account-item{background:#1e1e1f}
:root[data-design="minecraft"] .account-item.active{border-color:#6cc349}
:root[data-design="minecraft"] .mod-icon,
:root[data-design="minecraft"] .account-av,
:root[data-design="minecraft"] .icon-preview{background:#48494a;border-radius:0}
:root[data-design="minecraft"] .mod-item{border-radius:0}
:root[data-design="minecraft"] .mod-item+.mod-item{border-color:#2a2a2b}
:root[data-design="minecraft"] .mod-item:hover{background:#262627;outline:1px solid #8b8b8b;outline-offset:-1px}
:root[data-design="minecraft"] .mods-tabs{border-bottom:2px solid #000}
:root[data-design="minecraft"] .mtab{border-radius:0;color:#c8c8c8;margin-bottom:-2px;border-width:2px}
:root[data-design="minecraft"] .mtab.on{background:#48494a;border-color:#000;color:#fff;text-shadow:1px 1px 0 #2a2a2a}
:root[data-design="minecraft"] .advanced,
:root[data-design="minecraft"] .stat-tile,
:root[data-design="minecraft"] .crash-fund,
:root[data-design="minecraft"] .share-code-box,
:root[data-design="minecraft"] .share-preview,
:root[data-design="minecraft"] .clip-bar,
:root[data-design="minecraft"] .crash-unklar{background:#262627;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a}
:root[data-design="minecraft"] .crash-fund{border-left:4px solid #3c8527}
:root[data-design="minecraft"] .badge{border-radius:0;background:#48494a;color:#e6e6e6;border-color:#000}
:root[data-design="minecraft"] .chat-blase{border-radius:0;background:#48494a;border:2px solid #000}
:root[data-design="minecraft"] .chat-blase.ich{background:#3c8527;border-color:#000}
:root[data-design="minecraft"] .stat-bar{border-radius:0;box-shadow:inset 0 -2px 0 #3c8527}
:root[data-design="minecraft"] .mod-new{border-radius:0}
:root[data-design="minecraft"] .friend-avatar{border-radius:0}
/* Einstellungen */
:root[data-design="minecraft"] .settings-body{background:url(assets/design/erde-dunkel.png) 0 0/64px 64px;image-rendering:pixelated}
:root[data-design="minecraft"] .settings-body>*{image-rendering:auto}
:root[data-design="minecraft"] .settings-nav{background:rgba(0,0,0,.45);border-right:2px solid #000;gap:4px}
:root[data-design="minecraft"] .settings-nav-label{color:#d0d0d0;text-shadow:1px 1px 0 #1a1a1a}
:root[data-design="minecraft"] .snav{padding:8px 12px}
:root[data-design="minecraft"] .settings-hero h4{font-family:var(--pixel);font-size:24px;font-weight:400;text-shadow:3px 3px 0 #3f3f3f}
:root[data-design="minecraft"] .settings-hero p{color:#d0d0d0}
:root[data-design="minecraft"] .settings-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f}
:root[data-design="minecraft"] .settings-card h5{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
:root[data-design="minecraft"] .settings-kv-row,
:root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b}
:root[data-design="minecraft"] .appinfo-item{background:#262627;border:2px solid #000;border-radius:0}
:root[data-design="minecraft"] .info-meta-link{color:#86d75e}
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
/* Kontextmenü, Hinweis, Bildlaufleisten */
:root[data-design="minecraft"] .ctx{background:#1e1e1f;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a,0 8px 24px rgba(0,0,0,.5)}
:root[data-design="minecraft"] .ctx-item{border-radius:0;text-shadow:1px 1px 0 #000}
:root[data-design="minecraft"] .ctx-item:hover{background:#3c8527}
:root[data-design="minecraft"] .ctx-danger:hover{background:#8c2b2b}
:root[data-design="minecraft"] .ctx-sep{background:#000;height:2px}
:root[data-design="minecraft"] .toast{background:#212122;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #4a4a4c,inset -2px -2px 0 #111,0 6px 20px rgba(0,0,0,.5);text-shadow:1px 1px 0 #000}
:root[data-design="minecraft"] .toast.err{background:#5c1a1a}
:root[data-design="minecraft"] ::-webkit-scrollbar-thumb{background:#8b8b8b;border:3px solid var(--area);border-radius:0;box-shadow:inset -2px -2px 0 #555}
:root[data-design="minecraft"] ::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
/* Skin-Editor */
:root[data-design="minecraft"] .se-modus{border:2px solid #000;border-radius:0;background:#2b2b2c}
:root[data-design="minecraft"] .se-modus-btn{color:#d0d0d0;text-shadow:1px 1px 0 #000}
:root[data-design="minecraft"] .se-modus-btn+.se-modus-btn{border-left:2px solid #000}
:root[data-design="minecraft"] .se-modus-btn.on{background:#3c8527;color:#fff;box-shadow:inset 2px 2px 0 #6cc349,inset -2px -2px 0 #1d4d13}
:root[data-design="minecraft"] .se-ebenen{border:2px solid #000;border-radius:0}
:root[data-design="minecraft"] .se-ebene{background:#2b2b2c;color:#d0d0d0}
:root[data-design="minecraft"] .se-ebene+.se-ebene{border-left:2px solid #000}
:root[data-design="minecraft"] .se-ebene.on{background:#3c8527;color:#fff}
:root[data-design="minecraft"] .se-buehne,:root[data-design="minecraft"] .se-mini{border:2px solid #000;border-radius:0;background:#1e1e1f}
:root[data-design="minecraft"] .se-farbfeld{border-radius:0;border-color:#000}
:root[data-design="minecraft"] .se-titel{color:#d0d0d0}
:root[data-design="minecraft"] .design-wahl{border-radius:0;border:2px solid #000;background:#262627}
:root[data-design="minecraft"] .design-wahl.on{border-color:#fff;box-shadow:none;background:#2f6b1f}
:root[data-design="minecraft"] .dv{border-radius:0}
+157 -8
View File
@@ -6,8 +6,15 @@
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self';" /> content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self';" />
<title>AeroMC</title> <title>AeroMC</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="designs.css" />
</head> </head>
<body> <body>
<!-- Eigene Titelleiste (Fensterknöpfe zeichnet Windows rechts darüber) -->
<div class="titelleiste">
<img src="assets/logo.png" alt="" class="titel-logo" />
<span class="titel-text">AeroMC</span>
</div>
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) --> <!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
<header class="toolbar"> <header class="toolbar">
<button id="btn-new" class="tbtn" data-ic="plus" title="Neue Instanz (Strg+N)">Instanz hinzufügen</button> <button id="btn-new" class="tbtn" data-ic="plus" title="Neue Instanz (Strg+N)">Instanz hinzufügen</button>
@@ -250,14 +257,7 @@
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-card"> <section class="settings-card">
<h5>Oberfläche</h5> <h5>Instanzliste</h5>
<label class="field">
<span>Design</span>
<select id="s-theme">
<option value="dark">Dunkel</option>
<option value="light">Hell</option>
</select>
</label>
<label class="field"> <label class="field">
<span>Instanzen sortieren</span> <span>Instanzen sortieren</span>
<select id="s-sortby"> <select id="s-sortby">
@@ -323,6 +323,37 @@
<p>Optik der Oberfläche und visuelle Extras für die Instanzansicht.</p> <p>Optik der Oberfläche und visuelle Extras für die Instanzansicht.</p>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-card settings-card-wide">
<h5>Design</h5>
<div class="field">
<select id="s-theme" class="hidden">
<option value="dark">Klassisch – Dunkel</option>
<option value="light">Klassisch – Hell</option>
<option value="aero">AeroMC</option>
<option value="lila">Nachtlila</option>
<option value="minecraft">Minecraft</option>
</select>
<div class="design-raster">
<button type="button" class="design-wahl" data-design="dark" title="Der bewährte Launcher-Look in Dunkelgrau">
<span class="dv dv-dark"><i></i><b></b><em></em></span><small>Klassisch Dunkel</small>
</button>
<button type="button" class="design-wahl" data-design="light" title="Der bewährte Launcher-Look in Hell">
<span class="dv dv-light"><i></i><b></b><em></em></span><small>Klassisch Hell</small>
</button>
<button type="button" class="design-wahl" data-design="aero" title="Nachtblau mit Cyan-Akzenten – wie die AeroMC-Webseite">
<span class="dv dv-aero"><i></i><b></b><em></em></span><small>AeroMC</small>
</button>
<button type="button" class="design-wahl" data-design="lila" title="Wie AeroMC, nur in dunklem Lila">
<span class="dv dv-lila"><i></i><b></b><em></em></span><small>Nachtlila</small>
</button>
<button type="button" class="design-wahl" data-design="minecraft" title="Block-Optik wie im Spiel">
<span class="dv dv-mc"><i></i><b></b><em></em></span><small>Minecraft</small>
</button>
</div>
</div>
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
</section>
<section class="settings-card"> <section class="settings-card">
<h5>Icons</h5> <h5>Icons</h5>
<label class="checkbox-field"> <label class="checkbox-field">
@@ -350,6 +381,14 @@
<option value="spooky">Unheimlich</option> <option value="spooky">Unheimlich</option>
</select> </select>
</label> </label>
<label class="field">
<span>Armform</span>
<select id="s-bgskin-model" title="Bei „Automatisch“ wird die Armform übernommen, die beim Konto eingestellt ist">
<option value="auto">Automatisch (vom Konto)</option>
<option value="classic">Normal (breite Arme, Steve)</option>
<option value="slim">Schmal (Alex)</option>
</select>
</label>
<label class="field"> <label class="field">
<span>Deckkraft: <span id="s-bgskin-val">55</span> %</span> <span>Deckkraft: <span id="s-bgskin-val">55</span> %</span>
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" /> <input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
@@ -1213,6 +1252,7 @@
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button> <button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
<button id="skin-editor" class="btn" title="Skin selbst pixeln – mit Grundschicht und 2. Schicht">Im Editor bearbeiten…</button>
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn" data-close>Schließen</button> <button class="btn" data-close>Schließen</button>
<button id="skin-apply" class="btn btn-primary" disabled>Skin setzen</button> <button id="skin-apply" class="btn btn-primary" disabled>Skin setzen</button>
@@ -1220,6 +1260,86 @@
</div> </div>
</div> </div>
<!-- ===================== Dialog: Skin-Editor ===================== -->
<div id="modal-skineditor" class="modal hidden">
<div class="modal-card se-karte">
<div class="modal-head">
<h3>Skin-Editor</h3>
<div class="se-modus">
<button class="se-modus-btn on" data-se-modus="2d" title="Auf der aufgeklappten Vorlage malen">2D-Vorlage</button>
<button class="se-modus-btn" data-se-modus="3d" title="Direkt auf der drehbaren Figur malen">3D-Figur</button>
</div>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body se-body">
<aside class="se-werkzeuge">
<div class="se-gruppe">
<div class="se-titel">Werkzeug</div>
<div class="se-tools">
<button class="se-tool on" data-se-tool="stift" title="Stift (B)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L18 10l-4-4L4 16v4zM14 6l4 4"/></svg><span>Stift</span></button>
<button class="se-tool" data-se-tool="radierer" title="Radierer (E)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 20h12M5.5 14.5l8-8a2 2 0 012.8 0l2.2 2.2a2 2 0 010 2.8L11 19H8.5l-3-3a1.1 1.1 0 010-1.5zM9 11l5 5"/></svg><span>Radierer</span></button>
<button class="se-tool" data-se-tool="pipette" title="Pipette (I) – auch Alt+Klick"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 7l3 3M5 19l1-4 8.5-8.5a2.1 2.1 0 013 3L9 18l-4 1zM15.5 5.5l1.5-1.5a2.1 2.1 0 013 3l-1.5 1.5"/></svg><span>Pipette</span></button>
<button class="se-tool" data-se-tool="fuellen" title="Füllen (G) – färbt gleichfarbige Pixel einer Seite"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11l7-7 7 7-7 7zM5 11h14M20 16s1.5 2 1.5 3a1.5 1.5 0 01-3 0c0-1 1.5-3 1.5-3z"/></svg><span>Füllen</span></button>
</div>
</div>
<div class="se-gruppe">
<div class="se-titel">Farbe</div>
<div class="se-farbzeile">
<input id="se-farbe" type="color" value="#3a6ea5" title="Farbe wählen" />
<input id="se-hex" type="text" maxlength="7" spellcheck="false" value="#3a6ea5" />
</div>
<div id="se-zuletzt" class="se-zuletzt"></div>
</div>
<div class="se-gruppe">
<div class="se-titel">Ebene</div>
<div class="se-ebenen">
<button class="se-ebene on" data-se-schicht="0" title="Die eigentliche Figur (L wechselt)">Grundschicht</button>
<button class="se-ebene" data-se-schicht="1" title="Hülle über der Figur – Haare, Jacke, Ärmel … (L wechselt)">2. Schicht</button>
</div>
<label class="se-check"><input id="se-aussen" type="checkbox" checked /> 2. Schicht zeigen</label>
<label class="se-check"><input id="se-durch" type="checkbox" checked /> Grundschicht durchscheinen</label>
<button id="se-leeren" class="btn btn-mini">2. Schicht leeren</button>
</div>
<div class="se-gruppe">
<div class="se-titel">Optionen</div>
<label class="se-check" title="Malt gleichzeitig auf der anderen Körperseite (M)"><input id="se-spiegeln" type="checkbox" /> Spiegeln (links ↔ rechts)</label>
<label class="se-feld"><span>Armform</span>
<select id="se-variante">
<option value="classic">Klassisch (Steve)</option>
<option value="slim">Schmal (Alex)</option>
</select>
</label>
<div class="se-undo">
<button id="se-undo" class="btn btn-mini" title="Rückgängig (Strg+Z)" disabled>↶ Rückgängig</button>
<button id="se-redo" class="btn btn-mini" title="Wiederholen (Strg+Y)" disabled>↷ Wiederholen</button>
</div>
</div>
</aside>
<section class="se-mitte">
<div id="se-buehne" class="se-buehne">
<div id="se-2d" class="se-ansicht se-2d"><canvas id="se-2d-canvas"></canvas></div>
</div>
<div id="se-info" class="se-info hint"></div>
</section>
<aside class="se-seite">
<div id="se-mini-titel" class="se-titel">3D-Vorschau</div>
<div id="se-mini" class="se-mini">
<div id="se-3d" class="se-ansicht se-3d"><canvas id="se-3d-canvas"></canvas><span id="se-3d-leer" class="hint se-3d-leer">wird geladen …</span></div>
</div>
<p class="hint se-tipp">Beide Ansichten sind bemalbar. Die <b>2. Schicht</b> liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.</p>
</aside>
</div>
<div class="modal-foot se-fuss">
<button id="se-neu" class="btn" title="Mit einer leeren Vorlage beginnen">Neu</button>
<button id="se-laden" class="btn" title="Den Skin deines angemeldeten Kontos laden">Aktuellen Skin laden</button>
<button id="se-oeffnen" class="btn">PNG öffnen…</button>
<button id="se-speichern" class="btn">Als PNG speichern…</button>
<span id="se-status" class="hint se-status"></span>
<button id="se-uebernehmen" class="btn btn-primary">Als Skin übernehmen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Logdateien ===================== --> <!-- ===================== Dialog: Logdateien ===================== -->
<div id="modal-logs" class="modal hidden"> <div id="modal-logs" class="modal hidden">
<div class="modal-card modal-lg"> <div class="modal-card modal-lg">
@@ -1364,6 +1484,7 @@
<button id="import-tab-modpack" class="mtab" data-kind="modpack">Modpack</button> <button id="import-tab-modpack" class="mtab" data-kind="modpack">Modpack</button>
<button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button> <button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
<button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button> <button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
<button id="import-tab-mr" class="mtab" data-kind="mr">Modrinth App</button>
<button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button> <button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button> <button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button> <button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
@@ -1460,6 +1581,31 @@
</div> </div>
</div> </div>
<div id="import-pane-mr" class="import-pane hidden">
<label class="field">
<span>Datenordner der Modrinth App</span>
<div class="path-row">
<input id="mr-path" type="text" placeholder="leer lassen = Standardort (%APPDATA%\ModrinthApp)" />
<button id="mr-pick" class="btn">Wählen…</button>
<button id="mr-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="mr-status" class="hint">Leer lassen nutzt automatisch den Standardort der Modrinth App.</small>
</label>
<div id="mr-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="mr-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="mr-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
</label>
</div>
<div id="mr-list" class="mods-list"></div>
<p class="hint" style="margin-top:8px">Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen und die Spielzeit. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC weiter aktualisieren.</p>
</div>
</div>
<div id="import-pane-mmc" class="import-pane hidden"> <div id="import-pane-mmc" class="import-pane hidden">
<label class="field"> <label class="field">
<span>MultiMC- oder Prism-Ordner</span> <span>MultiMC- oder Prism-Ordner</span>
@@ -1540,6 +1686,7 @@
<div id="mc-progress" class="hint hidden"></div> <div id="mc-progress" class="hint hidden"></div>
<div id="mmc-progress" class="hint hidden"></div> <div id="mmc-progress" class="hint hidden"></div>
<div id="cf-progress" class="hint hidden"></div> <div id="cf-progress" class="hint hidden"></div>
<div id="mr-progress" class="hint hidden"></div>
<div id="technic-progress" class="hint hidden"></div> <div id="technic-progress" class="hint hidden"></div>
<div id="code-progress" class="hint hidden"></div> <div id="code-progress" class="hint hidden"></div>
<div class="spacer"></div> <div class="spacer"></div>
@@ -1548,6 +1695,7 @@
<button id="import-modpack-do" class="btn btn-primary hidden">Modpack wählen…</button> <button id="import-modpack-do" class="btn btn-primary hidden">Modpack wählen…</button>
<button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button> <button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button> <button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="mr-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button> <button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="technic-do" class="btn btn-primary hidden" disabled>Übernehmen</button> <button id="technic-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="code-do" class="btn btn-primary hidden" disabled>Instanz anlegen</button> <button id="code-do" class="btn btn-primary hidden" disabled>Instanz anlegen</button>
@@ -1656,5 +1804,6 @@
<canvas id="skin-canvas" width="420" height="700" style="display:none"></canvas> <canvas id="skin-canvas" width="420" height="700" style="display:none"></canvas>
<script src="vendor/skinview3d.js"></script> <script src="vendor/skinview3d.js"></script>
<script src="renderer.js"></script> <script src="renderer.js"></script>
<script src="skineditor.js"></script>
</body> </body>
</html> </html>
+211 -8
View File
@@ -29,6 +29,7 @@ const bedrock = require('./bedrock');
const backup = require('./backup'); const backup = require('./backup');
const mmcimport = require('./mmcimport'); const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport'); const cfimport = require('./cfimport');
const mrimport = require('./mrimport');
const mcimport = require('./mcimport'); const mcimport = require('./mcimport');
const technicimport = require('./technicimport'); const technicimport = require('./technicimport');
const technicdiscover = require('./technicdiscover'); const technicdiscover = require('./technicdiscover');
@@ -112,6 +113,7 @@ const POPOUT_SPEC = {
screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 }, screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 },
servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 }, servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 },
skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 }, skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 },
skineditor: { title: 'AeroMC – Skin-Editor', width: 1180, height: 800, minWidth: 920, minHeight: 600 },
bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 }, bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 },
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 }, login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 }, import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
@@ -171,7 +173,7 @@ function openPopout(modalId, params) {
minWidth: spec.minWidth, minWidth: spec.minWidth,
minHeight: spec.minHeight, minHeight: spec.minHeight,
title: spec.title || POPOUT_DEFAULT.title, title: spec.title || POPOUT_DEFAULT.title,
backgroundColor: '#1b1e24', backgroundColor: fensterFarbe(),
icon: windowIcon(), icon: windowIcon(),
frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste
show: false, show: false,
@@ -211,6 +213,12 @@ ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active;
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg)); ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params)); ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params));
// Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster,
// die Popup-Fenster haben ihren eigenen Kopf)
ipcMain.on('window:titleBar', (e, design) => {
if (!mainWindow || mainWindow.isDestroyed() || e.sender !== mainWindow.webContents) return;
try { mainWindow.setTitleBarOverlay(titelleistenFarben(design)); } catch { /* ältere Windows-Fassung */ }
});
ipcMain.on('popout:hide', (e) => { ipcMain.on('popout:hide', (e) => {
const w = BrowserWindow.fromWebContents(e.sender); const w = BrowserWindow.fromWebContents(e.sender);
if (w && !w.isDestroyed()) w.hide(); if (w && !w.isDestroyed()) w.hide();
@@ -714,6 +722,28 @@ function verknuepfungsIcon(ziel) {
return windowIcon() || ziel; return windowIcon() || ziel;
} }
// Eigene Titelleiste im Hauptfenster: Minimieren/Maximieren/Schließen bleiben
// echte Windows-Knöpfe (Andocken, Fensterlayouts …), Windows zeichnet sie aber
// in den Farben des Designs. Die Farben müssen zu --titel-bg/--titel-fg in
// styles.css bzw. designs.css passen.
const TITELLEISTE = {
dark: { color: '#2d2d30', symbolColor: '#e4e4e6' },
light: { color: '#f0f0f0', symbolColor: '#1c1c1c' },
aero: { color: '#0a0e1a', symbolColor: '#e8ecfb' },
lila: { color: '#0f0a1a', symbolColor: '#f0eafb' },
minecraft: { color: '#1e1e1f', symbolColor: '#ffffff' },
};
function titelleistenFarben(design) {
return Object.assign({ height: 32 }, TITELLEISTE[design] || TITELLEISTE.dark);
}
// Grundfarbe eines neuen Fensters passend zum Design – sonst blitzt beim
// Öffnen kurz die falsche Farbe auf, bis die Oberfläche gezeichnet ist
function fensterFarbe() {
const design = (store.getSettings() || {}).theme;
return { light: '#f0f0f0', aero: '#05070d', lila: '#08050f', minecraft: '#313233' }[design] || '#1b1e24';
}
function windowIcon() { function windowIcon() {
const png = path.join(__dirname, 'assets', 'logo.png'); const png = path.join(__dirname, 'assets', 'logo.png');
const ico = path.join(__dirname, 'assets', 'icon.ico'); const ico = path.join(__dirname, 'assets', 'icon.ico');
@@ -728,10 +758,12 @@ function createWindow(startHidden) {
height: 820, height: 820,
minWidth: 940, minWidth: 940,
minHeight: 640, minHeight: 640,
backgroundColor: '#1b1e24', backgroundColor: fensterFarbe(),
title: 'AeroMC', title: 'AeroMC',
icon: windowIcon(), icon: windowIcon(),
autoHideMenuBar: true, autoHideMenuBar: true,
titleBarStyle: 'hidden',
titleBarOverlay: titelleistenFarben((store.getSettings() || {}).theme),
show: !startHidden, show: !startHidden,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
@@ -800,7 +832,7 @@ function oeffneVorbereitetenImport() {
try { try {
const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, '')); const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, ''));
quellen = (Array.isArray(daten.sources) ? daten.sources : []) quellen = (Array.isArray(daten.sources) ? daten.sources : [])
.filter((q) => q && ['mc', 'cf', 'mmc', 'technic'].includes(q.kind)) .filter((q) => q && ['mc', 'cf', 'mr', 'mmc', 'technic'].includes(q.kind))
.map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' })); .map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' }));
} catch { /* beschädigt -> verwerfen */ } } catch { /* beschädigt -> verwerfen */ }
try { fs.unlinkSync(datei); } catch { /* egal */ } try { fs.unlinkSync(datei); } catch { /* egal */ }
@@ -1028,9 +1060,8 @@ ipcMain.handle('settings:save', (_e, patch) => {
// (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das // (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das
// Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert. // Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert.
const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId'); const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId');
if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) { // an alle Fenster: das Design muss auch in offenen Popup-Fenstern wechseln
mainWindow.webContents.send('settings:changed', s); if (!nurLetzteInstanz) broadcast('settings:changed', s);
}
return s; return s;
}); });
@@ -1733,6 +1764,30 @@ async function aktivesToken() {
return acc && acc.mcAccessToken ? acc.mcAccessToken : null; return acc && acc.mcAccessToken ? acc.mcAccessToken : null;
} }
// Armform (breit/schmal) eines beliebigen Kontos – steht in den Textur-Angaben
// des öffentlichen Profils bei Mojang (SKIN.metadata.model = "slim").
// Ergebnis 10 Minuten merken, nach eigenen Skin-Änderungen sofort verwerfen.
const armformCache = new Map(); // uuid -> { modell, zeit }
ipcMain.handle('skin:modelFor', async (_e, uuid) => {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return 'classic';
const gemerkt = armformCache.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 10 * 60 * 1000) return gemerkt.modell;
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) return gemerkt ? gemerkt.modell : 'classic';
const profil = await res.json();
const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures');
const texturen = eigenschaft ? JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')) : {};
const skin = texturen.textures && texturen.textures.SKIN;
const modell = skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic';
armformCache.set(id, { modell, zeit: Date.now() });
return modell;
} catch {
return gemerkt ? gemerkt.modell : 'classic';
}
});
ipcMain.handle('skin:profile', async () => { ipcMain.handle('skin:profile', async () => {
const token = await aktivesToken(); const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
@@ -1764,7 +1819,7 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const res = await skins.setzeSkin(token, dateiPfad, variante); const res = await skins.setzeSkin(token, dateiPfad, variante);
if (res && res.ok) broadcast('skin:changed'); if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); }
return res; return res;
} }
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; } catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
@@ -1775,11 +1830,78 @@ ipcMain.handle('skin:reset', async () => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const res = await skins.setzeSkinZurueck(token); const res = await skins.setzeSkinZurueck(token);
if (res && res.ok) broadcast('skin:changed'); if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); }
return res; return res;
} }
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
}); });
// ---------- Skin-Editor ----------
// Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net)
// würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt
// der Hauptprozess die Datei und reicht sie als data:-Adresse weiter.
const PNG_KENNUNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function skinPngAusBase64(base64) {
const daten = Buffer.from(String(base64 || ''), 'base64');
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
if (daten.readUInt32BE(16) !== 64 || daten.readUInt32BE(20) !== 64) return null;
return daten;
}
ipcMain.handle('skin:currentData', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
const p = await skins.holeProfil(token);
if (!p.ok) return p;
if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' };
const res = await fetch(p.skinUrl);
if (!res.ok) return { ok: false, message: 'Skin-Datei nicht abrufbar (HTTP ' + res.status + ').' };
const daten = Buffer.from(await res.arrayBuffer());
return { ok: true, name: p.name, variante: p.variante, dataUri: 'data:image/png;base64,' + daten.toString('base64') };
} catch (err) {
return { ok: false, message: 'Skin konnte nicht geladen werden: ' + err.message };
}
});
ipcMain.handle('skin:readData', (_e, dateiPfad) => {
const geprueft = skins.pruefeDatei(dateiPfad);
if (!geprueft.ok) return geprueft;
try { return { ok: true, dataUri: 'data:image/png;base64,' + fs.readFileSync(dateiPfad).toString('base64') }; }
catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
});
ipcMain.handle('skin:saveData', async (e, base64) => {
const daten = skinPngAusBase64(base64);
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Skin speichern',
defaultPath: path.join(app.getPath('pictures'), 'skin.png'),
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
const daten = skinPngAusBase64(base64);
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png');
try {
fs.writeFileSync(tmp, daten);
const res = await skins.setzeSkin(token, tmp, variante);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
return res;
} catch (err) {
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
} finally {
try { fs.unlinkSync(tmp); } catch { /* egal */ }
}
});
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id)); ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; }); ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
@@ -3732,6 +3854,87 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
return { ok: true, imported: done, failed }; return { ok: true, imported: done, failed };
}); });
// ---------- Modrinth App ----------
ipcMain.handle('mr:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Datenordner der Modrinth App wählen (meist %APPDATA%\\ModrinthApp)',
defaultPath: mrimport.standardOrdner(),
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('mr:scan', async (_e, folder) => {
try { return await mrimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
/*
* War die Instanz in der Modrinth App mit einem Modrinth-Modpack verknüpft,
* übernehmen wir die Verknüpfung – samt der Liste der Dateien, die zum Paket
* gehören. Ohne diese Liste würde "Modpack aktualisieren" alte Mods liegen
* lassen; deshalb wird die Paketdatei einmal geladen und nur ihr Inhaltsverzeichnis
* gelesen. Klappt das nicht (offline …), bleibt die Instanz ohne Verknüpfung.
*/
async function verknuepfeModrinthModpack(instId, link) {
const kopf = { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } };
const vres = await fetch(`https://api.modrinth.com/v2/version/${encodeURIComponent(link.versionId)}`, kopf);
if (!vres.ok) return false;
const version = await vres.json();
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
if (!datei || !datei.url) return false;
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!dl.ok) return false;
const buf = Buffer.from(await dl.arrayBuffer());
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) return false;
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^\uFEFF/, ''));
const dateien = (index.files || [])
.filter((f) => f.path && !(f.env && f.env.client === 'unsupported'))
.map((f) => String(f.path).replace(/\\/g, '/'));
for (const prefix of ['overrides/', 'client-overrides/']) {
for (const name of Object.keys(zip)) if (name.startsWith(prefix) && !name.endsWith('/')) dateien.push(name.slice(prefix.length));
}
let titel = index.name || '';
try {
const pres = await fetch(`https://api.modrinth.com/v2/project/${encodeURIComponent(link.projectId)}`, kopf);
if (pres.ok) titel = (await pres.json()).title || titel;
} catch { /* Titel ist nur Zierde */ }
store.updateInstance(instId, {
modpack: {
quelle: 'modrinth',
name: index.name || titel,
title: titel,
projectId: link.projectId,
versionId: link.versionId,
versionNummer: version.version_number || '',
dateien,
},
});
return true;
}
ipcMain.handle('mr:import', async (_e, entries, copyData) => {
const done = [];
const failed = [];
const liste = entries || [];
for (const entry of liste) {
try {
const imported = mrimport.importOne(store, entry, copyData !== false);
if (entry.modpack && entry.modpack.projectId && entry.modpack.versionId) {
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length, detail: 'Modpack-Verknüpfung für ' + entry.name });
try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
catch { imported.modpack = false; }
}
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async (e) => { ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)', title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
+437
View File
@@ -0,0 +1,437 @@
'use strict';
/*
* mrimport.js – Instanzen aus der Modrinth App übernehmen
* --------------------------------------------------------
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
* der App verlegt, steht der neue Ort in settings.custom_dir.
*
* Unterstützte Datenbank-Fassungen:
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
* Gruppen in "instance_group_memberships"/"instance_groups",
* Starteinstellungen als JSONB in "instance_launch_overrides"
* - 2024 bis Mitte 2026: eine Tabelle "profiles"
* - vorher (ohne app.db): je Instanz eine profile.json
*
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
* die Datenbank der Modrinth App wird nie verändert.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
let sqlPromise = null;
function sqlJs() {
if (!sqlPromise) {
const initSqlJs = require('sql.js');
const wasmDir = path.dirname(require.resolve('sql.js'));
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
}
return sqlPromise;
}
function appDatenOrdner() {
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
}
function standardOrdner() {
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
}
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
function ermittleOrte(eingabe) {
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
if (!fs.existsSync(basis)) return null;
// direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
const hoeher = path.dirname(basis);
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
}
const db = path.join(basis, 'app.db');
return { basis, db: fs.existsSync(db) ? db : null };
}
// ---------- Datenbank ----------
/*
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
* Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
* dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
* mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
*/
function spieleWalEin(daten, walPfad) {
let wal;
try { wal = fs.readFileSync(walPfad); } catch { return null; }
if (wal.length < 32) return null;
const magie = wal.readUInt32BE(0);
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
const bigEndian = magie === 0x377f0683;
const seite = wal.readUInt32BE(8);
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
if (!seite || seite !== dbSeite) return null;
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
let s0 = 0, s1 = 0;
const pruefe = (buf, start, ende) => {
for (let i = start; i < ende; i += 8) {
s0 = (s0 + wort(buf, i) + s1) >>> 0;
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
}
};
pruefe(wal, 0, 24);
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
let offen = new Map();
let seiten = Math.floor(daten.length / seite);
let rahmen = 0;
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
pruefe(wal, pos, pos + 8);
pruefe(wal, pos + 24, pos + 24 + seite);
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
const nachCommit = wal.readUInt32BE(pos + 4);
if (nachCommit) { // Transaktion abgeschlossen
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
offen = new Map();
seiten = nachCommit;
rahmen++;
}
}
if (!rahmen) return null;
const neu = Buffer.alloc(seiten * seite);
daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
return { daten: neu, rahmen };
}
async function oeffneDb(dbPfad) {
const SQL = await sqlJs();
let daten = fs.readFileSync(dbPfad);
let walEingespielt = false;
if (daten.length > 100 && daten[18] === 2) {
const mitWal = spieleWalEin(daten, dbPfad + '-wal');
if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
daten[18] = 1; daten[19] = 1;
}
const db = new SQL.Database(daten);
db.walEingespielt = walEingespielt;
return db;
}
function zeilen(db, sql, params) {
const st = db.prepare(sql);
try {
if (params) st.bind(params);
const out = [];
while (st.step()) out.push(st.getAsObject());
return out;
} finally {
st.free();
}
}
function tabellen(db) {
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
}
function spalten(db, tabelle) {
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
}
function jsonFeld(wert) {
if (wert === null || wert === undefined || wert === '') return null;
try { return JSON.parse(wert); } catch { return null; }
}
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
}
function eigenerOrdner(db, t) {
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
return r && r.custom_dir ? String(r.custom_dir) : null;
}
function leseNeu(db, t) {
const si = spalten(db, 'instances');
const liste = zeilen(db, `
SELECT i.id AS id, i.path AS path, i.name AS name,
${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
${sp(si, 'i.last_played', 'last_played')},
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
FROM instances i
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
const overrides = new Map();
if (t.has('instance_launch_overrides')) {
for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
overrides.set(r.instance_id, jsonFeld(r.o) || {});
}
}
const gruppen = new Map();
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
if (t.has('instance_group_memberships') && t.has('instance_groups')) {
for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
} else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
}
const links = new Map();
if (t.has('instance_links')) {
for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
}
}
return liste.map((r) => {
const o = overrides.get(r.id) || {};
const hooks = o.hooks || {};
return {
path: r.path,
name: r.name,
iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version,
loader: r.loader,
loaderVersion: r.loader_version,
gruppe: gruppen.get(r.id) || '',
javaPath: o.java_path || '',
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
vorStart: hooks.pre_launch || '',
nachEnde: hooks.post_exit || '',
modpack: links.get(r.id) || null,
};
});
}
function leseProfile(db) {
const s = spalten(db, 'profiles');
const liste = zeilen(db, `SELECT path, name,
${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
${sp(s, 'last_played', 'last_played')},
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
${sp(s, 'recent_time_played', 'recent_time_played', '0')},
${sp(s, 'override_java_path', 'override_java_path')},
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
FROM profiles`);
return liste.map((r) => {
const gruppen = jsonFeld(r.groups);
const args = jsonFeld(r.override_extra_launch_args);
return {
path: r.path,
name: r.name,
iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version,
loader: r.mod_loader,
loaderVersion: r.mod_loader_version,
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
javaPath: r.override_java_path || '',
maxMemMb: Number(r.override_mc_memory_max) || null,
extraArgs: Array.isArray(args) ? args.join(' ') : '',
vorStart: r.override_hook_pre_launch || '',
nachEnde: r.override_hook_post_exit || '',
modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
};
});
}
// sehr alte Fassungen: profiles\<Ordner>\profile.json
function leseProfileJson(profilesDir) {
const liste = [];
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
if (!p) continue;
const m = p.metadata || p;
const lv = m.loader_version;
liste.push({
path: e.name,
name: m.name || e.name,
iconPath: m.icon || null,
installiert: !p.install_stage || p.install_stage === 'installed',
lastPlayed: m.last_played || null,
sekunden: 0,
gameVersion: m.game_version,
loader: m.loader,
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
vorStart: (p.hooks && p.hooks.pre_launch) || '',
nachEnde: (p.hooks && p.hooks.post_exit) || '',
modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
});
}
return liste;
}
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
function loaderVersion(loader, wert, mc) {
let v = String(wert || '').trim();
if (!v || v === 'stable' || v === 'latest') return '';
if (loader === 'forge') {
v = v.replace(/^forge-/i, '');
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
} else if (loader === 'neoforge') {
v = v.replace(/^neoforge-/i, '');
} else if (loader === 'fabric' || loader === 'quilt') {
v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
}
return v;
}
function zeitstempel(wert) {
if (!wert) return null;
const n = Number(wert);
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
const d = new Date(wert);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
/*
* Durchsucht die Modrinth App. Leere Eingabe = Standardort.
* -> { ok, basis, count, instances, hinweis }
*/
async function scan(eingabe) {
const orte = ermittleOrte(eingabe);
if (!orte) return { ok: false, reason: 'not-found' };
let roh = [];
let profilesDir = path.join(orte.basis, 'profiles');
let hinweis = '';
if (orte.db) {
const db = await oeffneDb(orte.db);
try {
const t = tabellen(db);
const eigener = eigenerOrdner(db, t);
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
if (t.has('instances')) roh = leseNeu(db, t);
else if (t.has('profiles')) roh = leseProfile(db);
} finally {
db.close();
}
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
// ist evtl. nicht ganz aktuell
try {
const wal = orte.db + '-wal';
if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
}
} catch { /* egal */ }
} else if (fs.existsSync(profilesDir)) {
roh = leseProfileJson(profilesDir);
} else {
return { ok: false, reason: 'not-found' };
}
const instances = [];
for (const r of roh) {
const dir = path.join(profilesDir, String(r.path || ''));
if (!r.path || !fs.existsSync(dir)) continue;
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
const version = String(r.gameVersion || '').trim();
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
instances.push({
folder: r.path,
dir,
gameDir: dir,
name: String(r.name || r.path),
group: r.gruppe || '',
version,
loader,
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
installiert: r.installiert,
iconPath: icon,
lastPlayed: zeitstempel(r.lastPlayed),
playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
javaPath: String(r.javaPath || '').trim(),
maxMemMb: r.maxMemMb || null,
extraArgs: String(r.extraArgs || '').trim(),
preLaunchCommand: String(r.vorStart || '').trim(),
postExitCommand: String(r.nachEnde || '').trim(),
modpack: r.modpack,
});
}
instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
}
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
function copyInto(quelle, ziel) {
fs.mkdirSync(ziel, { recursive: true });
for (const name of fs.readdirSync(quelle)) {
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
}
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
});
const patch = {};
if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
}
if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
const vorher = store.getInstance(created.id) || {};
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
if (entry.iconPath) {
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, standardOrdner };
+15 -1
View File
@@ -154,6 +154,11 @@ contextBridge.exposeInMainWorld('api', {
skinChoose: () => ipcRenderer.invoke('skin:choose'), skinChoose: () => ipcRenderer.invoke('skin:choose'),
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante), skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
skinReset: () => ipcRenderer.invoke('skin:reset'), skinReset: () => ipcRenderer.invoke('skin:reset'),
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
authRemove: (id) => ipcRenderer.invoke('auth:remove', id), authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
authLogin: () => ipcRenderer.invoke('auth:login'), authLogin: () => ipcRenderer.invoke('auth:login'),
authLogout: () => ipcRenderer.invoke('auth:logout'), authLogout: () => ipcRenderer.invoke('auth:logout'),
@@ -192,6 +197,14 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('mmc:progress', l); return () => ipcRenderer.removeListener('mmc:progress', l);
}, },
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'), cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
onMrProgress: (cb) => {
const l = (_e, p) => cb(p);
ipcRenderer.on('mr:progress', l);
return () => ipcRenderer.removeListener('mr:progress', l);
},
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder), cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData), cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
onCfProgress: (cb) => { onCfProgress: (cb) => {
@@ -269,6 +282,7 @@ contextBridge.exposeInMainWorld('api', {
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params), popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
popoutHide: () => ipcRenderer.send('popout:hide'), popoutHide: () => ipcRenderer.send('popout:hide'),
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
onPopoutReopen: (cb) => { onPopoutReopen: (cb) => {
const l = (_e, params) => cb(params); const l = (_e, params) => cb(params);
ipcRenderer.on('popout:reopen', l); ipcRenderer.on('popout:reopen', l);
@@ -285,7 +299,7 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('instances:changed', l); return () => ipcRenderer.removeListener('instances:changed', l);
}, },
onSkinChanged: (cb) => { onSkinChanged: (cb) => {
const l = () => cb(); const l = (_e, info) => cb(info || {});
ipcRenderer.on('skin:changed', l); ipcRenderer.on('skin:changed', l);
return () => ipcRenderer.removeListener('skin:changed', l); return () => ipcRenderer.removeListener('skin:changed', l);
}, },
+146 -8
View File
@@ -537,13 +537,23 @@ function escapeHtml(s) {
} }
function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; } function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }
// Designs: 'dark'/'light' = Klassisch (MultiMC-Look), 'aero' = wie die Webseite,
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
function applyTheme(theme) { function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark'); const t = DESIGNS.includes(theme) ? theme : 'dark';
const root = document.documentElement;
root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
root.setAttribute('data-design', ['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch');
if (!POPOUT && window.api.setTitleBarDesign) window.api.setTitleBarDesign(t);
document.querySelectorAll('.design-wahl').forEach((b) => b.classList.toggle('on', b.dataset.design === t));
} }
// ---------- Instanzen aus MultiMC übernehmen ---------- // ---------- Instanzen aus MultiMC übernehmen ----------
let mmcFound = []; let mmcFound = [];
let cfFound = []; let cfFound = [];
let mrFound = [];
let mcFound = []; let mcFound = [];
let technicFound = []; let technicFound = [];
let importKind = 'zip'; let importKind = 'zip';
@@ -568,8 +578,8 @@ async function ensureDefaultMcScan() {
} }
function setImportKind(kind) { function setImportKind(kind) {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip'; importKind = ['zip', 'modpack', 'mc', 'cf', 'mr', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].forEach((entryKind) => { ['zip', 'modpack', 'mc', 'cf', 'mr', 'mmc', 'technic', 'code'].forEach((entryKind) => {
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind); el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind); el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
}); });
@@ -577,12 +587,14 @@ function setImportKind(kind) {
el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack'); el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack');
el('mc-do').classList.toggle('hidden', importKind !== 'mc'); el('mc-do').classList.toggle('hidden', importKind !== 'mc');
el('cf-do').classList.toggle('hidden', importKind !== 'cf'); el('cf-do').classList.toggle('hidden', importKind !== 'cf');
el('mr-do').classList.toggle('hidden', importKind !== 'mr');
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc'); el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
el('technic-do').classList.toggle('hidden', importKind !== 'technic'); el('technic-do').classList.toggle('hidden', importKind !== 'technic');
el('code-do').classList.toggle('hidden', importKind !== 'code'); el('code-do').classList.toggle('hidden', importKind !== 'code');
el('code-progress').classList.toggle('hidden', importKind !== 'code' || !el('code-progress').textContent); el('code-progress').classList.toggle('hidden', importKind !== 'code' || !el('code-progress').textContent);
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent); el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent); el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
el('mr-progress').classList.toggle('hidden', importKind !== 'mr' || !el('mr-progress').textContent);
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent); el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent); el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
} }
@@ -601,6 +613,12 @@ function openImportDialog(kind = 'zip', vorauswahl = null) {
el('cf-progress').textContent = ''; el('cf-progress').textContent = '';
el('cf-progress').classList.add('hidden'); el('cf-progress').classList.add('hidden');
el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances'; el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances';
mrFound = [];
el('mr-result').classList.add('hidden');
el('mr-do').disabled = true;
el('mr-progress').textContent = '';
el('mr-progress').classList.add('hidden');
el('mr-status').textContent = 'Leer lassen nutzt automatisch den Standardort der Modrinth App.';
el('mmc-result').classList.add('hidden'); el('mmc-result').classList.add('hidden');
el('mmc-do').disabled = true; el('mmc-do').disabled = true;
el('mmc-progress').textContent = ''; el('mmc-progress').textContent = '';
@@ -613,6 +631,8 @@ function openImportDialog(kind = 'zip', vorauswahl = null) {
ensureDefaultMcScan(); ensureDefaultMcScan();
} }
if (importKind === 'cf') el('cf-path').focus(); if (importKind === 'cf') el('cf-path').focus();
// Modrinth App: Standardort gleich durchsuchen, wie beim Minecraft Launcher
if (importKind === 'mr' && !(Array.isArray(vorauswahl) && vorauswahl.length)) scanMr();
if (importKind === 'mmc') el('mmc-path').focus(); if (importKind === 'mmc') el('mmc-path').focus();
if (Array.isArray(vorauswahl) && vorauswahl.length) uebernehmeSetupVorauswahl(vorauswahl); if (Array.isArray(vorauswahl) && vorauswahl.length) uebernehmeSetupVorauswahl(vorauswahl);
} }
@@ -622,7 +642,7 @@ function openImportDialog(kind = 'zip', vorauswahl = null) {
* eintragen und durchsuchen, damit man beim ersten Start nur noch auswählt. * eintragen und durchsuchen, damit man beim ersten Start nur noch auswählt.
*/ */
function uebernehmeSetupVorauswahl(quellen) { function uebernehmeSetupVorauswahl(quellen) {
const scanner = { mc: scanMc, cf: scanCf, mmc: scanMmc, technic: scanTechnic }; const scanner = { mc: scanMc, cf: scanCf, mr: scanMr, mmc: scanMmc, technic: scanTechnic };
importWarteschlange = quellen.map((q) => q && q.kind).filter((k) => scanner[k]); importWarteschlange = quellen.map((q) => q && q.kind).filter((k) => scanner[k]);
for (const q of quellen) { for (const q of quellen) {
if (!q || !scanner[q.kind]) continue; if (!q || !scanner[q.kind]) continue;
@@ -923,6 +943,82 @@ async function runCfImport() {
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
async function scanMr() {
const folder = el('mr-path').value.trim();
el('mr-status').textContent = 'Suche Instanzen der Modrinth App …';
const res = await window.api.mrScan(folder);
if (!res.ok) {
el('mr-status').textContent = folder
? '✖ Dort wurden keine Daten der Modrinth App gefunden. Gemeint ist der Ordner mit „app.db“ und „profiles“.'
: '✖ Die Modrinth App wurde am Standardort nicht gefunden. Ist sie installiert? Sonst den Datenordner wählen.';
el('mr-result').classList.add('hidden');
el('mr-do').disabled = true;
return;
}
mrFound = res.instances;
el('mr-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden (${res.basis}).`
+ (res.hinweis ? ' ⚠ ' + res.hinweis : '');
el('mr-result').classList.remove('hidden');
el('mr-all').checked = true;
renderMrList();
}
function renderMrList() {
const box = el('mr-list');
box.innerHTML = '';
if (!mrFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; return; }
mrFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt, inst.group ? 'Gruppe: ' + inst.group : '',
inst.modpack ? 'Modrinth-Modpack' : '', inst.playSeconds ? fmtDuration(inst.playSeconds) + ' gespielt' : '',
inst.installiert === false ? 'nicht fertig installiert' : '']
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateMrButton();
}
function selectedMr() {
return [...document.querySelectorAll('#mr-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => mrFound[Number(c.dataset.idx)]);
}
function updateMrButton() {
const n = selectedMr().length;
el('mr-do').disabled = n === 0;
el('mr-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runMrImport() {
const entries = selectedMr();
if (!entries.length) return;
const copy = el('mr-copy').checked;
el('mr-do').disabled = true;
el('mr-progress').textContent = 'Übernehme …';
el('mr-progress').classList.remove('hidden');
const res = await window.api.mrImport(entries, copy);
await reload();
importAbgeschlossen('mr');
const n = res.imported.length;
const f = res.failed.length;
const ohneLink = res.imported.filter((i) => i.modpack === false).length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}`
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
async function scanMmc() { async function scanMmc() {
const folder = el('mmc-path').value.trim(); const folder = el('mmc-path').value.trim();
if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; } if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
@@ -1464,7 +1560,11 @@ async function applySkinBackground(uuidOverride) {
// Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht), // Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht),
// damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist. // damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist.
// 1) Skin lokal in 3D rendern – unabhängig von Render-Diensten // 1) Skin lokal in 3D rendern – unabhängig von Render-Diensten
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking'); // Armform: fest gewählt oder (Standard) die des Kontos – sonst sieht ein
// schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm)
let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : '';
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell });
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; } if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
// 2) Rückfall: externe Render-Dienste // 2) Rückfall: externe Render-Dienste
@@ -1630,7 +1730,7 @@ const ICONS_SVG = {
terminal: '<path d="M4 5h16v14H4zM7 9l3 3-3 3M13 16h4"/>', terminal: '<path d="M4 5h16v14H4zM7 9l3 3-3 3M13 16h4"/>',
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z"/>', user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z"/>',
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>', users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>', settings: '<path d="M19.1 13.1 21.5 14.5 20.5 16.9 17.8 16.3 16.3 17.8 16.9 20.5 14.5 21.5 13.1 19.1 10.9 19.1 9.5 21.5 7.1 20.5 7.7 17.8 6.2 16.3 3.5 16.9 2.5 14.5 4.9 13.1 4.9 10.9 2.5 9.5 3.5 7.1 6.2 7.7 7.7 6.2 7.1 3.5 9.5 2.5 10.9 4.9 13.1 4.9 14.5 2.5 16.9 3.5 16.3 6.2 17.8 7.7 20.5 7.1 21.5 9.5 19.1 10.9Z"/><circle cx="12" cy="12" r="3"/>', // Zahnrad (8 Zähne)
play: '<path d="M7 5l12 7-12 7z"/>', play: '<path d="M7 5l12 7-12 7z"/>',
blocks: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>', blocks: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>', globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>',
@@ -4790,7 +4890,8 @@ async function openSettings(pane) {
window.api.updateLastCheck().then(renderUpdateStatus).catch(() => {}); window.api.updateLastCheck().then(renderUpdateStatus).catch(() => {});
const s = await window.api.getSettings(); const s = await window.api.getSettings();
el('s-theme').value = s.theme || 'dark'; el('s-theme').value = DESIGNS.includes(s.theme) ? s.theme : 'dark';
applyTheme(el('s-theme').value);
el('s-global-pre').value = s.globalPreLaunch || ''; el('s-global-pre').value = s.globalPreLaunch || '';
el('s-global-post').value = s.globalPostExit || ''; el('s-global-post').value = s.globalPostExit || '';
el('s-wrapper').value = s.wrapperCommand || ''; el('s-wrapper').value = s.wrapperCommand || '';
@@ -4806,6 +4907,7 @@ async function openSettings(pane) {
} }
sel.value = s.bgSkin || ''; sel.value = s.bgSkin || '';
el('s-bgskin-pose').value = s.bgSkinPose || 'walking'; el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
el('s-bgskin-model').value = ['classic', 'slim'].includes(s.bgSkinModel) ? s.bgSkinModel : 'auto';
el('s-bgskin-cycle').checked = !!s.bgSkinCycle; el('s-bgskin-cycle').checked = !!s.bgSkinCycle;
el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30; el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30;
sel.disabled = !!s.bgSkinCycle; sel.disabled = !!s.bgSkinCycle;
@@ -4939,6 +5041,7 @@ async function saveSettings() {
globalPostExit: el('s-global-post').value.trim(), globalPostExit: el('s-global-post').value.trim(),
bgSkin: el('s-bgskin').value, bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value, bgSkinPose: el('s-bgskin-pose').value,
bgSkinModel: el('s-bgskin-model').value,
bgSkinCycle: el('s-bgskin-cycle').checked, bgSkinCycle: el('s-bgskin-cycle').checked,
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30), bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value, sortBy: el('s-sortby').value,
@@ -5471,6 +5574,9 @@ function wire() {
}); });
document.addEventListener('scroll', closeContextMenu, true); document.addEventListener('scroll', closeContextMenu, true);
window.addEventListener('blur', closeContextMenu); window.addEventListener('blur', closeContextMenu);
// Titelleiste wie bei Windows abblenden, solange das Fenster nicht aktiv ist
window.addEventListener('blur', () => document.body.classList.add('fenster-inaktiv'));
window.addEventListener('focus', () => document.body.classList.remove('fenster-inaktiv'));
// Warnung anklicken -> sofort erneut prüfen // Warnung anklicken -> sofort erneut prüfen
el('status-auth').addEventListener('click', async () => { el('status-auth').addEventListener('click', async () => {
@@ -5530,6 +5636,18 @@ function wire() {
}); });
el('cf-list').addEventListener('change', updateCfButton); el('cf-list').addEventListener('change', updateCfButton);
el('cf-do').addEventListener('click', runCfImport); el('cf-do').addEventListener('click', runCfImport);
el('mr-pick').addEventListener('click', async () => {
const p = await window.api.mrPickFolder();
if (p) { el('mr-path').value = p; await scanMr(); }
});
el('mr-scan').addEventListener('click', scanMr);
el('mr-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMr(); });
el('mr-all').addEventListener('change', (e) => {
document.querySelectorAll('#mr-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateMrButton();
});
el('mr-list').addEventListener('change', updateMrButton);
el('mr-do').addEventListener('click', runMrImport);
el('mmc-pick').addEventListener('click', async () => { el('mmc-pick').addEventListener('click', async () => {
const p = await window.api.mmcPickFolder(); const p = await window.api.mmcPickFolder();
if (p) { el('mmc-path').value = p; await scanMmc(); } if (p) { el('mmc-path').value = p; await scanMmc(); }
@@ -5567,6 +5685,10 @@ function wire() {
el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('cf-progress').classList.remove('hidden'); el('cf-progress').classList.remove('hidden');
}); });
window.api.onMrProgress((p) => {
el('mr-progress').textContent = p.detail ? `${p.detail} …` : `Übernehme … ${p.current}/${p.total}`;
el('mr-progress').classList.remove('hidden');
});
window.api.onMmcProgress((p) => { window.api.onMmcProgress((p) => {
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mmc-progress').classList.remove('hidden'); el('mmc-progress').classList.remove('hidden');
@@ -5804,6 +5926,10 @@ function wire() {
el('skin-pick').addEventListener('click', waehleSkinDatei); el('skin-pick').addEventListener('click', waehleSkinDatei);
el('skin-variante').addEventListener('change', skinVarianteGewechselt); el('skin-variante').addEventListener('change', skinVarianteGewechselt);
el('skin-apply').addEventListener('click', setzeSkin); el('skin-apply').addEventListener('click', setzeSkin);
// gewählte Datei (sonst der aktuelle Skin des Kontos) im Editor öffnen
el('skin-editor').addEventListener('click', () => window.openSkinEditor(gewaehlterSkin
? { pfad: gewaehlterSkin, variante: el('skin-variante').value }
: { quelle: 'aktuell' }));
el('skin-reset').addEventListener('click', setzeSkinZurueck); el('skin-reset').addEventListener('click', setzeSkinZurueck);
el('logs-select').addEventListener('change', (e) => ladeLog(e.target.value)); el('logs-select').addEventListener('change', (e) => ladeLog(e.target.value));
el('logs-folder').addEventListener('click', async () => { el('logs-folder').addEventListener('click', async () => {
@@ -5986,6 +6112,11 @@ function wire() {
if (p) { el('s-java').value = p; await refreshJavaStatus(p); } if (p) { el('s-java').value = p; await refreshJavaStatus(p); }
}); });
el('s-theme').addEventListener('change', (e) => applyTheme(e.target.value)); el('s-theme').addEventListener('change', (e) => applyTheme(e.target.value));
// Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an
document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => {
el('s-theme').value = b.dataset.design;
applyTheme(b.dataset.design);
}));
el('s-bgskin-opacity').addEventListener('input', (e) => { el('s-bgskin-opacity').addEventListener('input', (e) => {
el('s-bgskin-val').textContent = e.target.value; el('s-bgskin-val').textContent = e.target.value;
document.documentElement.style.setProperty('--bg-skin-opacity', String(e.target.value / 100)); document.documentElement.style.setProperty('--bg-skin-opacity', String(e.target.value / 100));
@@ -5995,11 +6126,13 @@ function wire() {
await window.api.saveSettings({ await window.api.saveSettings({
bgSkin: el('s-bgskin').value, bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value, bgSkinPose: el('s-bgskin-pose').value,
bgSkinModel: el('s-bgskin-model').value,
}); });
await refreshSkinBackground(); await refreshSkinBackground();
}; };
el('s-bgskin').addEventListener('change', previewSkin); el('s-bgskin').addEventListener('change', previewSkin);
el('s-bgskin-pose').addEventListener('change', previewSkin); el('s-bgskin-pose').addEventListener('change', previewSkin);
el('s-bgskin-model').addEventListener('change', previewSkin);
const previewSkinCycle = async () => { const previewSkinCycle = async () => {
el('s-bgskin').disabled = el('s-bgskin-cycle').checked; el('s-bgskin').disabled = el('s-bgskin-cycle').checked;
await window.api.saveSettings({ await window.api.saveSettings({
@@ -6240,6 +6373,7 @@ const POPOUT_OPENERS = {
screens: () => openScreens(), screens: () => openScreens(),
servers: () => openServers(), servers: () => openServers(),
skin: () => openSkin(), skin: () => openSkin(),
skineditor: (p) => window.openSkinEditor(p),
bedrock: () => openBedrock(), bedrock: () => openBedrock(),
login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }), login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }),
import: (p) => { import: (p) => {
@@ -6398,7 +6532,11 @@ window.addEventListener('DOMContentLoaded', async () => {
// Ein Skin-Wechsel oder eine neue/aktualisierte Instanz aus einem anderen // Ein Skin-Wechsel oder eine neue/aktualisierte Instanz aus einem anderen
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im // Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an // Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
window.api.onSkinChanged(() => { if (!POPOUT) refreshSkinBackground(); }); window.api.onSkinChanged((info) => {
if (!POPOUT) refreshSkinBackground();
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
if (POPOUT === 'skin' && info.quelle === 'editor' && !el('modal-skin').classList.contains('hidden')) openSkin();
});
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); }); window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ } try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
await loadMcIcons(); // echte Minecraft-Icons bereitstellen await loadMcIcons(); // echte Minecraft-Icons bereitstellen
+983
View File
@@ -0,0 +1,983 @@
'use strict';
/*
* skineditor.js – Pixel-Editor für Minecraft-Skins
* -------------------------------------------------
* Ein Skin ist eine einzige 64×64-Textur. Jedes Körperteil hat darin zwei
* Bereiche: die Grundschicht (liegt direkt auf der Figur) und die 2. Schicht
* (eine etwas größere Hülle darüber – Haare, Jacke, Ärmel …). Die 2. Schicht
* darf durchsichtig sein und lässt die Figur dadurch plastischer wirken.
*
* Gemalt wird wahlweise auf der aufgeklappten Vorlage (2D) oder direkt auf der
* drehbaren Figur (3D). Beide Ansichten sind immer da – die gerade nicht
* gewählte steht klein rechts und ist ebenfalls bemalbar.
*
* Läuft im Popup-Fenster "skineditor" und nutzt die Helfer aus renderer.js
* (el, show, toast, askConfirm, POPOUT, SKIN_POSES).
*/
(() => {
const N = 64;
// Lage der Körperteile in der Textur. w/h/d = Breite/Höhe/Tiefe in Pixeln,
// base/over = linke obere Ecke des Bereichs der Grund- bzw. 2. Schicht.
const TEILE = [
{ key: 'head', name: 'Kopf', w: 8, h: 8, d: 8, base: [0, 0], over: [32, 0], spiegel: 'head' },
{ key: 'body', name: 'Körper', w: 8, h: 12, d: 4, base: [16, 16], over: [16, 32], spiegel: 'body' },
{ key: 'rightArm', name: 'Rechter Arm', w: 4, h: 12, d: 4, base: [40, 16], over: [40, 32], spiegel: 'leftArm', arm: true },
{ key: 'leftArm', name: 'Linker Arm', w: 4, h: 12, d: 4, base: [32, 48], over: [48, 48], spiegel: 'rightArm', arm: true },
{ key: 'rightLeg', name: 'Rechtes Bein', w: 4, h: 12, d: 4, base: [0, 16], over: [0, 32], spiegel: 'leftLeg' },
{ key: 'leftLeg', name: 'Linkes Bein', w: 4, h: 12, d: 4, base: [16, 48], over: [0, 48], spiegel: 'rightLeg' },
];
const TEIL = Object.fromEntries(TEILE.map((t) => [t.key, t]));
const SEITE_NAME = { oben: 'oben', unten: 'unten', rechts: 'rechte Seite', vorne: 'vorne', links: 'linke Seite', hinten: 'hinten' };
const SEITE_GEGENSTUECK = { rechts: 'links', links: 'rechts' };
const SCHICHT_NAME = ['Grundschicht', '2. Schicht'];
// Rechtecke [x, y, breite, höhe] der sechs Seiten eines Teils (Minecraft-Aufteilung:
// oben/unten in der ersten Reihe, darunter rechts | vorne | links | hinten)
function seiten(teil, schicht, slim) {
const w = teil.arm && slim ? 3 : teil.w;
const { h, d } = teil;
const [u, v] = schicht ? teil.over : teil.base;
return {
oben: [u + d, v, w, d],
unten: [u + d + w, v, w, d],
rechts: [u, v + d, d, h],
vorne: [u + d, v + d, w, h],
links: [u + d + w, v + d, d, h],
hinten: [u + 2 * d + w, v + d, w, h],
};
}
// Für jede Armform: Liste aller Flächen und eine Pixel→Fläche-Karte
const AUFTEILUNG = [false, true].map((slim) => {
const flaechen = [];
const karte = new Array(N * N).fill(null);
for (const teil of TEILE) {
for (const schicht of [0, 1]) {
for (const [seite, r] of Object.entries(seiten(teil, schicht, slim))) {
const f = { teil, schicht, seite, x: r[0], y: r[1], w: r[2], h: r[3] };
flaechen.push(f);
for (let yy = 0; yy < f.h; yy++) for (let xx = 0; xx < f.w; xx++) karte[(f.y + yy) * N + f.x + xx] = f;
}
}
}
return { flaechen, karte };
});
// ---------- Zustand ----------
const ed = {
daten: new Uint8ClampedArray(N * N * 4),
slim: false,
werkzeug: 'stift',
vorPipette: 'stift',
farbe: '#3a6ea5',
schicht: 0,
zeigeAussen: true,
durchscheinen: true,
spiegeln: false,
modus: '2d',
undo: [],
redo: [],
geaendert: false,
hover: null,
strich: null, // { snap, letzter: [x,y] | null, letzterBild: [cx,cy] | null, geaendert }
viewer: null,
bereit: false,
zeichnenGeplant: false,
};
const textur = document.createElement('canvas');
textur.width = textur.height = N;
const texturCtx = textur.getContext('2d', { willReadFrequently: true });
// Grundschicht als Schatten unter die 2. Schicht legen (Option "durchscheinen")
const schatten = document.createElement('canvas');
schatten.width = schatten.height = N;
const schattenCtx = schatten.getContext('2d');
// Hintergrund (Karomuster in den Flächen) je Armform einmal vorberechnet
const hintergrund = [null, null];
const aufteilung = () => AUFTEILUNG[ed.slim ? 1 : 0];
const flaecheBei = (x, y) => (x < 0 || y < 0 || x >= N || y >= N ? null : aufteilung().karte[y * N + x]);
const dunkel = () => document.documentElement.getAttribute('data-theme') === 'dark';
function hexZuRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return [0, 0, 0];
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgbZuHex = (r, g, b) => '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
// Pixel auf der gespiegelten Körperseite (rechter ↔ linker Arm/Bein,
// rechte ↔ linke Seite, waagerecht umgedreht)
function spiegelPixel(x, y) {
const f = flaecheBei(x, y);
if (!f) return null;
const partner = TEIL[f.teil.spiegel];
const seite = SEITE_GEGENSTUECK[f.seite] || f.seite;
const r = seiten(partner, f.schicht, ed.slim)[seite];
const fx = x - f.x;
const fy = y - f.y;
return [r[0] + (r[2] - 1 - fx), r[1] + fy];
}
// ---------- Daten ändern ----------
function setzePixel(x, y, rgba) {
const i = (y * N + x) * 4;
const d = ed.daten;
if (d[i] === rgba[0] && d[i + 1] === rgba[1] && d[i + 2] === rgba[2] && d[i + 3] === rgba[3]) return false;
d[i] = rgba[0]; d[i + 1] = rgba[1]; d[i + 2] = rgba[2]; d[i + 3] = rgba[3];
return true;
}
function zielPixel(x, y) {
const liste = [[x, y]];
if (ed.spiegeln) {
const s = spiegelPixel(x, y);
if (s && (s[0] !== x || s[1] !== y)) liste.push(s);
}
return liste;
}
function malFarbe() {
if (ed.werkzeug === 'radierer') return [0, 0, 0, 0];
return [...hexZuRgb(ed.farbe), 255];
}
// Einen Pixel mit dem aktuellen Werkzeug bearbeiten (Stift/Radierer)
function malePixel(x, y) {
const f = flaecheBei(x, y);
if (!f || f.schicht !== ed.schicht) return false;
let geaendert = false;
const rgba = malFarbe();
for (const [px, py] of zielPixel(x, y)) if (setzePixel(px, py, rgba)) geaendert = true;
return geaendert;
}
// Füllen: zusammenhängende gleichfarbige Pixel innerhalb derselben Fläche
function fuelle(x, y) {
const f = flaecheBei(x, y);
if (!f || f.schicht !== ed.schicht) return false;
const d = ed.daten;
const i0 = (y * N + x) * 4;
const alt = [d[i0], d[i0 + 1], d[i0 + 2], d[i0 + 3]];
const neu = malFarbe();
if (alt.every((v, k) => v === neu[k])) return false;
const gleich = (px, py) => {
const i = (py * N + px) * 4;
return d[i] === alt[0] && d[i + 1] === alt[1] && d[i + 2] === alt[2] && d[i + 3] === alt[3];
};
const gesehen = new Set();
const stapel = [[x, y]];
const treffer = [];
while (stapel.length) {
const [px, py] = stapel.pop();
const k = py * N + px;
if (gesehen.has(k)) continue;
if (px < f.x || py < f.y || px >= f.x + f.w || py >= f.y + f.h) continue;
gesehen.add(k);
if (!gleich(px, py)) continue;
treffer.push([px, py]);
stapel.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]);
}
let geaendert = false;
for (const [px, py] of treffer) for (const [zx, zy] of zielPixel(px, py)) if (setzePixel(zx, zy, neu)) geaendert = true;
return geaendert;
}
function nimmFarbe(x, y) {
const i = (y * N + x) * 4;
const d = ed.daten;
if (d[i + 3] === 0) return false;
setzeFarbe(rgbZuHex(d[i], d[i + 1], d[i + 2]));
return true;
}
// ---------- Striche, Rückgängig ----------
function beginneStrich() {
ed.strich = { snap: ed.daten.slice(), letzter: null, letzterBild: null, geaendert: false };
}
function beendeStrich() {
const s = ed.strich;
ed.strich = null;
if (!s || !s.geaendert) return;
ed.undo.push(s.snap);
if (ed.undo.length > 100) ed.undo.shift();
ed.redo = [];
ed.geaendert = true;
if (ed.werkzeug !== 'radierer') merkeFarbe(ed.farbe);
aktualisiereKnoepfe();
}
// Werkzeug auf einen Texturpixel anwenden (während eines Strichs)
function wendeAn(x, y) {
const s = ed.strich;
if (!s) return;
if (ed.werkzeug === 'pipette') {
nimmFarbe(x, y);
return;
}
if (ed.werkzeug === 'fuellen') {
if (s.letzter) return; // ein Klick = eine Füllung
s.letzter = [x, y];
if (fuelle(x, y)) { s.geaendert = true; texturGeaendert(); }
return;
}
let geaendert = false;
// Lücken zwischen zwei Mausereignissen schließen (nur innerhalb der Vorlage
// sinnvoll, auf der Figur liegen benachbarte Pixel nicht nebeneinander)
const punkte = s.letzter && s.zweiD ? linie(s.letzter[0], s.letzter[1], x, y) : [[x, y]];
for (const [px, py] of punkte) if (malePixel(px, py)) geaendert = true;
s.letzter = [x, y];
if (geaendert) { s.geaendert = true; texturGeaendert(); }
}
function linie(x0, y0, x1, y1) {
const punkte = [];
const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
for (;;) {
punkte.push([x0, y0]);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
return punkte;
}
function rueckgaengig() {
if (!ed.undo.length) return;
ed.redo.push(ed.daten.slice());
ed.daten = ed.undo.pop();
ed.geaendert = true;
texturGeaendert();
aktualisiereKnoepfe();
}
function wiederholen() {
if (!ed.redo.length) return;
ed.undo.push(ed.daten.slice());
ed.daten = ed.redo.pop();
ed.geaendert = true;
texturGeaendert();
aktualisiereKnoepfe();
}
// ganze Änderung (Laden, Leeren …) als ein Rückgängig-Schritt
function aenderung(fn) {
const snap = ed.daten.slice();
fn();
if (snap.every((v, i) => v === ed.daten[i])) return false;
ed.undo.push(snap);
if (ed.undo.length > 100) ed.undo.shift();
ed.redo = [];
ed.geaendert = true;
texturGeaendert();
aktualisiereKnoepfe();
return true;
}
// ---------- Farben ----------
const FARB_SCHLUESSEL = 'aeromc-skin-farben';
const STANDARD_FARBEN = ['#000000', '#ffffff', '#c69c6d', '#a87b4f', '#6b4a2f', '#3b2416', '#d32f2f', '#f9a825',
'#43a047', '#1e88e5', '#3949ab', '#8e24aa', '#00897b', '#757575', '#424242', '#ff8a65'];
let zuletzt = [];
try { zuletzt = JSON.parse(localStorage.getItem(FARB_SCHLUESSEL) || '[]'); } catch { zuletzt = []; }
if (!Array.isArray(zuletzt) || !zuletzt.length) zuletzt = STANDARD_FARBEN.slice();
function merkeFarbe(hex) {
zuletzt = [hex, ...zuletzt.filter((f) => f !== hex)].slice(0, 16);
try { localStorage.setItem(FARB_SCHLUESSEL, JSON.stringify(zuletzt)); } catch { /* egal */ }
zeigeZuletzt();
}
function zeigeZuletzt() {
const box = el('se-zuletzt');
box.innerHTML = '';
for (const f of zuletzt) {
const b = document.createElement('button');
b.className = 'se-farbfeld' + (f === ed.farbe ? ' on' : '');
b.style.background = f;
b.title = f;
b.addEventListener('click', () => setzeFarbe(f));
box.appendChild(b);
}
}
function setzeFarbe(hex) {
ed.farbe = hex.toLowerCase();
el('se-farbe').value = ed.farbe;
el('se-hex').value = ed.farbe;
// nach dem Aufnehmen zurück zum vorherigen Werkzeug; mit dem Radierer
// ergibt eine neue Farbe keinen Sinn -> Stift
if (ed.werkzeug === 'pipette') waehleWerkzeug(ed.vorPipette === 'radierer' ? 'stift' : ed.vorPipette);
else if (ed.werkzeug === 'radierer') waehleWerkzeug('stift');
zeigeZuletzt();
}
// ---------- Bedienelemente ----------
function waehleWerkzeug(w) {
if (w === 'radierer' && ed.schicht === 0) w = 'stift';
if (w === 'pipette' && ed.werkzeug !== 'pipette') ed.vorPipette = ed.werkzeug;
ed.werkzeug = w;
document.querySelectorAll('[data-se-tool]').forEach((b) => b.classList.toggle('on', b.dataset.seTool === w));
for (const h of [el('se-2d'), el('se-3d')]) h.dataset.werkzeug = w;
}
function waehleSchicht(s) {
ed.schicht = s;
document.querySelectorAll('[data-se-schicht]').forEach((b) => b.classList.toggle('on', Number(b.dataset.seSchicht) === s));
// die 2. Schicht muss sichtbar sein, wenn man darauf malen will
if (s === 1 && !ed.zeigeAussen) { ed.zeigeAussen = true; el('se-aussen').checked = true; }
const radierer = document.querySelector('[data-se-tool="radierer"]');
radierer.disabled = s === 0;
radierer.title = s === 0
? 'Radierer – nur in der 2. Schicht (die Grundschicht darf keine Löcher haben)'
: 'Radierer (E) – macht Pixel durchsichtig';
if (s === 0 && ed.werkzeug === 'radierer') waehleWerkzeug('stift');
el('se-durch').disabled = s === 0;
ed.hover = null;
texturGeaendert();
}
function waehleModus(m) {
ed.modus = m;
document.querySelectorAll('[data-se-modus]').forEach((b) => b.classList.toggle('on', b.dataset.seModus === m));
const gross = el('se-buehne');
const klein = el('se-mini');
gross.appendChild(m === '2d' ? el('se-2d') : el('se-3d'));
klein.appendChild(m === '2d' ? el('se-3d') : el('se-2d'));
el('se-mini-titel').textContent = m === '2d' ? '3D-Vorschau' : 'Vorlage';
zeigeInfo();
passeGroessenAn();
}
function aktualisiereKnoepfe() {
el('se-undo').disabled = !ed.undo.length;
el('se-redo').disabled = !ed.redo.length;
}
function zeigeInfo() {
const info = el('se-info');
const h = ed.hover;
const f = h && flaecheBei(h[0], h[1]);
if (f) {
info.textContent = `${f.teil.name} · ${SEITE_NAME[f.seite]} · ${SCHICHT_NAME[f.schicht]} · Pixel ${h[0]}, ${h[1]}`;
return;
}
info.textContent = ed.modus === '3d'
? 'Linke Maustaste malt · rechte Maustaste dreht · Mausrad zoomt · Alt+Klick nimmt Farbe auf'
: 'Linke Maustaste malt · Alt+Klick nimmt eine Farbe auf · abgedunkelte Bereiche gehören zur anderen Schicht';
}
function status(text) { el('se-status').textContent = text || ''; el('se-status').title = text || ''; }
// ---------- Zeichnen ----------
function texturGeaendert() {
texturCtx.putImageData(new ImageData(ed.daten, N, N), 0, 0);
// Schatten: Grundschicht-Pixel an die Stelle der zugehörigen 2. Schicht legen
const s = schattenCtx.createImageData(N, N);
for (const f of aufteilung().flaechen) {
if (f.schicht !== 1) continue;
const r = seiten(f.teil, 0, ed.slim)[f.seite];
for (let yy = 0; yy < f.h; yy++) {
for (let xx = 0; xx < f.w; xx++) {
const q = ((r[1] + yy) * N + r[0] + xx) * 4;
const z = ((f.y + yy) * N + f.x + xx) * 4;
s.data[z] = ed.daten[q]; s.data[z + 1] = ed.daten[q + 1]; s.data[z + 2] = ed.daten[q + 2]; s.data[z + 3] = ed.daten[q + 3];
}
}
}
schattenCtx.putImageData(s, 0, 0);
planeZeichnen();
}
function planeZeichnen() {
if (ed.zeichnenGeplant) return;
ed.zeichnenGeplant = true;
requestAnimationFrame(() => {
ed.zeichnenGeplant = false;
zeichne2D();
zeichne3D();
zeigeInfo();
});
}
function hintergrundFuer(slim) {
const k = slim ? 1 : 0;
if (hintergrund[k] && hintergrund[k].dunkel === dunkel()) return hintergrund[k].canvas;
const c = document.createElement('canvas');
c.width = c.height = N;
const g = c.getContext('2d');
const img = g.createImageData(N, N);
const [a, b] = dunkel() ? [[58, 58, 64], [48, 48, 54]] : [[236, 236, 238], [218, 219, 223]];
for (const f of AUFTEILUNG[k].flaechen) {
for (let yy = f.y; yy < f.y + f.h; yy++) {
for (let xx = f.x; xx < f.x + f.w; xx++) {
const farbe = (xx + yy) % 2 ? a : b;
const i = (yy * N + xx) * 4;
img.data[i] = farbe[0]; img.data[i + 1] = farbe[1]; img.data[i + 2] = farbe[2]; img.data[i + 3] = 255;
}
}
}
g.putImageData(img, 0, 0);
hintergrund[k] = { canvas: c, dunkel: dunkel() };
return c;
}
// sichtbare Textur für die 2D-Ansicht: ohne ungenutzte Bereiche und, falls
// ausgeblendet, ohne 2. Schicht
const sicht = document.createElement('canvas');
sicht.width = sicht.height = N;
const sichtCtx = sicht.getContext('2d');
function zeichne2D() {
const canvas = el('se-2d-canvas');
const k = Number(canvas.dataset.k) || 0;
if (!k) return;
const g = canvas.getContext('2d');
g.setTransform(1, 0, 0, 1, 0, 0);
g.clearRect(0, 0, canvas.width, canvas.height);
g.imageSmoothingEnabled = false;
g.setTransform(k, 0, 0, k, 0, 0);
g.fillStyle = dunkel() ? '#1c1c1f' : '#c9ccd1';
g.fillRect(0, 0, N, N);
g.drawImage(hintergrundFuer(ed.slim), 0, 0);
if (ed.schicht === 1 && ed.durchscheinen) {
g.globalAlpha = 0.35;
g.drawImage(schatten, 0, 0);
g.globalAlpha = 1;
}
sichtCtx.clearRect(0, 0, N, N);
const img = sichtCtx.createImageData(N, N);
const karte = aufteilung().karte;
for (let i = 0; i < N * N; i++) {
const f = karte[i];
if (!f || (f.schicht === 1 && !ed.zeigeAussen)) continue;
const q = i * 4;
img.data[q] = ed.daten[q]; img.data[q + 1] = ed.daten[q + 1]; img.data[q + 2] = ed.daten[q + 2]; img.data[q + 3] = ed.daten[q + 3];
}
sichtCtx.putImageData(img, 0, 0);
g.drawImage(sicht, 0, 0);
// andere Schicht abdunkeln
g.fillStyle = dunkel() ? 'rgba(20,20,22,.62)' : 'rgba(40,44,52,.45)';
for (const f of aufteilung().flaechen) if (f.schicht !== ed.schicht) g.fillRect(f.x, f.y, f.w, f.h);
const px = 1 / k;
// Pixelraster
if (k >= 6) {
g.strokeStyle = dunkel() ? 'rgba(255,255,255,.07)' : 'rgba(0,0,0,.08)';
g.lineWidth = px;
g.beginPath();
for (let i = 1; i < N; i++) { g.moveTo(i, 0); g.lineTo(i, N); g.moveTo(0, i); g.lineTo(N, i); }
g.stroke();
}
// Flächenränder
g.lineWidth = 1.5 * px;
for (const f of aufteilung().flaechen) {
g.strokeStyle = f.schicht === ed.schicht
? (dunkel() ? 'rgba(255,255,255,.45)' : 'rgba(0,0,0,.4)')
: (dunkel() ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.12)');
g.strokeRect(f.x, f.y, f.w, f.h);
}
// Mauszeiger und gespiegelter Partner
if (ed.hover) {
const [hx, hy] = ed.hover;
g.lineWidth = 2 * px;
g.strokeStyle = dunkel() ? '#ffffff' : '#000000';
g.strokeRect(hx + px, hy + px, 1 - 2 * px, 1 - 2 * px);
if (ed.spiegeln) {
const s = spiegelPixel(hx, hy);
if (s && (s[0] !== hx || s[1] !== hy)) {
g.setLineDash([2 * px, 2 * px]);
g.strokeRect(s[0] + px, s[1] + px, 1 - 2 * px, 1 - 2 * px);
g.setLineDash([]);
}
}
}
}
function zeichne3D() {
const v = ed.viewer;
if (!v || !v.skinTexture) return;
const g = v.skinCanvas.getContext('2d');
if (v.skinCanvas.width !== N || v.skinCanvas.height !== N) { v.skinCanvas.width = N; v.skinCanvas.height = N; }
g.clearRect(0, 0, N, N);
g.drawImage(textur, 0, 0);
// Pixel unter der Maus hervorheben
if (ed.hover && ed.werkzeug !== 'pipette') {
const ziele = ed.spiegeln ? zielPixel(ed.hover[0], ed.hover[1]) : [ed.hover];
for (const [hx, hy] of ziele) {
const i = (hy * N + hx) * 4;
const hell = ed.daten[i + 3] === 0 || (ed.daten[i] + ed.daten[i + 1] + ed.daten[i + 2]) < 380;
g.fillStyle = hell ? 'rgba(255,255,255,.75)' : 'rgba(0,0,0,.55)';
g.fillRect(hx, hy, 1, 1);
}
}
v.skinTexture.needsUpdate = true;
v.playerObject.skin.setOuterLayerVisible(ed.zeigeAussen);
}
// ---------- Größen ----------
function passeGroessenAn() {
const h2 = el('se-2d');
const c = el('se-2d-canvas');
const platz = Math.min(h2.clientWidth, h2.clientHeight) - 8;
const s = Math.max(1, Math.floor(platz / N));
const dpr = window.devicePixelRatio || 1;
c.style.width = c.style.height = (s * N) + 'px';
c.width = c.height = Math.round(s * N * dpr);
c.dataset.k = String((s * N * dpr) / N);
const h3 = el('se-3d');
if (ed.viewer && h3.clientWidth && h3.clientHeight) ed.viewer.setSize(h3.clientWidth, h3.clientHeight);
planeZeichnen();
}
// ---------- 2D-Eingabe ----------
function texelAus2D(e) {
const c = el('se-2d-canvas');
const r = c.getBoundingClientRect();
const x = Math.floor(((e.clientX - r.left) / r.width) * N);
const y = Math.floor(((e.clientY - r.top) / r.height) * N);
return x >= 0 && y >= 0 && x < N && y < N ? [x, y] : null;
}
function verbinde2D() {
const c = el('se-2d-canvas');
c.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
const t = texelAus2D(e);
if (!t) return;
e.preventDefault();
try { c.setPointerCapture(e.pointerId); } catch { /* egal */ }
if (e.altKey) { nimmFarbe(t[0], t[1]); return; }
beginneStrich();
ed.strich.zweiD = true;
wendeAn(t[0], t[1]);
});
c.addEventListener('pointermove', (e) => {
const t = texelAus2D(e);
if (ed.strich && t) wendeAn(t[0], t[1]);
const neu = t && flaecheBei(t[0], t[1]) ? t : null;
if (String(neu) !== String(ed.hover)) { ed.hover = neu; planeZeichnen(); }
});
const ende = () => beendeStrich();
c.addEventListener('pointerup', ende);
c.addEventListener('pointercancel', ende);
c.addEventListener('pointerleave', () => { if (!ed.strich && ed.hover) { ed.hover = null; planeZeichnen(); } });
}
// ---------- 3D: Strahl gegen die Figur ----------
// three.js' Raycaster wird von skinview3d nicht mitgeliefert – daher eigene
// Schnittprüfung Strahl/Dreieck (Möller–Trumbore) gegen die Quader der Figur.
function schnitt(o, d, a, b, c) {
const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
const px = d.y * e2z - d.z * e2y, py = d.z * e2x - d.x * e2z, pz = d.x * e2y - d.y * e2x;
const det = e1x * px + e1y * py + e1z * pz;
if (Math.abs(det) < 1e-9) return null;
const inv = 1 / det;
const tx = o.x - a.x, ty = o.y - a.y, tz = o.z - a.z;
const u = (tx * px + ty * py + tz * pz) * inv;
if (u < 0 || u > 1) return null;
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
const v = (d.x * qx + d.y * qy + d.z * qz) * inv;
if (v < 0 || u + v > 1) return null;
const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
return t > 1e-6 ? { t, u, v } : null;
}
// liefert den Texturpixel der getroffenen Schicht (0/1) oder null
function strahl(clientX, clientY, schicht) {
const v = ed.viewer;
if (!v) return null;
const r = v.canvas.getBoundingClientRect();
if (!r.width || !r.height) return null;
const cam = v.camera;
const V3 = cam.position.constructor;
cam.updateMatrixWorld();
const o = new V3().setFromMatrixPosition(cam.matrixWorld);
const nx = ((clientX - r.left) / r.width) * 2 - 1;
const ny = -((clientY - r.top) / r.height) * 2 + 1;
const d = new V3(nx, ny, 0.5).unproject(cam).sub(o).normalize();
v.playerObject.updateMatrixWorld(true);
const ecken = [new V3(), new V3(), new V3()];
let best = null;
for (const teil of v.playerObject.skin.getBodyParts()) {
if (!teil.visible) continue;
const mesh = schicht ? teil.outerLayer : teil.innerLayer;
if (!mesh || !mesh.visible) continue;
const geo = mesh.geometry;
const pos = geo.attributes.position;
const idx = geo.index;
const anzahl = idx ? idx.count : pos.count;
for (let i = 0; i < anzahl; i += 3) {
const ia = idx ? [idx.getX(i), idx.getX(i + 1), idx.getX(i + 2)] : [i, i + 1, i + 2];
for (let k = 0; k < 3; k++) ecken[k].fromBufferAttribute(pos, ia[k]).applyMatrix4(mesh.matrixWorld);
const h = schnitt(o, d, ecken[0], ecken[1], ecken[2]);
if (h && (!best || h.t < best.t)) best = { t: h.t, b: [1 - h.u - h.v, h.u, h.v], ia, uv: geo.attributes.uv };
}
}
if (!best) return null;
let U = 0, W = 0;
let uMin = 1, uMax = 0, vMin = 1, vMax = 0;
for (let k = 0; k < 3; k++) {
const u = best.uv.getX(best.ia[k]);
const w = best.uv.getY(best.ia[k]);
U += best.b[k] * u; W += best.b[k] * w;
uMin = Math.min(uMin, u); uMax = Math.max(uMax, u); vMin = Math.min(vMin, w); vMax = Math.max(vMax, w);
}
// Dreieck gehört zu genau einer Seitenfläche – Rundung an der Kante darf
// nicht in die Nachbarfläche rutschen
const x0 = Math.round(uMin * N), x1 = Math.round(uMax * N) - 1;
const y0 = Math.round((1 - vMax) * N), y1 = Math.round((1 - vMin) * N) - 1;
const x = Math.min(x1, Math.max(x0, Math.floor(U * N)));
const y = Math.min(y1, Math.max(y0, Math.floor((1 - W) * N)));
return { t: best.t, x, y };
}
// sichtbarer Pixel für die Pipette: erst die 2. Schicht (falls dort Farbe ist)
function sichtbarerPixel(cx, cy) {
if (ed.zeigeAussen) {
const a = strahl(cx, cy, 1);
if (a && ed.daten[(a.y * N + a.x) * 4 + 3] > 0) return [a.x, a.y];
}
const b = strahl(cx, cy, 0);
return b ? [b.x, b.y] : null;
}
function verbinde3D() {
const huelle = el('se-3d');
const canvas = el('se-3d-canvas');
// in der Einfangphase: trifft die Maus die Figur, malen wir und die
// Drehsteuerung von skinview3d bekommt das Ereignis gar nicht erst
huelle.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !ed.viewer) return;
if (e.altKey || ed.werkzeug === 'pipette') {
const t = sichtbarerPixel(e.clientX, e.clientY);
if (!t) return;
e.stopPropagation(); e.preventDefault();
nimmFarbe(t[0], t[1]);
return;
}
const t = strahl(e.clientX, e.clientY, ed.schicht);
if (!t) return;
e.stopPropagation(); e.preventDefault();
try { canvas.setPointerCapture(e.pointerId); } catch { /* egal */ }
beginneStrich();
ed.strich.letzterBild = [e.clientX, e.clientY];
wendeAn(t.x, t.y);
}, true);
canvas.addEventListener('pointermove', (e) => {
if (ed.strich) {
// Strecke seit dem letzten Ereignis in kleinen Schritten abtasten
const [ax, ay] = ed.strich.letzterBild || [e.clientX, e.clientY];
const schritte = Math.max(1, Math.ceil(Math.hypot(e.clientX - ax, e.clientY - ay) / 2));
for (let i = 1; i <= schritte; i++) {
const t = strahl(ax + ((e.clientX - ax) * i) / schritte, ay + ((e.clientY - ay) * i) / schritte, ed.schicht);
if (t) wendeAn(t.x, t.y);
}
ed.strich.letzterBild = [e.clientX, e.clientY];
}
if (e.buttons && !ed.strich) return; // dreht gerade
const t = strahl(e.clientX, e.clientY, ed.schicht);
const neu = t ? [t.x, t.y] : null;
if (String(neu) !== String(ed.hover)) { ed.hover = neu; planeZeichnen(); }
});
const ende = () => beendeStrich();
canvas.addEventListener('pointerup', ende);
canvas.addEventListener('pointercancel', ende);
canvas.addEventListener('pointerleave', () => { if (!ed.strich && ed.hover) { ed.hover = null; planeZeichnen(); } });
}
async function erzeugeViewer() {
if (ed.viewer || typeof skinview3d === 'undefined') {
if (typeof skinview3d === 'undefined') el('se-3d-leer').textContent = '3D-Ansicht nicht verfügbar';
return;
}
const h = el('se-3d');
const viewer = new skinview3d.SkinViewer({
canvas: el('se-3d-canvas'),
width: h.clientWidth || 300,
height: h.clientHeight || 300,
skin: textur,
model: ed.slim ? 'slim' : 'default',
});
await new Promise((resolve) => {
const check = (n) => {
if ((viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) || n > 150) resolve();
else setTimeout(() => check(n + 1), 40);
};
check(0);
});
viewer.fov = 36;
viewer.zoom = 0.78;
viewer.autoRotate = false;
viewer.animation = null;
// leicht abgespreizte Arme/Beine, damit man auch an die Innenseiten kommt
const p = viewer.playerObject;
p.rotation.set(0, -0.35, 0);
p.skin.leftArm.rotation.set(0, 0, 0.2);
p.skin.rightArm.rotation.set(0, 0, -0.2);
p.skin.leftLeg.rotation.set(0, 0, 0.06);
p.skin.rightLeg.rotation.set(0, 0, -0.06);
viewer.controls.enableRotate = true;
viewer.controls.enableZoom = true;
viewer.controls.enablePan = false;
// rechte Maustaste dreht immer – auch wenn man gerade auf die Figur zeigt
viewer.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 0 };
ed.viewer = viewer;
el('se-3d-leer').classList.add('hidden');
passeGroessenAn();
}
async function setzeArmform(slim) {
ed.slim = !!slim;
el('se-variante').value = ed.slim ? 'slim' : 'classic';
ed.hover = null;
if (ed.viewer) {
await ed.viewer.loadSkin(textur, { model: ed.slim ? 'slim' : 'default' });
}
texturGeaendert();
}
// ---------- Laden / Vorlage ----------
function vorlage() {
const d = new Uint8ClampedArray(N * N * 4);
const farben = { head: '#c69c6d', body: '#2f9e9e', rightArm: '#c69c6d', leftArm: '#c69c6d', rightLeg: '#3b3f9e', leftLeg: '#3b3f9e' };
for (const f of AUFTEILUNG[ed.slim ? 1 : 0].flaechen) {
if (f.schicht !== 0) continue;
const [r, g, b] = hexZuRgb(farben[f.teil.key]);
for (let yy = f.y; yy < f.y + f.h; yy++) {
for (let xx = f.x; xx < f.x + f.w; xx++) {
const i = (yy * N + xx) * 4;
d[i] = r; d[i + 1] = g; d[i + 2] = b; d[i + 3] = 255;
}
}
}
return d;
}
function ladeBild(dataUri) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
if (img.width !== N || (img.height !== N && img.height !== N / 2)) {
reject(new Error(`Ein Skin muss 64×64 oder 64×32 Pixel groß sein (diese Datei: ${img.width}×${img.height}).`));
return;
}
const c = document.createElement('canvas');
c.width = c.height = N;
const g = c.getContext('2d', { willReadFrequently: true });
g.drawImage(img, 0, 0);
const daten = g.getImageData(0, 0, N, N).data;
if (img.height === N / 2) {
// alte 64×32-Skins: linker Arm und linkes Bein sind dort gespiegelte
// Kopien der rechten – wie Minecraft es selbst macht
const karte = AUFTEILUNG[0].karte;
for (let i = 0; i < N * N / 2; i++) {
const f = karte[i];
if (!f || f.schicht !== 0 || (f.teil.key !== 'rightArm' && f.teil.key !== 'rightLeg')) continue;
const partner = TEIL[f.teil.spiegel];
const seite = SEITE_GEGENSTUECK[f.seite] || f.seite;
const r = seiten(partner, 0, false)[seite];
const x = i % N, y = Math.floor(i / N);
const z = ((r[1] + (y - f.y)) * N + r[0] + (r[2] - 1 - (x - f.x))) * 4;
for (let k = 0; k < 4; k++) daten[z + k] = daten[i * 4 + k];
}
}
resolve(daten);
};
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden.'));
img.src = dataUri;
});
}
async function uebernimmDaten(daten, variante, text) {
if (variante) await setzeArmform(variante === 'slim');
aenderung(() => { ed.daten = new Uint8ClampedArray(daten); });
ed.geaendert = false;
texturGeaendert();
status(text || '');
}
function wennVerwerfenOk(aktion) {
if (!ed.geaendert) { aktion(); return; }
askConfirm('Änderungen verwerfen?', 'Der Skin im Editor hat ungespeicherte Änderungen. Trotzdem fortfahren?', 'Verwerfen', aktion);
}
async function ladeAktuellen() {
status('Aktueller Skin wird geladen …');
const res = await window.api.skinCurrentData();
if (!res.ok) {
if (res.standard) {
await uebernimmDaten(vorlage(), res.variante, 'Dein Konto nutzt den Standard-Skin – hier ist eine leere Vorlage zum Loslegen.');
} else {
status('✖ ' + (res.message || 'Skin konnte nicht geladen werden.'));
}
return;
}
try {
await uebernimmDaten(await ladeBild(res.dataUri), res.variante, `Skin von ${res.name || 'deinem Konto'} geladen.`);
} catch (err) { status('✖ ' + err.message); }
}
async function ladeDatei(pfad, variante) {
const res = pfad ? await window.api.skinReadData(pfad) : await window.api.skinChoose();
if (res.canceled) return;
if (!res.ok) { status('✖ ' + (res.message || 'Datei konnte nicht geladen werden.')); return; }
try {
await uebernimmDaten(await ladeBild(res.dataUri), variante, `${(res.pfad || pfad || '').split(/[\\/]/).pop()} geladen.`);
} catch (err) { status('✖ ' + err.message); }
}
// ---------- Speichern / Übernehmen ----------
function alsBase64() {
const c = document.createElement('canvas');
c.width = c.height = N;
c.getContext('2d').putImageData(new ImageData(ed.daten, N, N), 0, 0);
return c.toDataURL('image/png').split(',')[1];
}
// Minecraft zeigt durchsichtige Pixel der Grundschicht schwarz an
function loecherInGrundschicht() {
let n = 0;
for (const f of aufteilung().flaechen) {
if (f.schicht !== 0) continue;
for (let yy = f.y; yy < f.y + f.h; yy++) for (let xx = f.x; xx < f.x + f.w; xx++) if (ed.daten[(yy * N + xx) * 4 + 3] < 255) n++;
}
return n;
}
function loecherHinweis() {
const n = loecherInGrundschicht();
return n ? ` Hinweis: ${n} durchsichtige Pixel in der Grundschicht erscheinen im Spiel schwarz.` : '';
}
async function speichere() {
const res = await window.api.skinSaveData(alsBase64());
if (res.canceled) return;
if (!res.ok) { status('✖ ' + (res.message || 'Speichern fehlgeschlagen.')); return; }
ed.geaendert = false;
status('✔ Gespeichert: ' + res.pfad.split(/[\\/]/).pop() + '.' + loecherHinweis());
}
async function uebernimm() {
const btn = el('se-uebernehmen');
btn.disabled = true;
status('Wird hochgeladen …');
try {
const res = await window.api.skinApplyData(alsBase64(), ed.slim ? 'slim' : 'classic');
if (res.ok) {
ed.geaendert = false;
status('✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.' + loecherHinweis());
toast('Skin geändert.');
} else {
status('✖ ' + (res.message || 'Fehlgeschlagen.'));
}
} finally {
btn.disabled = false;
}
}
// ---------- Öffnen ----------
let verdrahtet = false;
function verdrahte() {
if (verdrahtet) return;
verdrahtet = true;
document.querySelectorAll('[data-se-tool]').forEach((b) => b.addEventListener('click', () => waehleWerkzeug(b.dataset.seTool)));
document.querySelectorAll('[data-se-schicht]').forEach((b) => b.addEventListener('click', () => waehleSchicht(Number(b.dataset.seSchicht))));
document.querySelectorAll('[data-se-modus]').forEach((b) => b.addEventListener('click', () => waehleModus(b.dataset.seModus)));
el('se-farbe').addEventListener('input', () => setzeFarbe(el('se-farbe').value));
el('se-hex').addEventListener('change', () => {
let v = el('se-hex').value.trim();
if (!v.startsWith('#')) v = '#' + v;
if (/^#[0-9a-f]{6}$/i.test(v)) setzeFarbe(v);
else el('se-hex').value = ed.farbe;
});
el('se-aussen').addEventListener('change', () => {
ed.zeigeAussen = el('se-aussen').checked;
if (!ed.zeigeAussen && ed.schicht === 1) waehleSchicht(0);
planeZeichnen();
});
el('se-durch').addEventListener('change', () => { ed.durchscheinen = el('se-durch').checked; planeZeichnen(); });
el('se-spiegeln').addEventListener('change', () => { ed.spiegeln = el('se-spiegeln').checked; planeZeichnen(); });
el('se-leeren').addEventListener('click', () => {
askConfirm('2. Schicht leeren?', 'Alle Pixel der 2. Schicht werden durchsichtig. Mit Strg+Z lässt sich das rückgängig machen.', 'Leeren', () => {
const geaendert = aenderung(() => {
for (const f of aufteilung().flaechen) {
if (f.schicht !== 1) continue;
for (let yy = f.y; yy < f.y + f.h; yy++) for (let xx = f.x; xx < f.x + f.w; xx++) setzePixel(xx, yy, [0, 0, 0, 0]);
}
});
status(geaendert ? '2. Schicht geleert.' : 'Die 2. Schicht war schon leer.');
});
});
el('se-variante').addEventListener('change', () => setzeArmform(el('se-variante').value === 'slim'));
el('se-undo').addEventListener('click', rueckgaengig);
el('se-redo').addEventListener('click', wiederholen);
el('se-neu').addEventListener('click', () => wennVerwerfenOk(() => uebernimmDaten(vorlage(), null, 'Neue Vorlage – leg los!')));
el('se-laden').addEventListener('click', () => wennVerwerfenOk(ladeAktuellen));
el('se-oeffnen').addEventListener('click', () => wennVerwerfenOk(() => ladeDatei(null, null)));
el('se-speichern').addEventListener('click', speichere);
el('se-uebernehmen').addEventListener('click', uebernimm);
document.addEventListener('keydown', (e) => {
if (el('modal-skineditor').classList.contains('hidden')) return;
if (!el('modal-confirm').classList.contains('hidden')) return;
const a = document.activeElement;
if (a && (['INPUT', 'TEXTAREA', 'SELECT'].includes(a.tagName) && a.type !== 'checkbox')) return;
const strg = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (strg && k === 'z' && !e.shiftKey) { e.preventDefault(); rueckgaengig(); return; }
if (strg && (k === 'y' || (k === 'z' && e.shiftKey))) { e.preventDefault(); wiederholen(); return; }
if (strg || e.altKey) return;
const werkzeug = { b: 'stift', e: 'radierer', i: 'pipette', g: 'fuellen' }[k];
if (werkzeug) { e.preventDefault(); waehleWerkzeug(werkzeug); return; }
if (k === 'm') { e.preventDefault(); el('se-spiegeln').click(); return; }
if (k === 'l') { e.preventDefault(); waehleSchicht(ed.schicht ? 0 : 1); }
});
verbinde2D();
verbinde3D();
new ResizeObserver(() => passeGroessenAn()).observe(el('se-buehne'));
new ResizeObserver(() => passeGroessenAn()).observe(el('se-mini'));
// Hell/Dunkel gewechselt -> Hintergrund neu
new MutationObserver(() => planeZeichnen()).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
async function openSkinEditor(p = {}) {
if (POPOUT !== 'skineditor') { window.api.popoutOpen('skineditor', p); return; }
verdrahte();
show('modal-skineditor');
if (!ed.bereit) {
ed.bereit = true;
el('se-farbe').value = ed.farbe;
el('se-hex').value = ed.farbe;
zeigeZuletzt();
waehleWerkzeug('stift');
waehleSchicht(0);
aktualisiereKnoepfe();
ed.daten = vorlage();
texturGeaendert();
waehleModus('2d');
await erzeugeViewer();
texturGeaendert();
}
const variante = p.variante === 'slim' || p.variante === 'classic' ? p.variante : null;
if (p.pfad) wennVerwerfenOk(() => ladeDatei(p.pfad, variante));
else if (p.quelle === 'aktuell') wennVerwerfenOk(ladeAktuellen);
else if (variante && !ed.geaendert) await setzeArmform(variante === 'slim');
}
window.openSkinEditor = openSkinEditor;
// für Tests
window.__skinEditor = { ed, strahl, spiegelPixel, seiten, AUFTEILUNG };
})();
+1
View File
@@ -87,6 +87,7 @@ const DEFAULT_SETTINGS = {
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist) bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
+69 -5
View File
@@ -10,7 +10,7 @@
} }
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{margin:0;height:100%;font-family:var(--font);font-size:12px;color:var(--text);background:var(--win)} html,body{margin:0;height:100%;font-family:var(--font);font-size:12px;color:var(--text);background:var(--win)}
body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden} body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:minmax(0,1fr);height:100vh;overflow:hidden}
.hidden{display:none!important} .hidden{display:none!important}
.mono{font-family:var(--mono);font-size:12px} .mono{font-family:var(--mono);font-size:12px}
.spacer{flex:1} .spacer{flex:1}
@@ -75,9 +75,19 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
:root[data-theme="dark"] .modal-close:hover{color:#fff} :root[data-theme="dark"] .modal-close:hover{color:#fff}
:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)} :root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)}
/* ---------- Titelleiste (ersetzt die weiße Windows-Leiste) ---------- */
/* Farben passend zu TITELLEISTE in main.js (dort für die Fensterknöpfe) */
:root{--titel-bg:#f0f0f0;--titel-fg:#1c1c1c}
:root[data-theme="dark"]{--titel-bg:#2d2d30;--titel-fg:#e4e4e6}
.titelleiste{display:flex;align-items:center;gap:8px;height:32px;padding:0 150px 0 10px;background:var(--titel-bg);color:var(--titel-fg);-webkit-app-region:drag;user-select:none;font-size:12px}
.titel-logo{width:16px;height:16px;flex:none}
.titel-text{white-space:nowrap}
body.fenster-inaktiv .titelleiste>*{opacity:.55}
body.is-popout>.titelleiste{display:none}
/* ---------- Toolbar ---------- */ /* ---------- Toolbar ---------- */
.toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;background:var(--win);border-bottom:1px solid var(--border)} .toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;min-width:0;overflow:hidden;background:var(--win);border-bottom:1px solid var(--border)}
.tbtn{display:inline-flex;align-items:center;gap:7px;padding:6px 10px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:12px} .tbtn{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;padding:6px 10px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
.tbtn:hover{background:#e2e6ec;border-color:var(--border2)} .tbtn:hover{background:#e2e6ec;border-color:var(--border2)}
.tbtn:active{background:#d7dde5} .tbtn:active{background:#d7dde5}
.tbtn svg{width:18px;height:18px;color:#4b5563} .tbtn svg{width:18px;height:18px;color:#4b5563}
@@ -95,7 +105,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.search-wrap input:focus{outline:none;border-color:var(--accent)} .search-wrap input:focus{outline:none;border-color:var(--accent)}
/* ---------- Arbeitsbereich ---------- */ /* ---------- Arbeitsbereich ---------- */
.workspace{display:grid;grid-template-columns:1fr 172px;overflow:hidden;background:var(--area)} .workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)}
.list-area{position:relative;overflow:hidden;min-height:0} .list-area{position:relative;overflow:hidden;min-height:0}
/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */ /* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */
.list-area::after{ .list-area::after{
@@ -160,7 +170,7 @@ body.file-drop-active::after{
.empty p{max-width:340px;margin:0 0 8px} .empty p{max-width:340px;margin:0 0 8px}
/* ---------- Statusleiste (zwei Zeilen) ---------- */ /* ---------- Statusleiste (zwei Zeilen) ---------- */
.statusbar{display:flex;flex-direction:column;background:var(--win);border-top:1px solid var(--border)} .statusbar{display:flex;flex-direction:column;min-width:0;background:var(--win);border-top:1px solid var(--border)}
.st-row{display:flex;justify-content:space-between;padding:3px 10px;font-size:11px;color:#333} .st-row{display:flex;justify-content:space-between;padding:3px 10px;font-size:11px;color:#333}
.st-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444} .st-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444}
.auth-warn{ .auth-warn{
@@ -640,3 +650,57 @@ img.account-av{object-fit:cover;image-rendering:auto}
::-webkit-scrollbar{width:14px;height:14px} ::-webkit-scrollbar{width:14px;height:14px}
::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px} ::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px}
::-webkit-scrollbar-thumb:hover{background:#a8a8a8} ::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
/* ===================== Skin-Editor ===================== */
#modal-skineditor .se-karte{width:1100px;max-width:calc(100vw - 32px);height:760px;max-height:calc(100vh - 40px)}
.se-modus{display:inline-flex;border:1px solid var(--border);border-radius:4px;overflow:hidden;background:var(--bar)}
.se-modus-btn{padding:5px 14px;border:none;background:none;color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
.se-modus-btn+.se-modus-btn{border-left:1px solid var(--border)}
.se-modus-btn:hover{color:var(--text)}
.se-modus-btn.on{background:var(--accent);color:#fff;font-weight:600}
.se-body{display:grid;grid-template-columns:200px minmax(0,1fr) 270px;gap:14px;flex:1 1 auto;min-height:0;overflow:hidden;padding:12px 14px}
.se-werkzeuge{display:flex;flex-direction:column;gap:14px;overflow:auto;min-height:0;padding-right:2px}
.se-gruppe{display:flex;flex-direction:column;gap:6px}
.se-titel{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dim)}
.se-tools{display:grid;grid-template-columns:1fr 1fr;gap:4px}
.se-tool{display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px 4px;border:1px solid var(--border);border-radius:4px;background:var(--bar);color:var(--text);font:inherit;font-size:11px;cursor:pointer}
.se-tool svg{width:18px;height:18px}
.se-tool:hover:not(:disabled){border-color:var(--sel-br)}
.se-tool.on{background:var(--sel);border-color:var(--sel-br);font-weight:600}
.se-tool:disabled{opacity:.4;cursor:default}
.se-farbzeile{display:flex;gap:6px;align-items:center}
.se-farbzeile input[type=color]{width:42px;height:30px;padding:0 2px;border:1px solid var(--border);border-radius:4px;background:var(--bar);cursor:pointer}
.se-farbzeile input[type=text]{flex:1;min-width:0;height:30px;padding:0 8px;border:1px solid var(--border);border-radius:4px;font-family:var(--mono);font-size:12px;background:#fff;color:var(--text)}
:root[data-theme="dark"] .se-farbzeile input[type=text]{background:#1e1e1e}
.se-zuletzt{display:grid;grid-template-columns:repeat(8,1fr);gap:3px}
.se-farbfeld{aspect-ratio:1;border:1px solid rgba(0,0,0,.25);border-radius:3px;cursor:pointer;padding:0}
.se-farbfeld:hover{transform:scale(1.12)}
.se-farbfeld.on{outline:2px solid var(--sel-br);outline-offset:1px}
.se-ebenen{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--border);border-radius:4px;overflow:hidden}
.se-ebene{padding:6px 4px;border:none;background:var(--bar);color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
.se-ebene+.se-ebene{border-left:1px solid var(--border)}
.se-ebene.on{background:var(--sel);color:var(--text);font-weight:600}
.se-check{display:flex;align-items:center;gap:6px;cursor:pointer}
.se-check input{margin:0}
.se-check:has(input:disabled){opacity:.5;cursor:default}
.se-feld{display:flex;flex-direction:column;gap:3px}
.se-feld select{height:28px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font:inherit}
:root[data-theme="dark"] .se-feld select{background:#1e1e1e}
.se-undo{display:grid;grid-template-columns:1fr 1fr;gap:4px}
.se-undo .btn{justify-content:center}
.se-gruppe>.btn-mini{align-self:flex-start}
.se-mitte{display:flex;flex-direction:column;gap:6px;min-width:0;min-height:0}
.se-buehne{flex:1 1 auto;min-height:0;position:relative;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
.se-info{min-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.se-seite{display:flex;flex-direction:column;gap:6px;min-height:0}
.se-mini{position:relative;height:320px;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
.se-tipp{margin:4px 0 0;line-height:1.45}
.se-ansicht{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
.se-ansicht canvas{display:block}
.se-2d canvas{cursor:crosshair;border-radius:2px}
.se-3d canvas{cursor:grab}
.se-3d canvas:active{cursor:grabbing}
.se-2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-3d-leer{position:absolute}
.se-fuss{align-items:center}
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}