diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9a75b..1b1e2a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,136 +1,158 @@ -# Changelog - -Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. - -## [1.1.5] - 2026-10-02 - -### Neu -- Instanz teilen per Code: „Instanz teilen“ in der Seitenleiste erzeugt einen Code (30 Tage gültig) – Freunde lösen ihn unter Importieren → „Per Code“ ein und bekommen Minecraft-Version, Loader sowie alle Mods, Ressourcenpakete und Shader in denselben Versionen -- Absturz-Assistent: erklärt die Ursache eines Absturzes in einfachen Worten – fehlende Abhängigkeiten, Mods für eine andere Minecraft-Version, unverträgliche oder doppelte Mods, falsches Java, zu viel oder zu wenig Arbeitsspeicher, Grafiktreiber – und behebt sie auf Wunsch mit einem Klick -- Der Absturz-Assistent meldet sich auch, wenn Minecraft schon beim Start scheitert und keinen Absturzbericht schreibt -- Nach einer Lösung lässt sich die Instanz direkt aus dem Absturz-Fenster erneut starten -- Fehlerberichte per Klick: „An M_Viper senden“ im Absturz-Assistenten schickt Bericht und Mod-Liste an den Entwickler – Windows-Benutzername und Anmeldedaten werden vorher entfernt, ohne Häkchen wird anonym gesendet -- Kurznachrichten an Freunde: kleiner Chat im Freunde-Fenster mit Schnellantworten wie „Bin in 10 Min. online“ – neue Nachrichten erscheinen im Launcher und als Meldung im Spiel -- Screenshots verschicken: mit 📤 in der Screenshot-Galerie ein Bild direkt an einen Freund schicken, er sieht es im Chat -- Schnellstart-Tasten: Lieblings-Instanzen per Rechtsklick auf Strg+1 bis Strg+5 legen und mit der Taste direkt starten -- Rechtsklick auf das AeroMC-Symbol neben der Uhr startet Schnellstart- und zuletzt gespielte Instanzen, ohne das Fenster zu öffnen -- Welten kopieren: „Kopieren nach …“ übernimmt eine Welt mit einem Klick in eine andere Instanz -- Automatische Welt-Sicherung: nach jeder Spielsitzung wird die zuletzt gespielte Welt gesichert, die letzten 5 Stände bleiben erhalten (abschaltbar unter Optionen → Backup) -- Rechtsklick-Menü in allen Textfeldern (Ausschneiden, Kopieren, Einfügen, Alles auswählen) - -### Geändert -- Freundesystem abgesichert: Anmeldung über Mojangs Sitzungsprüfung wie bei Minecraft-Servern – niemand kann sich mehr als ein anderer Spieler ausgeben, ganz ohne eigenes Passwort -- Das Symbol im Infobereich neben der Uhr ist jetzt immer da, nicht nur bei Autostart -- Der Zähler am „Freunde“-Knopf zählt jetzt offene Anfragen und ungelesene Nachrichten -- Installer: Lizenztext übersichtlich mit Überschriften und Aufzählungen, Rückfragen im Setup-Look statt Windows-Meldungsfenster, der nicht abwählbare Startmenü-Eintrag wird nicht mehr angezeigt -- Benötigt das Plugin „AeroMC Freunde“ 1.4.0 auf dem Server (Kurznachrichten, Screenshots, Fehlerberichte, Instanz teilen) - -### Behoben -- Windows-Benachrichtigungen zeigten „electron.app.AeroMC“ ohne Logo statt „AeroMC“ -- Im Import-Fenster standen „Wählen…“, „Suchen“ und „Prüfen“ unter statt neben dem Eingabefeld - -## [1.1.4] - 2026-10-02 - -### Neu -- Neuer Installer im modernen Look: Schritt-für-Schritt-Assistent mit Lizenz, Ordnerwahl und Verknüpfungen, eigene Deinstallation (Instanzen und Welten bleiben auf Wunsch erhalten) – eine vorhandene Installation wird automatisch übernommen -- Optionaler Import im Installer: erkennt Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic und bietet deren Instanzen beim ersten Start zum Übernehmen an -- Updates aus dem Launcher heraus laufen jetzt ohne Rückfragen durch und starten AeroMC danach von selbst neu -- Freunde: „Beitreten“ startet eine passende Instanz und verbindet direkt mit dem Server, auf dem ein Freund spielt – gibt es keine passende Instanz, wird eine angelegt -- Freunde: „Einladen“ schickt einem Freund deinen aktuellen Server – er bekommt die Einladung im Launcher und als Meldung im Spiel (Strg+Alt+J kopiert dort die Adresse) -- Freunde: Der eigene Server lässt sich in den Einstellungen vor Freunden verbergen -- Clip-Modus in der Spielaufzeichnung: läuft im Hintergrund mit, Alt+F10 speichert die letzten 30, 60 oder 90 Sekunden als MP4 -- Performance-Paket beim Anlegen einer Fabric-, Quilt- oder NeoForge-Instanz: Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling und ModernFix per Häkchen mitinstallieren -- Automatische Mod-Updates vor dem Spielstart (Optionen → Spiel → Mods) -- Spielzeit-Statistik: Gesamtspielzeit, Spielstarts, meistgespielte Instanz und Spielzeit pro Instanz auf einen Blick - -### Geändert -- Die Suche nach Updates startet jetzt sofort beim Öffnen des Launchers, die Info-Seite zeigt das Ergebnis direkt an -- Direkt einem Server beitreten funktioniert jetzt auch bei Minecraft-Versionen vor 1.20 - -### Behoben -- Die Info-Seite zeigte „Kein Update-Check ausgeführt“, obwohl der Launcher beim Start bereits gesucht hatte - -## [1.1.3] - 2026-09-29 - -### Neu -- Freundesystem: Freunde per Minecraft-Name hinzufügen, Anfragen annehmen/ablehnen, gesendete Anfragen zurückziehen, Spitznamen vergeben – mit 3D-Spielerkopf und Online-Status -- In-Game-Meldung mit 3D-Spielerkopf, sobald ein Freund online kommt – bei mehreren gleichzeitig laufenden Konten erscheint sie im jeweils richtigen Spielfenster -- Tastenkombination Strg+Alt+F zeigt im Spiel an, welche Freunde gerade online sind (inklusive Server) -- Zähler für offene Freundschaftsanfragen am "Freunde"-Knopf -- Freundesystem in den Einstellungen abschaltbar – man ist dann weder sichtbar noch als Freund hinzufügbar, der "Freunde"-Knopf wird ausgeblendet -- Neuer Reiter "Aufzeichnungen" im Screenshot-Fenster mit Vorschaubildern, Abspielen und Löschen - -### Geändert -- Aufzeichnungen werden jetzt als MP4 gespeichert und laufen damit in jedem gängigen Player -- Der Mauszeiger ist in Aufzeichnungen nicht mehr zu sehen - -### Behoben -- Aufzeichnungen ruckelten bzw. hatten eine falsche Bildrate -- Wechsel des aktiven Microsoft-Kontos wurde nicht in allen Fenstern übernommen - -## [1.1.2] - 2026-09-27 - -### Neu -- Auflösung, Qualität und Bildrate der Spielaufzeichnung einstellbar (Nativ/1080p/720p/480p, Niedrig/Mittel/Hoch, 30/60 fps) – inklusive Anzeige der tatsächlich aufgenommenen Pixelgröße -- Lautstärke jeder einzelnen Tonspur der Aufzeichnung (Systemton, Nur Minecraft, jedes Mikrofon einzeln) regelbar, auch während eine Aufnahme bereits läuft -- Video- und Audioeinstellungen der Spielaufzeichnung bleiben jetzt über einen Neustart des Launchers hinweg erhalten - -### Geändert -- Anzeige "Aufnahme läuft" samt Timer sitzt jetzt über dem Start/Stopp-Knopf statt auf dem Videobild -- Mikrofonnamen in der Audioquellen-Auswahl zeigen keine technische USB-Kennung mehr (z. B. "(9886:0038)") - -### Behoben -- Servername in der Spielaufzeichnung fehlte, wenn man sich schon vor dem Öffnen des Aufzeichnungsfensters mit dem Server verbunden hatte – wird jetzt zentral erkannt und trägt sich auch nachträglich in den Titel ein -- Vorschaubild der Aufzeichnung blieb beim ersten Öffnen manchmal schwarz – wird jetzt automatisch erkannt und neu erfasst - -## [1.0.9] - 2026-08-09 - -### Behoben -- Update-Installation brach mit "EPERM: operation not permitted, lstat …" ab, wenn vom vorherigen Versuch noch eine gesperrte Installer-Datei im Temp-Ordner lag (z. B. durch einen Virenscanner) – der Download weicht jetzt automatisch auf einen neuen Dateinamen aus -- Autostart mit Windows öffnete im Entwicklungsmodus ein leeres Electron-Fenster statt AeroMC (gleiche Ursache wie zuvor bei den Desktop-Verknüpfungen), zusätzlich zeigte der Autostart-Eintrag bei der Portable-Fassung potenziell auf einen flüchtigen Temp-Pfad -- Wiederherstellen aus einem ungültigen oder beschädigten Backup-Archiv löschte die aktuellen, unveränderten Instanzen ersatzlos, obwohl der Vorgang mit einer Fehlermeldung abgebrochen wurde – der Rollback greift jetzt nur noch, wenn am Originalbestand überhaupt schon etwas verändert wurde - -## [1.0.8] - 2026-08-08 - -### Neu -- Datenpakete lassen sich jetzt wie Mods, Ressourcenpakete und Shader direkt aus dem Modrinth-Katalog durchsuchen, installieren, aktivieren/deaktivieren und aktualisieren -- Modpacks lassen sich jetzt auch aus dem CurseForge-Katalog durchsuchen und mit einem Klick installieren (bisher nur Modrinth und Technic; CurseForge ging nur per manuellem ZIP-Import) – braucht wie die bestehende CurseForge-Suche einen eigenen API-Key -- OptiFine als eigenständige Loader-Option: Installer-Datei auswählen, AeroMC bereitet die passende Minecraft-Version vor und startet den offiziellen OptiFine-Installer sichtbar; nach Abschluss wird die neue Version automatisch übernommen -- Von Hand hinzugefügte OptiFine-Dateien im Mods-Dialog werden jetzt erkannt und eigens gekennzeichnet (funktioniert bei jedem Loader, u. a. für Fabric + OptiFabric) -- Lizenztext direkt in den Einstellungen unter Info einsehbar - -### Geändert -- Lizenzangabe von MIT auf die tatsächlich geltenden Nutzungsbedingungen umgestellt (nur privater, nicht-kommerzieller Gebrauch – siehe LICENSE) - -## [1.0.7] - 2026-08-06 - -### Neu -- Desktop-Verknüpfung pro Server: startet die Instanz und verbindet direkt mit dem hinterlegten Server (Quick Play, ab MC 1.20) -- Name der Desktop-Verknüpfung (Instanz oder Server) lässt sich beim Erstellen frei selbst vergeben - -### Behoben -- Desktop-Verknüpfungen zeigten im Entwicklungsmodus einen leeren Electron-Startbildschirm statt AeroMC zu öffnen -- Desktop-Verknüpfungen zeigten im Entwicklungsmodus das Electron-Symbol statt des AeroMC-Logos -- Desktop-Verknüpfungen (Instanz und Server) starteten bei kaltem Start unzuverlässig gar keine Instanz – der Startwunsch traf manchmal ein, bevor die Oberfläche bereit war, ihn zu empfangen, und ging dann verloren - -## [1.0.6] - 2026-08-05 - -### Neu -- Technic-Modpacks direkt durchsuchen und installieren (Katalog), oder eine vorhandene Technic-Installation importieren -- Mods lassen sich jetzt auch als eigene Datei hinzufügen, nicht nur über die Suche -- Dateien (Mods, Modpacks, Instanzen) per Drag & Drop aufs Fenster ziehen -- Tastenkürzel: Strg+F (Suche), Strg+N (neue Instanz), Entf (Instanz löschen) -- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird -- Serverliste zeigt jetzt Live-Status (Spieleranzahl, MOTD, Version) -- Ein-Klick-Knopf für empfohlene GC-Flags bei den JVM-Argumenten -- Einzelne Welten lassen sich separat schnellsichern -- Warnung, wenn ein Mod entfernt wird, den ein anderer installierter Mod noch braucht -- Skin-Hintergrund kann automatisch zwischen mehreren angemeldeten Konten durchwechseln -- Optionaler Autostart mit Windows (im Infobereich statt als Fenster) - -### Geändert -- Update-Suche läuft automatisch beim Start, Anzeige wird grün wenn alles aktuell ist - -### Behoben -- Bedrock-Start repariert (ging nach einem Windows-Update nicht mehr) und startete zwischenzeitlich doppelt -- Absturz beim Schließen des Spiels durch hängende Mod-Threads (z. B. JourneyMap-Webmap) behoben +# Changelog + +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 + +### Neu +- Instanz teilen per Code: „Instanz teilen“ in der Seitenleiste erzeugt einen Code (30 Tage gültig) – Freunde lösen ihn unter Importieren → „Per Code“ ein und bekommen Minecraft-Version, Loader sowie alle Mods, Ressourcenpakete und Shader in denselben Versionen +- Absturz-Assistent: erklärt die Ursache eines Absturzes in einfachen Worten – fehlende Abhängigkeiten, Mods für eine andere Minecraft-Version, unverträgliche oder doppelte Mods, falsches Java, zu viel oder zu wenig Arbeitsspeicher, Grafiktreiber – und behebt sie auf Wunsch mit einem Klick +- Der Absturz-Assistent meldet sich auch, wenn Minecraft schon beim Start scheitert und keinen Absturzbericht schreibt +- Nach einer Lösung lässt sich die Instanz direkt aus dem Absturz-Fenster erneut starten +- Fehlerberichte per Klick: „An M_Viper senden“ im Absturz-Assistenten schickt Bericht und Mod-Liste an den Entwickler – Windows-Benutzername und Anmeldedaten werden vorher entfernt, ohne Häkchen wird anonym gesendet +- Kurznachrichten an Freunde: kleiner Chat im Freunde-Fenster mit Schnellantworten wie „Bin in 10 Min. online“ – neue Nachrichten erscheinen im Launcher und als Meldung im Spiel +- Screenshots verschicken: mit 📤 in der Screenshot-Galerie ein Bild direkt an einen Freund schicken, er sieht es im Chat +- Schnellstart-Tasten: Lieblings-Instanzen per Rechtsklick auf Strg+1 bis Strg+5 legen und mit der Taste direkt starten +- Rechtsklick auf das AeroMC-Symbol neben der Uhr startet Schnellstart- und zuletzt gespielte Instanzen, ohne das Fenster zu öffnen +- Welten kopieren: „Kopieren nach …“ übernimmt eine Welt mit einem Klick in eine andere Instanz +- Automatische Welt-Sicherung: nach jeder Spielsitzung wird die zuletzt gespielte Welt gesichert, die letzten 5 Stände bleiben erhalten (abschaltbar unter Optionen → Backup) +- Rechtsklick-Menü in allen Textfeldern (Ausschneiden, Kopieren, Einfügen, Alles auswählen) + +### Geändert +- Freundesystem abgesichert: Anmeldung über Mojangs Sitzungsprüfung wie bei Minecraft-Servern – niemand kann sich mehr als ein anderer Spieler ausgeben, ganz ohne eigenes Passwort +- Das Symbol im Infobereich neben der Uhr ist jetzt immer da, nicht nur bei Autostart +- Der Zähler am „Freunde“-Knopf zählt jetzt offene Anfragen und ungelesene Nachrichten +- Installer: Lizenztext übersichtlich mit Überschriften und Aufzählungen, Rückfragen im Setup-Look statt Windows-Meldungsfenster, der nicht abwählbare Startmenü-Eintrag wird nicht mehr angezeigt +- Benötigt das Plugin „AeroMC Freunde“ 1.4.0 auf dem Server (Kurznachrichten, Screenshots, Fehlerberichte, Instanz teilen) + +### Behoben +- Windows-Benachrichtigungen zeigten „electron.app.AeroMC“ ohne Logo statt „AeroMC“ +- Im Import-Fenster standen „Wählen…“, „Suchen“ und „Prüfen“ unter statt neben dem Eingabefeld + +## [1.1.4] - 2026-10-02 + +### Neu +- Neuer Installer im modernen Look: Schritt-für-Schritt-Assistent mit Lizenz, Ordnerwahl und Verknüpfungen, eigene Deinstallation (Instanzen und Welten bleiben auf Wunsch erhalten) – eine vorhandene Installation wird automatisch übernommen +- Optionaler Import im Installer: erkennt Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic und bietet deren Instanzen beim ersten Start zum Übernehmen an +- Updates aus dem Launcher heraus laufen jetzt ohne Rückfragen durch und starten AeroMC danach von selbst neu +- Freunde: „Beitreten“ startet eine passende Instanz und verbindet direkt mit dem Server, auf dem ein Freund spielt – gibt es keine passende Instanz, wird eine angelegt +- Freunde: „Einladen“ schickt einem Freund deinen aktuellen Server – er bekommt die Einladung im Launcher und als Meldung im Spiel (Strg+Alt+J kopiert dort die Adresse) +- Freunde: Der eigene Server lässt sich in den Einstellungen vor Freunden verbergen +- Clip-Modus in der Spielaufzeichnung: läuft im Hintergrund mit, Alt+F10 speichert die letzten 30, 60 oder 90 Sekunden als MP4 +- Performance-Paket beim Anlegen einer Fabric-, Quilt- oder NeoForge-Instanz: Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling und ModernFix per Häkchen mitinstallieren +- Automatische Mod-Updates vor dem Spielstart (Optionen → Spiel → Mods) +- Spielzeit-Statistik: Gesamtspielzeit, Spielstarts, meistgespielte Instanz und Spielzeit pro Instanz auf einen Blick + +### Geändert +- Die Suche nach Updates startet jetzt sofort beim Öffnen des Launchers, die Info-Seite zeigt das Ergebnis direkt an +- Direkt einem Server beitreten funktioniert jetzt auch bei Minecraft-Versionen vor 1.20 + +### Behoben +- Die Info-Seite zeigte „Kein Update-Check ausgeführt“, obwohl der Launcher beim Start bereits gesucht hatte + +## [1.1.3] - 2026-09-29 + +### Neu +- Freundesystem: Freunde per Minecraft-Name hinzufügen, Anfragen annehmen/ablehnen, gesendete Anfragen zurückziehen, Spitznamen vergeben – mit 3D-Spielerkopf und Online-Status +- In-Game-Meldung mit 3D-Spielerkopf, sobald ein Freund online kommt – bei mehreren gleichzeitig laufenden Konten erscheint sie im jeweils richtigen Spielfenster +- Tastenkombination Strg+Alt+F zeigt im Spiel an, welche Freunde gerade online sind (inklusive Server) +- Zähler für offene Freundschaftsanfragen am "Freunde"-Knopf +- Freundesystem in den Einstellungen abschaltbar – man ist dann weder sichtbar noch als Freund hinzufügbar, der "Freunde"-Knopf wird ausgeblendet +- Neuer Reiter "Aufzeichnungen" im Screenshot-Fenster mit Vorschaubildern, Abspielen und Löschen + +### Geändert +- Aufzeichnungen werden jetzt als MP4 gespeichert und laufen damit in jedem gängigen Player +- Der Mauszeiger ist in Aufzeichnungen nicht mehr zu sehen + +### Behoben +- Aufzeichnungen ruckelten bzw. hatten eine falsche Bildrate +- Wechsel des aktiven Microsoft-Kontos wurde nicht in allen Fenstern übernommen + +## [1.1.2] - 2026-09-27 + +### Neu +- Auflösung, Qualität und Bildrate der Spielaufzeichnung einstellbar (Nativ/1080p/720p/480p, Niedrig/Mittel/Hoch, 30/60 fps) – inklusive Anzeige der tatsächlich aufgenommenen Pixelgröße +- Lautstärke jeder einzelnen Tonspur der Aufzeichnung (Systemton, Nur Minecraft, jedes Mikrofon einzeln) regelbar, auch während eine Aufnahme bereits läuft +- Video- und Audioeinstellungen der Spielaufzeichnung bleiben jetzt über einen Neustart des Launchers hinweg erhalten + +### Geändert +- Anzeige "Aufnahme läuft" samt Timer sitzt jetzt über dem Start/Stopp-Knopf statt auf dem Videobild +- Mikrofonnamen in der Audioquellen-Auswahl zeigen keine technische USB-Kennung mehr (z. B. "(9886:0038)") + +### Behoben +- Servername in der Spielaufzeichnung fehlte, wenn man sich schon vor dem Öffnen des Aufzeichnungsfensters mit dem Server verbunden hatte – wird jetzt zentral erkannt und trägt sich auch nachträglich in den Titel ein +- Vorschaubild der Aufzeichnung blieb beim ersten Öffnen manchmal schwarz – wird jetzt automatisch erkannt und neu erfasst + +## [1.0.9] - 2026-08-09 + +### Behoben +- Update-Installation brach mit "EPERM: operation not permitted, lstat …" ab, wenn vom vorherigen Versuch noch eine gesperrte Installer-Datei im Temp-Ordner lag (z. B. durch einen Virenscanner) – der Download weicht jetzt automatisch auf einen neuen Dateinamen aus +- Autostart mit Windows öffnete im Entwicklungsmodus ein leeres Electron-Fenster statt AeroMC (gleiche Ursache wie zuvor bei den Desktop-Verknüpfungen), zusätzlich zeigte der Autostart-Eintrag bei der Portable-Fassung potenziell auf einen flüchtigen Temp-Pfad +- Wiederherstellen aus einem ungültigen oder beschädigten Backup-Archiv löschte die aktuellen, unveränderten Instanzen ersatzlos, obwohl der Vorgang mit einer Fehlermeldung abgebrochen wurde – der Rollback greift jetzt nur noch, wenn am Originalbestand überhaupt schon etwas verändert wurde + +## [1.0.8] - 2026-08-08 + +### Neu +- Datenpakete lassen sich jetzt wie Mods, Ressourcenpakete und Shader direkt aus dem Modrinth-Katalog durchsuchen, installieren, aktivieren/deaktivieren und aktualisieren +- Modpacks lassen sich jetzt auch aus dem CurseForge-Katalog durchsuchen und mit einem Klick installieren (bisher nur Modrinth und Technic; CurseForge ging nur per manuellem ZIP-Import) – braucht wie die bestehende CurseForge-Suche einen eigenen API-Key +- OptiFine als eigenständige Loader-Option: Installer-Datei auswählen, AeroMC bereitet die passende Minecraft-Version vor und startet den offiziellen OptiFine-Installer sichtbar; nach Abschluss wird die neue Version automatisch übernommen +- Von Hand hinzugefügte OptiFine-Dateien im Mods-Dialog werden jetzt erkannt und eigens gekennzeichnet (funktioniert bei jedem Loader, u. a. für Fabric + OptiFabric) +- Lizenztext direkt in den Einstellungen unter Info einsehbar + +### Geändert +- Lizenzangabe von MIT auf die tatsächlich geltenden Nutzungsbedingungen umgestellt (nur privater, nicht-kommerzieller Gebrauch – siehe LICENSE) + +## [1.0.7] - 2026-08-06 + +### Neu +- Desktop-Verknüpfung pro Server: startet die Instanz und verbindet direkt mit dem hinterlegten Server (Quick Play, ab MC 1.20) +- Name der Desktop-Verknüpfung (Instanz oder Server) lässt sich beim Erstellen frei selbst vergeben + +### Behoben +- Desktop-Verknüpfungen zeigten im Entwicklungsmodus einen leeren Electron-Startbildschirm statt AeroMC zu öffnen +- Desktop-Verknüpfungen zeigten im Entwicklungsmodus das Electron-Symbol statt des AeroMC-Logos +- Desktop-Verknüpfungen (Instanz und Server) starteten bei kaltem Start unzuverlässig gar keine Instanz – der Startwunsch traf manchmal ein, bevor die Oberfläche bereit war, ihn zu empfangen, und ging dann verloren + +## [1.0.6] - 2026-08-05 + +### Neu +- Technic-Modpacks direkt durchsuchen und installieren (Katalog), oder eine vorhandene Technic-Installation importieren +- Mods lassen sich jetzt auch als eigene Datei hinzufügen, nicht nur über die Suche +- Dateien (Mods, Modpacks, Instanzen) per Drag & Drop aufs Fenster ziehen +- Tastenkürzel: Strg+F (Suche), Strg+N (neue Instanz), Entf (Instanz löschen) +- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird +- Serverliste zeigt jetzt Live-Status (Spieleranzahl, MOTD, Version) +- Ein-Klick-Knopf für empfohlene GC-Flags bei den JVM-Argumenten +- Einzelne Welten lassen sich separat schnellsichern +- Warnung, wenn ein Mod entfernt wird, den ein anderer installierter Mod noch braucht +- Skin-Hintergrund kann automatisch zwischen mehreren angemeldeten Konten durchwechseln +- Optionaler Autostart mit Windows (im Infobereich statt als Fenster) + +### Geändert +- Update-Suche läuft automatisch beim Start, Anzeige wird grün wenn alles aktuell ist + +### Behoben +- Bedrock-Start repariert (ging nach einem Windows-Update nicht mehr) und startete zwischenzeitlich doppelt +- Absturz beim Schließen des Spiels durch hängende Mod-Threads (z. B. JourneyMap-Webmap) behoben diff --git a/src/mrimport.js b/src/mrimport.js new file mode 100644 index 0000000..e1339a9 --- /dev/null +++ b/src/mrimport.js @@ -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\", 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\\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 }; diff --git a/src/preload.js b/src/preload.js index 3b5cf3e..c95c742 100644 --- a/src/preload.js +++ b/src/preload.js @@ -154,6 +154,11 @@ contextBridge.exposeInMainWorld('api', { skinChoose: () => ipcRenderer.invoke('skin:choose'), skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante), 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), authLogin: () => ipcRenderer.invoke('auth:login'), authLogout: () => ipcRenderer.invoke('auth:logout'), @@ -192,6 +197,14 @@ contextBridge.exposeInMainWorld('api', { return () => ipcRenderer.removeListener('mmc:progress', l); }, 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), cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData), onCfProgress: (cb) => { @@ -269,6 +282,7 @@ contextBridge.exposeInMainWorld('api', { // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params), popoutHide: () => ipcRenderer.send('popout:hide'), + setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design), onPopoutReopen: (cb) => { const l = (_e, params) => cb(params); ipcRenderer.on('popout:reopen', l); @@ -285,7 +299,7 @@ contextBridge.exposeInMainWorld('api', { return () => ipcRenderer.removeListener('instances:changed', l); }, onSkinChanged: (cb) => { - const l = () => cb(); + const l = (_e, info) => cb(info || {}); ipcRenderer.on('skin:changed', l); return () => ipcRenderer.removeListener('skin:changed', l); }, diff --git a/src/renderer.js b/src/renderer.js index 2872c29..340d099 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -537,13 +537,23 @@ function escapeHtml(s) { } 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) { - 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 ---------- let mmcFound = []; let cfFound = []; +let mrFound = []; let mcFound = []; let technicFound = []; let importKind = 'zip'; @@ -568,8 +578,8 @@ async function ensureDefaultMcScan() { } function setImportKind(kind) { - importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip'; - ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].forEach((entryKind) => { + importKind = ['zip', 'modpack', 'mc', 'cf', 'mr', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip'; + ['zip', 'modpack', 'mc', 'cf', 'mr', 'mmc', 'technic', 'code'].forEach((entryKind) => { el(`import-tab-${entryKind}`).classList.toggle('on', 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('mc-do').classList.toggle('hidden', importKind !== 'mc'); 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('technic-do').classList.toggle('hidden', importKind !== 'technic'); el('code-do').classList.toggle('hidden', importKind !== 'code'); 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('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('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').classList.add('hidden'); 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-do').disabled = true; el('mmc-progress').textContent = ''; @@ -613,6 +631,8 @@ function openImportDialog(kind = 'zip', vorauswahl = null) { ensureDefaultMcScan(); } 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 (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. */ 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]); for (const q of quellen) { if (!q || !scanner[q.kind]) continue; @@ -923,6 +943,82 @@ async function runCfImport() { 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 = '
Keine Instanzen gefunden.
'; 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 = + ` +
+
${escapeHtml(inst.name)}
+
${escapeHtml(meta)}
+
`; + 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() { const folder = el('mmc-path').value.trim(); 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), // damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist. // 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; } // 2) Rückfall: externe Render-Dienste @@ -1630,7 +1730,7 @@ const ICONS_SVG = { terminal: '', user: '', users: '', - settings: '', + settings: '', // Zahnrad (8 Zähne) play: '', blocks: '', globe: '', @@ -4790,7 +4890,8 @@ async function openSettings(pane) { window.api.updateLastCheck().then(renderUpdateStatus).catch(() => {}); 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-post').value = s.globalPostExit || ''; el('s-wrapper').value = s.wrapperCommand || ''; @@ -4806,6 +4907,7 @@ async function openSettings(pane) { } sel.value = s.bgSkin || ''; 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-seconds').value = s.bgSkinCycleSeconds || 30; sel.disabled = !!s.bgSkinCycle; @@ -4939,6 +5041,7 @@ async function saveSettings() { globalPostExit: el('s-global-post').value.trim(), bgSkin: el('s-bgskin').value, bgSkinPose: el('s-bgskin-pose').value, + bgSkinModel: el('s-bgskin-model').value, bgSkinCycle: el('s-bgskin-cycle').checked, bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30), sortBy: el('s-sortby').value, @@ -5471,6 +5574,9 @@ function wire() { }); document.addEventListener('scroll', closeContextMenu, true); 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 el('status-auth').addEventListener('click', async () => { @@ -5530,6 +5636,18 @@ function wire() { }); el('cf-list').addEventListener('change', updateCfButton); 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 () => { const p = await window.api.mmcPickFolder(); 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').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) => { el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('mmc-progress').classList.remove('hidden'); @@ -5804,6 +5926,10 @@ function wire() { el('skin-pick').addEventListener('click', waehleSkinDatei); el('skin-variante').addEventListener('change', skinVarianteGewechselt); 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('logs-select').addEventListener('change', (e) => ladeLog(e.target.value)); el('logs-folder').addEventListener('click', async () => { @@ -5986,6 +6112,11 @@ function wire() { if (p) { el('s-java').value = p; await refreshJavaStatus(p); } }); 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-val').textContent = e.target.value; document.documentElement.style.setProperty('--bg-skin-opacity', String(e.target.value / 100)); @@ -5995,11 +6126,13 @@ function wire() { await window.api.saveSettings({ bgSkin: el('s-bgskin').value, bgSkinPose: el('s-bgskin-pose').value, + bgSkinModel: el('s-bgskin-model').value, }); await refreshSkinBackground(); }; el('s-bgskin').addEventListener('change', previewSkin); el('s-bgskin-pose').addEventListener('change', previewSkin); + el('s-bgskin-model').addEventListener('change', previewSkin); const previewSkinCycle = async () => { el('s-bgskin').disabled = el('s-bgskin-cycle').checked; await window.api.saveSettings({ @@ -6240,6 +6373,7 @@ const POPOUT_OPENERS = { screens: () => openScreens(), servers: () => openServers(), skin: () => openSkin(), + skineditor: (p) => window.openSkinEditor(p), bedrock: () => openBedrock(), login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }), import: (p) => { @@ -6398,7 +6532,11 @@ window.addEventListener('DOMContentLoaded', async () => { // Ein Skin-Wechsel oder eine neue/aktualisierte Instanz aus einem anderen // Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im // 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(); }); try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ } await loadMcIcons(); // echte Minecraft-Icons bereitstellen diff --git a/src/skineditor.js b/src/skineditor.js new file mode 100644 index 0000000..b92a1ce --- /dev/null +++ b/src/skineditor.js @@ -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 }; +})(); diff --git a/src/store.js b/src/store.js index c6ce6b8..2de0f7e 100644 --- a/src/store.js +++ b/src/store.js @@ -87,6 +87,7 @@ const DEFAULT_SETTINGS = { bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % 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 bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt diff --git a/src/styles.css b/src/styles.css index e1ae506..aebec80 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,642 +1,706 @@ -/* AeroMC – heller MultiMC-Native-Look */ -:root{ - --win:#f0f0f0; --area:#e2e2e2; --bar:#e9eaed; - --border:#c4c7cc; --border2:#d6d8dc; - --text:#1c1c1c; --dim:#6b7280; - --sel:#dce8f6; --sel-br:#3a6ea5; --link:#2c5aa0; --accent:#3a6ea5; - --danger:#c0392b; --play:#3a9c3a; - --font:"Segoe UI",Tahoma,system-ui,sans-serif; - --mono:Consolas,"Cascadia Mono",monospace; -} -*{box-sizing:border-box} -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} -.hidden{display:none!important} -.mono{font-family:var(--mono);font-size:12px} -.spacer{flex:1} -.opt,.hint{color:var(--dim)} -[data-ic] svg,.ic svg{width:16px;height:16px} - -/* ===================== DUNKLER MODUS (native, dunkelgrau) ===================== */ -:root[data-theme="dark"]{ - --win:#2d2d30; --area:#242426; --bar:#333337; - --border:#3f3f46; --border2:#3a3a40; - --text:#e4e4e6; --dim:#9a9aa2; - --sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb; - --danger:#e06c6c; --play:#5cb85c; - --input:#1e1e1e; -} -:root[data-theme="dark"] body{background:var(--win)} -:root[data-theme="dark"] .search-wrap input, -:root[data-theme="dark"] .field input, -:root[data-theme="dark"] .field select, -:root[data-theme="dark"] .field textarea, -:root[data-theme="dark"] .mods-search input, -:root[data-theme="dark"] .path-row input, -:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)} -:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618} -:root[data-theme="dark"] .modal-head{background:#333337} -:root[data-theme="dark"] .modal-body{background:#2d2d30} -:root[data-theme="dark"] .modal-foot{background:#2a2a2e} -:root[data-theme="dark"] .advanced{background:#242426} -:root[data-theme="dark"] .mods-list, -:root[data-theme="dark"] .account-item, -:root[data-theme="dark"] .screen-thumb{background:#1e1e1e} -:root[data-theme="dark"] .mod-icon, -:root[data-theme="dark"] .account-av, -:root[data-theme="dark"] .icon-preview{background:#3a3a40} -:root[data-theme="dark"] .mod-item+.mod-item{border-color:#333} -:root[data-theme="dark"] .mod-item:hover{background:#2a2f36} -:root[data-theme="dark"] .btn{background:linear-gradient(#3b3b40,#303034);color:var(--text);border-color:var(--border)} -:root[data-theme="dark"] .btn:hover{background:linear-gradient(#44444b,#35353b);border-color:#5a5a63} -:root[data-theme="dark"] .btn:active{background:#2a2a2e} -:root[data-theme="dark"] .btn-primary{background:linear-gradient(#2a7ac0,#1f66a8);border-color:#1a5a95;color:#fff} -:root[data-theme="dark"] .btn-primary:hover{background:linear-gradient(#3286cc,#2470b4)} -:root[data-theme="dark"] .btn-danger{color:#e88;border-color:#6a3a3a} -:root[data-theme="dark"] .btn-danger:hover{background:#3a2626} -:root[data-theme="dark"] .tbtn:hover, -:root[data-theme="dark"] .sbtn:hover, -:root[data-theme="dark"] .card:hover{background:#37373d;border-color:var(--border)} -:root[data-theme="dark"] .card.sel{background:#0e639c;border-color:#1177bb} -:root[data-theme="dark"] .card.sel .card-name{background:#1177bb;color:#fff} -:root[data-theme="dark"] .st-row2{background:#2a2a2e} -:root[data-theme="dark"] .st-row, -:root[data-theme="dark"] .group-header, -:root[data-theme="dark"] .empty h2, -:root[data-theme="dark"] .mods-col h4, -:root[data-theme="dark"] .field>span{color:var(--text)} -:root[data-theme="dark"] #btn-console svg{color:#c8c8cc} -:root[data-theme="dark"] .acct-av svg{color:#c8c8cc} -:root[data-theme="dark"] .mods-filters select, -:root[data-theme="dark"] .mods-search select, -:root[data-theme="dark"] .mmc-bar select{background:#1e1e1e;color:var(--text);border-color:var(--border)} -:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)} -:root[data-theme="dark"] .modal-close{color:#aaa} -:root[data-theme="dark"] .modal-close:hover{color:#fff} -:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)} - -/* ---------- Toolbar ---------- */ -.toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;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:hover{background:#e2e6ec;border-color:var(--border2)} -.tbtn:active{background:#d7dde5} -.tbtn svg{width:18px;height:18px;color:#4b5563} -/* farbige Toolbar-Icons wie in MultiMC */ -#btn-new svg{color:#3a9c3a} -#btn-import svg{color:#3d7ab5} -#btn-open-dir svg{color:#d8a72f} -#btn-console svg{color:#5a6470} -#btn-account svg{color:#3a9c3a} -#btn-settings svg{color:#3d7ab5} -.tbtn.big{font-size:13px;padding:8px 16px;border:1px solid var(--border);background:linear-gradient(#fdfdfd,#e9e9e9)} -.tbsep{width:1px;height:24px;background:var(--border);margin:0 5px} -.tbgrow{flex:1} -.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px} -.search-wrap input:focus{outline:none;border-color:var(--accent)} - -/* ---------- Arbeitsbereich ---------- */ -.workspace{display:grid;grid-template-columns:1fr 172px;overflow:hidden;background:var(--area)} -.list-area{position:relative;overflow:hidden;min-height:0} -/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */ -.list-area::after{ - content:'';position:absolute;right:24px;bottom:8px;width:52%;height:88%; - background-image:var(--bg-skin,none);background-repeat:no-repeat; - background-position:right bottom;background-size:contain; - opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0; -} -.list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px} -.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px} -.side-empty{text-align:center;color:var(--dim);padding:24px 8px} -.side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px} -.side-ico img{width:56px;height:56px;object-fit:cover;border-radius:3px} -.side-title{text-align:center;font-weight:600;font-size:14px;word-break:break-word} -.side-grp{text-align:center;color:var(--link);font-size:11px;margin-top:2px;cursor:pointer} -.side-grp:hover{text-decoration:underline} -.hr{height:1px;background:var(--border);margin:6px 2px} -.sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)} -.sbtn:hover{background:#e2e6ec;border-color:var(--border2)} -.sbtn:disabled{opacity:.5;cursor:default} -.sbtn:disabled:hover{background:none;border-color:transparent} -.sbtn.play{font-weight:600} -.sbtn.del:hover{color:var(--danger)} - -/* ---------- Instanzliste / Gruppen / Kacheln ---------- */ -.group-section{margin-bottom:2px} -.group-header{display:flex;align-items:center;gap:7px;padding:5px 4px 2px;color:#333;font-weight:600;font-size:12px;cursor:pointer;user-select:none} -.group-header::after{content:'';flex:1;height:1px;background:var(--border);margin-left:6px} -.group-header .chev{font-size:9px;color:#888;width:10px;text-align:center} -.group-header.collapsed .chev{transform:none} -.group-count{color:var(--dim);font-weight:400} -.group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start} -.group-section.collapsed .group-grid{display:none} -.card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative} -.card:hover{background:#e3ecf7} -.card.sel{background:var(--sel);border-color:var(--sel-br)} -.card-icon{width:60px;height:60px;display:grid;place-items:center;font-size:40px} -.card-icon svg,.side-ico svg{display:block} -.mc-texture-icon{display:block;object-fit:contain;image-rendering:auto;filter:drop-shadow(0 1px 1px rgba(0,0,0,.18))} -.card-icon-img{width:60px;height:60px;object-fit:cover;border-radius:3px} -.card-name{width:100%;max-width:88px;box-sizing:border-box;font-size:12px;text-align:center;line-height:1.25;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.card.sel .card-name{background:var(--sel-br);color:#fff;border-radius:2px} -.card-badges{display:none} -.card-play{display:none} -.group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px} -.card.dragging{opacity:.4} - -/* Datei von außen übers Fenster gezogen (.jar/.mrpack/.zip) */ -body.file-drop-active::after{ - content:"Datei loslassen zum Importieren"; - position:fixed;inset:0;z-index:9999; - display:flex;align-items:center;justify-content:center; - background:rgba(0,0,0,.55); - color:#fff;font-size:20px;font-weight:600; - border:3px dashed var(--accent); - pointer-events:none; -} - -/* ---------- Leerer Zustand ---------- */ -.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px} -.empty h2{color:#333;margin:0} -.empty p{max-width:340px;margin:0 0 8px} - -/* ---------- Statusleiste (zwei Zeilen) ---------- */ -.statusbar{display:flex;flex-direction:column;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-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444} -.auth-warn{ - display:inline-flex;align-items:center;gap:6px; - color:#fff;background:var(--danger); - padding:1px 10px;border-radius:3px;font-weight:600;cursor:pointer; - animation:authpulse 2s ease-in-out infinite; -} -.auth-warn::before{content:'⚠';font-size:12px} -.auth-warn:hover{filter:brightness(1.12)} -@keyframes authpulse{0%,100%{opacity:1}50%{opacity:.72}} - -/* ---------- Modals ---------- */ -.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50} -/* Bestätigungsdialog immer über anderen Dialogen (z. B. Screenshots) */ -#modal-confirm,#modal-prompt{z-index:120} -.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden} -.modal-sm{width:420px}.modal-lg{width:820px} -#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)} -.modal-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#e8e8e8;border-bottom:1px solid var(--border)} -.modal-head h3{margin:0;font-size:14px;font-weight:600} -.modal-close{background:none;border:none;color:#666;font-size:15px;cursor:pointer;line-height:1} -.modal-close:hover{color:#000} -.modal-body{padding:16px;overflow:auto;background:#f6f6f6} -.modal-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:#efefef} - -/* Popup-Fenster (Einstellungen/Konsole): Haupt-UI ausblenden, das eine Modal - füllt das ganze (rahmenlose) Fenster; der eigene Kopfbereich ersetzt die - native Titelleiste – per -webkit-app-region per Maus verschiebbar */ -body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>footer.statusbar{display:none} -.popout-host{position:static;inset:auto;background:none;display:block} -/* !important: schlägt auch die spezifischere #modal-settings-Breitenregel oben */ -.popout-host .modal-card{width:100%!important;max-width:none!important;height:100vh!important;max-height:none!important;border:none;border-radius:0;box-shadow:none;margin:0} -/* Inhalt soll die restliche Fensterhöhe füllen (scrollen statt Leerraum unter dem Fuß) */ -.popout-host .modal-body{flex:1 1 auto;min-height:0} -/* Konsole/Logs/Screenshots haben einen einzelnen scrollbaren Kasten mit - fester Höhe (für den normalen Dialog gedacht) – im Popup-Fenster soll der - stattdessen die volle verbleibende Höhe ausfüllen statt Leerraum darunter - zu lassen */ -#modal-console.popout-host .modal-body, -#modal-logs.popout-host .modal-body, -#modal-screens.popout-host .modal-body, -#modal-servers.popout-host .modal-body, -#modal-mods.popout-host .modal-body, -#modal-record.popout-host .modal-body{display:flex;flex-direction:column} -.popout-host .console-out, -#modal-logs.popout-host .crash-voll, -.popout-host .mods-list, -.popout-host .record-stage{flex:1 1 auto;height:auto;max-height:none;min-height:0} -/* Screenshots/Aufzeichnungen NICHT auf die volle Fensterhöhe zwingen (Grid- - Zeilen strecken sich sonst nicht mit, es bliebe bei wenigen Einträgen ein - großer sichtbar leerer Kasten stehen) - nur die feste 460px-Deckelung - aufheben, damit bei VIELEN Einträgen trotzdem mehr Platz genutzt wird */ -.popout-host .screens-grid{max-height:none} -/* Mods-Fenster: die aktive Ansicht (Katalog/Installiert) muss selbst ein - Flex-Container werden, damit ihre eigene Liste (.mods-list) die Regel oben - nutzen und den restlichen Platz füllen kann */ -#modal-mods.popout-host #m-pane-catalog, -#modal-mods.popout-host #m-pane-installed{display:flex;flex-direction:column;flex:1 1 auto;min-height:0} -.popout-host .modal-head{-webkit-app-region:drag;cursor:default} -.popout-host .modal-head button,.popout-host .modal-head input,.popout-host .modal-head select{-webkit-app-region:no-drag} - -/* Formular */ -.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px} -.field>span{font-size:12px;color:#333} -.field input,.field select,.field textarea{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px} -.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)} -.field textarea{resize:vertical} -.field-row{display:flex;gap:12px;flex-wrap:wrap}.field-row .field{flex:1 1 0;min-width:0} -.field-icon{max-width:210px;min-width:0} -.backup-select-field{margin-top:14px;margin-bottom:0} -.backup-select-field select{width:100%} -.hint{font-size:12px} -.path-row{display:flex;gap:8px} -.path-row input{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px} -.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb} -.advanced legend{color:var(--dim);font-size:12px;padding:0 6px} -.version-row{display:flex;gap:8px}.version-row #f-version-type{max-width:120px}.version-row #f-version{flex:1} -.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:3px;margin-top:6px; - width:100%;max-width:100%;max-height:132px;overflow-y:auto;overflow-x:hidden;box-sizing:border-box} -.icon-choice{display:flex;align-items:center;justify-content:center;padding:2px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;min-width:0;overflow:hidden} -.icon-choice img{width:24px;height:24px;display:block;object-fit:contain;image-rendering:auto} -.icon-choice:hover{background:rgba(128,128,128,.18)} -.icon-choice.on{border-color:var(--accent);background:var(--sel)} -.icon-choice svg{display:block} -.icon-custom{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap} -.icon-preview{width:34px;height:34px;border-radius:3px;overflow:hidden;background:#e4e4e4;border:1px solid var(--border)} -.icon-preview img{width:100%;height:100%;object-fit:cover} -.appinfo{margin-top:8px;font-size:12px;color:var(--dim);line-height:1.6} -.about-brand{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;margin:0 0 18px;padding:6px 0 14px;border-bottom:1px solid var(--border2);text-align:center} -.about-logo{width:56px;height:56px;border-radius:12px} -.about-name{font-size:18px;font-weight:600} - -/* Buttons in Dialogen */ -.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:3px;background:linear-gradient(#fdfdfd,#e9e9e9);color:var(--text);cursor:pointer;font-family:inherit;font-size:12px} -.btn:hover{background:linear-gradient(#ffffff,#e2e2e2);border-color:#9aa0a8} -.btn:active{background:#dcdcdc} -.btn:disabled{opacity:.5;cursor:default} -.btn svg{width:15px;height:15px} -.btn-primary{background:linear-gradient(#5a9be0,#3a7bc8);border-color:#2f6bb0;color:#fff} -.btn-primary:hover{background:linear-gradient(#69a8ea,#3f83d2)} -.btn-danger{color:var(--danger);border-color:#cbb0ac} -.btn-danger:hover{background:#f7e9e7;border-color:#c0392b} -.btn-lg{padding:9px 18px;font-size:13px} -.btn-mini{padding:4px 8px;font-size:11px} - -/* Badges (dezent) */ -.badge{font-size:10px;padding:1px 6px;border-radius:2px;background:#e2e2e2;color:#555;border:1px solid var(--border)} - -/* Detail-Kopf (Mods/Login) */ -.detail-head{display:flex;align-items:center;gap:12px} -.detail-icon{font-size:28px} -.detail-sub{color:var(--dim);font-size:12px;margin-top:2px} - -/* Mods */ -.mods-body{min-height:420px} -.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px} -.mods-search{display:flex;gap:8px;margin-bottom:10px;align-items:stretch} -.mods-search select{ - flex:0 0 auto;min-width:140px;max-width:180px; - padding:7px 10px;border:1px solid var(--border);border-radius:3px; - background:var(--input,#fff);color:var(--text); - font-family:inherit;font-size:12px; -} -.mods-search select:focus{outline:none;border-color:var(--accent)} -.mods-filters{display:flex;gap:8px;margin-bottom:12px} -.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} -.mods-filters select:focus{outline:none;border-color:var(--accent)} -#m-more{display:block;width:100%;margin-top:8px} -.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)} -.mods-search input:focus{outline:none;border-color:var(--accent)} -.mods-columns{display:grid;grid-template-columns:1fr 1fr;gap:14px} -.mods-col h4{margin:0 0 8px;font-size:12px;color:#333;font-weight:600;display:flex;align-items:center;gap:6px} -.mods-tabs{display:flex;gap:2px;margin-bottom:10px;border-bottom:1px solid var(--border)} -.mtab{padding:7px 14px;border:1px solid transparent;border-bottom:none;border-radius:3px 3px 0 0;background:none;color:var(--dim);cursor:pointer;font-family:inherit;font-size:12px;margin-bottom:-1px} -.mtab:hover{color:var(--text)} -.mtab.on{color:var(--text);background:var(--bar);border-color:var(--border);font-weight:600} -.mods-list{height:340px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:3px;background:#fff;padding:6px} -.mods-empty{color:var(--dim);text-align:center;padding:24px 8px;font-size:12px} -.mod-item{display:flex;gap:10px;align-items:center;padding:8px;border-radius:3px;max-width:100%;overflow:hidden} -.mod-item.clickable{cursor:pointer} -.mod-item.installing{opacity:.6} -.mod-item+.mod-item{border-top:1px solid #eee} -.mod-item:hover{background:#f0f4f8} -.mod-icon{width:38px;height:38px;border-radius:3px;flex:none;background:#e4e4e4;object-fit:cover;display:flex;align-items:center;justify-content:center;font-size:18px} -.mod-info{flex:1;min-width:0} -.mod-name{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.mod-desc{font-size:12px;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} -.mod-meta{font-size:11px;color:var(--dim);margin-top:2px} -.mod-new{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#2f7d32;color:#fff;vertical-align:middle} -.mod-off{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#6b6b6b;color:#fff;vertical-align:middle} -.crash-kern{margin:10px 0;padding:10px 12px;border-left:3px solid #c0392b;background:rgba(192,57,43,.08);border-radius:0 4px 4px 0} -.crash-zeile{display:flex;gap:8px;margin-bottom:5px;font-size:12px;line-height:1.45} -.crash-zeile:last-child{margin-bottom:0} -.crash-titel{flex:0 0 132px;color:var(--dim)} -.crash-wert{flex:1;word-break:break-word} -.crash-verlauf{margin:0;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--dim);white-space:pre-wrap;word-break:break-all} -.skin-vorschau{display:flex;gap:14px;margin-top:12px} -.skin-spalte{flex:1;display:flex;flex-direction:column;gap:4px;min-width:0} -.skin-label{font-size:11px;color:var(--dim)} -.skin-bild{height:300px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,.15);padding:6px;text-align:center;overflow:hidden} -/* Die gerenderte Figur soll das Feld ausfüllen; eine rohe 64x64-Textur wird - ohne Weichzeichnen vergrößert, damit sie nicht verschwommen wirkt. */ -.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated} -.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab} -.skin-bild canvas:active{cursor:grabbing} -.crash-details{margin-top:12px} -.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)} -.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word} -.mod-aus .mod-name,.mod-aus .mod-meta{opacity:.55} -.mod-aus .mod-icon{filter:grayscale(1);opacity:.6} -.mod-action{flex:none} - -/* Import */ -.import-pane{min-height:180px} -.import-copy{margin:0 0 10px;font-size:13px;line-height:1.5} - -/* MultiMC-Import */ -.mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px} -.mmc-bar select{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} -.mmc-bar select:focus{outline:none;border-color:var(--accent)} -.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px} -.mmc-item+.mmc-item{border-top:1px solid var(--border2)} -.mmc-item:hover{background:rgba(128,128,128,.10)} -.mmc-item input{width:16px;height:16px;flex:none} -.mmc-name{font-weight:600;font-size:13px} -.mmc-meta{font-size:11px;color:var(--dim)} - -/* Bedrock */ -.bd-actions{display:flex;gap:8px;align-items:center;margin-bottom:14px} -.bd-meta{font-size:11px;color:var(--dim);margin-top:2px} - -/* Login / Konten */ -.login-code{text-align:center} -.login-link{color:var(--link);font-size:15px;display:inline-block;margin-bottom:12px;cursor:pointer;text-decoration:none} -.login-link:hover{text-decoration:underline} -.login-usercode{font-family:var(--mono);font-size:28px;font-weight:700;letter-spacing:3px;color:#222;background:#fff;border:1px solid var(--border);border-radius:4px;padding:12px;margin:6px 0 12px} -.login-error{color:var(--danger)!important;font-weight:600} -.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:10px} -.account-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:3px;background:#fff;border:1px solid var(--border)} -.account-item.active{border-color:var(--accent)} -.account-av{width:34px;height:34px;border-radius:3px;flex:none;background:#e4e4e4;display:flex;align-items:center;justify-content:center;font-size:18px} -img.account-av{object-fit:cover;image-rendering:auto} -.account-main{flex:1;min-width:0} -.account-name{font-weight:600} -.account-tag{font-size:11px;color:var(--link)} -.account-actions{display:flex;gap:6px} -.account-empty{color:var(--dim);text-align:center;padding:10px} - -/* Screenshots */ -.screens-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;max-height:460px;overflow:auto} -.screen-thumb{border:1px solid var(--border);border-radius:3px;overflow:hidden;background:#fff;cursor:pointer;position:relative} -.screen-thumb img{width:100%;height:100px;object-fit:cover;display:block} -.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer} -.screen-thumb:hover .screen-del{opacity:1} -.screen-thumb.selected{outline:2px solid var(--sel-br);outline-offset:1px;background:var(--sel)} -.screen-thumb .screen-check{position:absolute;top:4px;left:4px;width:18px;height:18px;margin:0;cursor:pointer;z-index:2} -.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap} -.screens-toolbar .hint{font-size:11px} -.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1} -.record-thumb-img-wrap{position:relative} -.record-thumb-img-wrap img{background:#000} -.record-thumb-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;text-shadow:0 1px 4px rgba(0,0,0,.7);pointer-events:none} -.record-thumb-size{font-size:10px;color:var(--dim);padding:0 6px 4px} - -/* Freunde */ -.tb-badge{display:inline-block;min-width:16px;height:16px;padding:0 4px;margin-left:6px;border-radius:8px;background:#d9453c;color:#fff;font-size:11px;font-weight:600;line-height:16px;text-align:center;box-sizing:border-box} -#modal-friends .field-row{margin-bottom:14px} -#modal-friends .field-row input{flex:1} -/* ---- Spielzeit-Statistik: Kennzahlen + Balken pro Instanz ---- */ -:root{--stat-bar:#3a6ea5} -:root[data-theme="dark"]{--stat-bar:#3d8fe0} -.stats-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:6px} -.stat-tile{border:1px solid var(--border);border-radius:4px;padding:8px 10px;min-width:0} -.stat-tile .stat-label{font-size:11px;color:var(--dim)} -.stat-tile .stat-value{font-size:18px;font-weight:600;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.stat-tile .stat-sub{font-size:11px;color:var(--dim);line-height:1.35} -.stats-list{display:flex;flex-direction:column;gap:2px} -.stat-row{display:grid;grid-template-columns:minmax(0,38%) 1fr auto;align-items:center;gap:10px;padding:5px 4px;border-radius:3px} -.stat-row:hover{background:var(--sel)} -.stat-name{min-width:0} -.stat-name b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.stat-name span{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block} -.stat-track{height:8px} -.stat-bar{height:8px;background:var(--stat-bar);border-radius:0 4px 4px 0;min-width:2px} -.stat-value-cell{font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;min-width:92px} -.popout-host .stats-list{max-height:none} - -/* Clip-Modus-Leiste im Aufzeichnungsfenster */ -.clip-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px;border:1px solid var(--border);border-radius:4px;background:var(--area)} -.clip-bar.aktiv{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)} -.clip-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;user-select:none} -.clip-switch input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none} -.clip-switch-track{position:relative;width:32px;height:18px;border-radius:9px;background:var(--border);transition:background .15s} -.clip-switch-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.35);transition:left .15s} -.clip-switch input:checked + .clip-switch-track{background:var(--accent)} -.clip-switch input:checked + .clip-switch-track .clip-switch-knob{left:16px} -.clip-switch input:focus-visible + .clip-switch-track{outline:2px solid var(--accent);outline-offset:2px} -.clip-length{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)} -.clip-length select{padding:3px 6px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} -.clip-length select:disabled{opacity:.6} -.clip-status{flex:1;min-width:200px;font-size:12px;color:var(--dim)} -.clip-bar.aktiv .clip-status{color:var(--text)} -.clip-bar.aktiv .clip-status::before{content:"";display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--danger);vertical-align:1px;animation:rec-pulse 1.6s infinite} - -/* ---- Absturz-Assistent ---- */ -.crash-hilfe{display:flex;flex-direction:column;gap:8px;margin-bottom:12px} -.crash-hilfe-titel{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em} -.crash-fund{display:flex;gap:12px;align-items:flex-start;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;background:var(--area)} -.crash-fund-text{flex:1;min-width:0} -.crash-fund-text b{display:block;font-size:13px;margin-bottom:3px} -.crash-fund-text span{font-size:12px;color:var(--dim);line-height:1.45} -.crash-fund .btn{flex:0 0 auto;align-self:center} -.crash-fund.erledigt{border-left-color:var(--play)} -.crash-unklar{padding:10px 12px;border:1px dashed var(--border);border-radius:4px;font-size:12px;color:var(--dim)} - -/* ---- Instanz teilen ---- */ -.share-code-box{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border:1px solid var(--accent);border-radius:4px;background:var(--area);margin:10px 0} -.share-code{font-family:var(--mono);font-size:24px;font-weight:600;letter-spacing:.12em;user-select:all} -.share-warn{color:var(--danger)} -.share-code-input{text-transform:uppercase;letter-spacing:.1em;font-family:var(--mono)} -.share-preview{margin-top:12px;padding:12px 14px;border:1px solid var(--border);border-radius:4px;background:var(--area)} -.share-preview b{font-size:14px} -.share-preview .hint{margin:4px 0 0} - -/* ---- Kurznachrichten ---- */ -.friends-chat{display:flex;flex-direction:column;gap:8px;height:100%;min-height:340px} -.chat-head{display:flex;align-items:center;gap:10px} -.chat-head b{font-size:13px} -.chat-verlauf{flex:1;min-height:200px;max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--area)} -.popout-host .chat-verlauf{max-height:none} -.chat-leer{margin:auto;font-size:12px;color:var(--dim);text-align:center} -.chat-blase{max-width:78%;padding:6px 10px;border-radius:10px;font-size:12.5px;line-height:1.4;word-break:break-word;background:var(--win);border:1px solid var(--border);align-self:flex-start} -.chat-blase.ich{align-self:flex-end;background:var(--accent);border-color:var(--accent);color:#fff} -.chat-zeit{display:block;font-size:10px;opacity:.65;margin-top:2px} -.chat-schnell{display:flex;flex-wrap:wrap;gap:6px} -.chat-btn{position:relative} -.chat-badge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:#e5484d;color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center} -.report-liste{margin:6px 0 10px;padding-left:20px;font-size:12px;line-height:1.6} - -/* ---- Auswahl-Dialog ---- */ -.wahl-liste{display:flex;flex-direction:column;gap:4px;max-height:320px;overflow-y:auto} -.popout-host .wahl-liste{max-height:none} -.wahl-option{display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:4px;background:var(--area);color:var(--text);font-family:inherit;text-align:left;cursor:pointer} -.wahl-option:hover{border-color:var(--accent)} -.wahl-option.an{border-color:var(--accent);background:var(--sel)} -.wahl-option:disabled{opacity:.5;cursor:default} -.wahl-bild{width:26px;height:26px;flex:0 0 auto} -.wahl-label{display:flex;flex-direction:column;min-width:0} -.wahl-label b{font-size:13px;font-weight:600} -.wahl-label small{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} - -/* ---- Schnellstart-Taste auf der Kachel ---- */ -.card{position:relative} -.card-taste{position:absolute;top:4px;right:4px;padding:1px 5px;border-radius:3px;background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:14px} - -/* ---- Bilder im Chat ---- */ -.chat-bild{display:block;max-width:100%;max-height:180px;border-radius:6px;margin-bottom:3px;cursor:zoom-in} -.screen-thumb .screen-teilen{right:32px;background:var(--accent)} - -.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px} -.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px} -.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))} -.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto} -.friend-dot.online{background:#3bb54a} -.friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain} -.friend-info{flex:1;min-width:0} -.friend-name{font-size:13px} -.friend-status{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.friends-empty{color:var(--dim);font-size:12px;padding:8px 4px} - -/* Spielaufzeichnung */ -.record-search{display:flex;flex-direction:column;align-items:center;gap:10px;padding:48px 0;color:var(--dim)} -.record-stage{display:flex;flex-direction:column;gap:10px} -.record-video{width:100%;flex:1 1 auto;min-height:360px;background:#000;border-radius:4px;object-fit:contain} -.record-toolbar{display:flex;flex-direction:column;align-items:flex-end;gap:6px} -.record-live-badge{display:flex;align-items:center;gap:8px;color:var(--danger);font-size:13px;font-weight:600} -#record-timer{font-variant-numeric:tabular-nums} -.rec-dot{width:10px;height:10px;border-radius:50%;background:var(--danger);animation:rec-pulse 1s infinite} -@keyframes rec-pulse{0%,100%{opacity:1}50%{opacity:.25}} -.record-audio-bar{display:flex;align-items:center;gap:10px;padding:2px 2px} -.record-mic-list{display:flex;flex-direction:column;gap:8px;margin-top:8px} -.record-source-row{display:flex;align-items:center;gap:10px;margin-bottom:8px} -.record-source-row .checkbox-field{margin-bottom:0;flex:1;min-width:0;font-size:13px;gap:6px} -.record-vol{width:110px} - -/* Konsole (dunkel wie ein Log) */ -.console-out{height:440px;overflow:auto;margin:0;background:#1e1e1e;border:1px solid var(--border);border-radius:3px;padding:10px;font-family:var(--mono);font-size:12px;color:#d4d4d4;white-space:pre-wrap;word-break:break-word;line-height:1.5} -.log-line{white-space:pre-wrap} -.log-error{color:#f48771}.log-warn{color:#dcdcaa}.log-info{color:#569cd6} - -/* Einstellungen */ -.settings-body{display:flex;padding:0;min-height:0;background:var(--area)} -.settings-nav{width:190px;flex:none;background:linear-gradient(180deg,var(--bar),rgba(128,128,128,.08));border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:4px} -.settings-nav-label{padding:2px 10px 8px;color:var(--dim);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase} -.snav{text-align:left;padding:10px 12px;border:1px solid transparent;border-radius:8px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;transition:background .15s,border-color .15s,transform .15s} -.snav:hover{background:rgba(128,128,128,.12)} -.snav.on{background:var(--sel);border-color:var(--sel-br);color:var(--text);box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)} -.settings-content{flex:1;padding:20px 22px;overflow:auto} -.settings-hero{margin-bottom:18px} -.settings-hero h4{margin:0 0 6px;font-size:20px;font-weight:700} -.settings-hero p{margin:0;color:var(--dim);font-size:13px;line-height:1.5} -.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start} -.settings-grid-stack{grid-template-columns:repeat(2,minmax(0,1fr))} -.settings-card{background:rgba(255,255,255,.7);border:1px solid var(--border);border-radius:12px;padding:16px 16px 12px;box-shadow:0 1px 0 rgba(255,255,255,.5)} -.settings-card h5{margin:0 0 14px;font-size:13px;font-weight:700;color:var(--text)} -.settings-card-wide{grid-column:span 2} -.settings-card .field:last-child,.settings-card .checkbox-field:last-of-type{margin-bottom:0} -.settings-kv{display:grid;gap:10px} -.settings-kv-row{display:flex;justify-content:space-between;gap:14px;padding-bottom:10px;border-bottom:1px solid var(--border2)} -.settings-kv-row:last-child{padding-bottom:0;border-bottom:none} -.settings-kv-label{color:var(--dim);font-size:12px} -.settings-kv-value{text-align:right;font-weight:600;font-size:12px;word-break:break-word} -.appinfo{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px;font-size:12px;line-height:1.6} -.appinfo-item{padding:10px 12px;border:1px solid var(--border2);border-radius:10px;background:rgba(128,128,128,.06)} -.appinfo-item.full{grid-column:span 2} -.appinfo-label{display:block;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.04em} -.appinfo-value{display:block;margin-top:4px;font-weight:600;word-break:break-word} -.info-card{max-width:760px} -.info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--border2)} -.info-status{font-size:12px;color:var(--dim)} -.modal-sep{border-top:1px solid var(--border2)} -.info-meta{margin-top:10px} -.info-meta .settings-kv-row{align-items:center} -.info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px} -.info-meta .settings-kv-value{text-align:left;font-size:13px;font-weight:600} -.info-meta-link{color:var(--link);text-decoration:none} -.info-meta-link:hover{text-decoration:underline} -.spane h4{margin:0 0 14px;font-size:14px;font-weight:600} -.status-ok{color:#2e8b2e!important;font-weight:600} -.status-bad{color:var(--danger)!important;font-weight:600} -:root[data-theme="dark"] .status-ok{color:#5cd65c!important} -.checkbox-field{display:flex;align-items:center;gap:8px;margin-bottom:14px;cursor:pointer;font-size:13px} -.checkbox-field input{width:16px;height:16px} -.field-port{max-width:110px;flex:none} - -:root[data-theme="dark"] .settings-body{background:#252528} -:root[data-theme="dark"] .settings-nav{background:linear-gradient(180deg,#2f2f34,#242428)} -:root[data-theme="dark"] .settings-card{background:rgba(30,30,30,.88);border-color:#3b3b42;box-shadow:none} -:root[data-theme="dark"] .settings-kv-row{border-bottom-color:#36363c} -:root[data-theme="dark"] .about-brand{border-bottom-color:#36363c} -:root[data-theme="dark"] .appinfo-item{background:rgba(255,255,255,.03);border-color:#36363c} -:root[data-theme="dark"] .info-meta-link{color:#7fc7ff} - -@media (max-width: 900px){ - #modal-settings .modal-card{max-width:calc(100vw - 20px);max-height:calc(100vh - 20px)} - .settings-body{flex-direction:column;min-height:0} - .settings-nav{width:auto;border-right:none;border-bottom:1px solid var(--border);padding:10px;flex-direction:row;flex-wrap:wrap} - .settings-nav-label{width:100%;padding:0 4px 6px} - .snav{flex:1 1 150px} - .settings-content{padding:16px} - .settings-grid,.settings-grid-stack{grid-template-columns:1fr} - .settings-card-wide{grid-column:auto} - .appinfo{grid-template-columns:1fr} - .appinfo-item.full{grid-column:auto} - .field-row{flex-direction:column;gap:0} - .path-row{flex-direction:column} -} - -/* Popup-Fenster sind eigenständige, meist schmalere Fenster -- die - Zeilenumbruch-Regel oben ist für die (deutlich breitere) Haupt-App gedacht - und soll hier nicht greifen (sonst z. B. Version/Icon im Instanz-Fenster - untereinander statt nebeneinander) */ -.is-popout .field-row{flex-direction:row} -.is-popout .path-row{flex-direction:row} - -/* Konto-Button mit 3D-Skin-Kopf */ -.acct-btn{gap:8px} -.acct-av{display:inline-flex;align-items:center;justify-content:center} -.acct-av svg{width:18px;height:18px;color:#4b5563} -.acct-head{width:24px;height:26px;object-fit:contain;image-rendering:auto;margin-top:-2px;margin-bottom:-2px} - -/* Update-Dialog */ -.upd-version{display:flex;align-items:center;justify-content:center;gap:12px;margin:4px 0 14px;font-size:15px} -.upd-old{color:var(--dim)} -.upd-arrow{color:var(--dim)} -.upd-newv{font-weight:700;color:var(--accent)} -.upd-channel{margin:-6px 0 12px;text-align:center} -.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px} -.upd-progress{margin:10px 0} -.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden} -.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s} -.backup-progress{margin-top:12px} -.backup-progress-bar{height:10px;background:var(--border2);border-radius:999px;overflow:hidden} -.backup-progress-fill{height:100%;width:0;background:linear-gradient(90deg,#3a7bc8,#5aa1e6);transition:width .2s;background-size:220% 100%} -.backup-progress-fill-busy{animation:backup-progress-shift 1.2s linear infinite} -.backup-progress-text{margin-top:6px} - -@keyframes backup-progress-shift{ - from{background-position:0 0} - to{background-position:220% 0} -} - -/* Kontextmenü (Rechtsklick auf Instanz) */ -.ctx{ - position:fixed;z-index:200;min-width:210px;padding:4px; - background:var(--win);border:1px solid var(--border);border-radius:4px; - box-shadow:0 8px 24px rgba(0,0,0,.35); -} -.ctx-item{ - display:block;width:100%;text-align:left;padding:6px 12px; - border:none;border-radius:3px;background:none;color:var(--text); - font-family:inherit;font-size:12px;cursor:pointer;white-space:nowrap; -} -.ctx-item:hover{background:var(--sel);color:var(--text)} -.ctx-item:disabled{opacity:.45;cursor:default;background:none} -.ctx-strong{font-weight:600} -.ctx-danger:hover{background:var(--danger);color:#fff} -.ctx-sep{height:1px;background:var(--border);margin:4px 6px} - -/* Toast */ -.toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px} -.toast.err{background:#a5342a} - -/* Scrollbars (hell) */ -::-webkit-scrollbar{width:14px;height:14px} -::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px} -::-webkit-scrollbar-thumb:hover{background:#a8a8a8} +/* AeroMC – heller MultiMC-Native-Look */ +:root{ + --win:#f0f0f0; --area:#e2e2e2; --bar:#e9eaed; + --border:#c4c7cc; --border2:#d6d8dc; + --text:#1c1c1c; --dim:#6b7280; + --sel:#dce8f6; --sel-br:#3a6ea5; --link:#2c5aa0; --accent:#3a6ea5; + --danger:#c0392b; --play:#3a9c3a; + --font:"Segoe UI",Tahoma,system-ui,sans-serif; + --mono:Consolas,"Cascadia Mono",monospace; +} +*{box-sizing:border-box} +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 auto 1fr auto;grid-template-columns:minmax(0,1fr);height:100vh;overflow:hidden} +.hidden{display:none!important} +.mono{font-family:var(--mono);font-size:12px} +.spacer{flex:1} +.opt,.hint{color:var(--dim)} +[data-ic] svg,.ic svg{width:16px;height:16px} + +/* ===================== DUNKLER MODUS (native, dunkelgrau) ===================== */ +:root[data-theme="dark"]{ + --win:#2d2d30; --area:#242426; --bar:#333337; + --border:#3f3f46; --border2:#3a3a40; + --text:#e4e4e6; --dim:#9a9aa2; + --sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb; + --danger:#e06c6c; --play:#5cb85c; + --input:#1e1e1e; +} +:root[data-theme="dark"] body{background:var(--win)} +:root[data-theme="dark"] .search-wrap input, +:root[data-theme="dark"] .field input, +:root[data-theme="dark"] .field select, +:root[data-theme="dark"] .field textarea, +:root[data-theme="dark"] .mods-search input, +:root[data-theme="dark"] .path-row input, +:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)} +:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618} +:root[data-theme="dark"] .modal-head{background:#333337} +:root[data-theme="dark"] .modal-body{background:#2d2d30} +:root[data-theme="dark"] .modal-foot{background:#2a2a2e} +:root[data-theme="dark"] .advanced{background:#242426} +:root[data-theme="dark"] .mods-list, +:root[data-theme="dark"] .account-item, +:root[data-theme="dark"] .screen-thumb{background:#1e1e1e} +:root[data-theme="dark"] .mod-icon, +:root[data-theme="dark"] .account-av, +:root[data-theme="dark"] .icon-preview{background:#3a3a40} +:root[data-theme="dark"] .mod-item+.mod-item{border-color:#333} +:root[data-theme="dark"] .mod-item:hover{background:#2a2f36} +:root[data-theme="dark"] .btn{background:linear-gradient(#3b3b40,#303034);color:var(--text);border-color:var(--border)} +:root[data-theme="dark"] .btn:hover{background:linear-gradient(#44444b,#35353b);border-color:#5a5a63} +:root[data-theme="dark"] .btn:active{background:#2a2a2e} +:root[data-theme="dark"] .btn-primary{background:linear-gradient(#2a7ac0,#1f66a8);border-color:#1a5a95;color:#fff} +:root[data-theme="dark"] .btn-primary:hover{background:linear-gradient(#3286cc,#2470b4)} +:root[data-theme="dark"] .btn-danger{color:#e88;border-color:#6a3a3a} +:root[data-theme="dark"] .btn-danger:hover{background:#3a2626} +:root[data-theme="dark"] .tbtn:hover, +:root[data-theme="dark"] .sbtn:hover, +:root[data-theme="dark"] .card:hover{background:#37373d;border-color:var(--border)} +:root[data-theme="dark"] .card.sel{background:#0e639c;border-color:#1177bb} +:root[data-theme="dark"] .card.sel .card-name{background:#1177bb;color:#fff} +:root[data-theme="dark"] .st-row2{background:#2a2a2e} +:root[data-theme="dark"] .st-row, +:root[data-theme="dark"] .group-header, +:root[data-theme="dark"] .empty h2, +:root[data-theme="dark"] .mods-col h4, +:root[data-theme="dark"] .field>span{color:var(--text)} +:root[data-theme="dark"] #btn-console svg{color:#c8c8cc} +:root[data-theme="dark"] .acct-av svg{color:#c8c8cc} +:root[data-theme="dark"] .mods-filters select, +:root[data-theme="dark"] .mods-search select, +:root[data-theme="dark"] .mmc-bar select{background:#1e1e1e;color:var(--text);border-color:var(--border)} +:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)} +:root[data-theme="dark"] .modal-close{color:#aaa} +:root[data-theme="dark"] .modal-close:hover{color:#fff} +: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{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;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:active{background:#d7dde5} +.tbtn svg{width:18px;height:18px;color:#4b5563} +/* farbige Toolbar-Icons wie in MultiMC */ +#btn-new svg{color:#3a9c3a} +#btn-import svg{color:#3d7ab5} +#btn-open-dir svg{color:#d8a72f} +#btn-console svg{color:#5a6470} +#btn-account svg{color:#3a9c3a} +#btn-settings svg{color:#3d7ab5} +.tbtn.big{font-size:13px;padding:8px 16px;border:1px solid var(--border);background:linear-gradient(#fdfdfd,#e9e9e9)} +.tbsep{width:1px;height:24px;background:var(--border);margin:0 5px} +.tbgrow{flex:1} +.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px} +.search-wrap input:focus{outline:none;border-color:var(--accent)} + +/* ---------- Arbeitsbereich ---------- */ +.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} +/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */ +.list-area::after{ + content:'';position:absolute;right:24px;bottom:8px;width:52%;height:88%; + background-image:var(--bg-skin,none);background-repeat:no-repeat; + background-position:right bottom;background-size:contain; + opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0; +} +.list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px} +.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px} +.side-empty{text-align:center;color:var(--dim);padding:24px 8px} +.side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px} +.side-ico img{width:56px;height:56px;object-fit:cover;border-radius:3px} +.side-title{text-align:center;font-weight:600;font-size:14px;word-break:break-word} +.side-grp{text-align:center;color:var(--link);font-size:11px;margin-top:2px;cursor:pointer} +.side-grp:hover{text-decoration:underline} +.hr{height:1px;background:var(--border);margin:6px 2px} +.sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)} +.sbtn:hover{background:#e2e6ec;border-color:var(--border2)} +.sbtn:disabled{opacity:.5;cursor:default} +.sbtn:disabled:hover{background:none;border-color:transparent} +.sbtn.play{font-weight:600} +.sbtn.del:hover{color:var(--danger)} + +/* ---------- Instanzliste / Gruppen / Kacheln ---------- */ +.group-section{margin-bottom:2px} +.group-header{display:flex;align-items:center;gap:7px;padding:5px 4px 2px;color:#333;font-weight:600;font-size:12px;cursor:pointer;user-select:none} +.group-header::after{content:'';flex:1;height:1px;background:var(--border);margin-left:6px} +.group-header .chev{font-size:9px;color:#888;width:10px;text-align:center} +.group-header.collapsed .chev{transform:none} +.group-count{color:var(--dim);font-weight:400} +.group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start} +.group-section.collapsed .group-grid{display:none} +.card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative} +.card:hover{background:#e3ecf7} +.card.sel{background:var(--sel);border-color:var(--sel-br)} +.card-icon{width:60px;height:60px;display:grid;place-items:center;font-size:40px} +.card-icon svg,.side-ico svg{display:block} +.mc-texture-icon{display:block;object-fit:contain;image-rendering:auto;filter:drop-shadow(0 1px 1px rgba(0,0,0,.18))} +.card-icon-img{width:60px;height:60px;object-fit:cover;border-radius:3px} +.card-name{width:100%;max-width:88px;box-sizing:border-box;font-size:12px;text-align:center;line-height:1.25;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.card.sel .card-name{background:var(--sel-br);color:#fff;border-radius:2px} +.card-badges{display:none} +.card-play{display:none} +.group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px} +.card.dragging{opacity:.4} + +/* Datei von außen übers Fenster gezogen (.jar/.mrpack/.zip) */ +body.file-drop-active::after{ + content:"Datei loslassen zum Importieren"; + position:fixed;inset:0;z-index:9999; + display:flex;align-items:center;justify-content:center; + background:rgba(0,0,0,.55); + color:#fff;font-size:20px;font-weight:600; + border:3px dashed var(--accent); + pointer-events:none; +} + +/* ---------- Leerer Zustand ---------- */ +.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px} +.empty h2{color:#333;margin:0} +.empty p{max-width:340px;margin:0 0 8px} + +/* ---------- Statusleiste (zwei Zeilen) ---------- */ +.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-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444} +.auth-warn{ + display:inline-flex;align-items:center;gap:6px; + color:#fff;background:var(--danger); + padding:1px 10px;border-radius:3px;font-weight:600;cursor:pointer; + animation:authpulse 2s ease-in-out infinite; +} +.auth-warn::before{content:'⚠';font-size:12px} +.auth-warn:hover{filter:brightness(1.12)} +@keyframes authpulse{0%,100%{opacity:1}50%{opacity:.72}} + +/* ---------- Modals ---------- */ +.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50} +/* Bestätigungsdialog immer über anderen Dialogen (z. B. Screenshots) */ +#modal-confirm,#modal-prompt{z-index:120} +.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden} +.modal-sm{width:420px}.modal-lg{width:820px} +#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)} +.modal-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#e8e8e8;border-bottom:1px solid var(--border)} +.modal-head h3{margin:0;font-size:14px;font-weight:600} +.modal-close{background:none;border:none;color:#666;font-size:15px;cursor:pointer;line-height:1} +.modal-close:hover{color:#000} +.modal-body{padding:16px;overflow:auto;background:#f6f6f6} +.modal-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:#efefef} + +/* Popup-Fenster (Einstellungen/Konsole): Haupt-UI ausblenden, das eine Modal + füllt das ganze (rahmenlose) Fenster; der eigene Kopfbereich ersetzt die + native Titelleiste – per -webkit-app-region per Maus verschiebbar */ +body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>footer.statusbar{display:none} +.popout-host{position:static;inset:auto;background:none;display:block} +/* !important: schlägt auch die spezifischere #modal-settings-Breitenregel oben */ +.popout-host .modal-card{width:100%!important;max-width:none!important;height:100vh!important;max-height:none!important;border:none;border-radius:0;box-shadow:none;margin:0} +/* Inhalt soll die restliche Fensterhöhe füllen (scrollen statt Leerraum unter dem Fuß) */ +.popout-host .modal-body{flex:1 1 auto;min-height:0} +/* Konsole/Logs/Screenshots haben einen einzelnen scrollbaren Kasten mit + fester Höhe (für den normalen Dialog gedacht) – im Popup-Fenster soll der + stattdessen die volle verbleibende Höhe ausfüllen statt Leerraum darunter + zu lassen */ +#modal-console.popout-host .modal-body, +#modal-logs.popout-host .modal-body, +#modal-screens.popout-host .modal-body, +#modal-servers.popout-host .modal-body, +#modal-mods.popout-host .modal-body, +#modal-record.popout-host .modal-body{display:flex;flex-direction:column} +.popout-host .console-out, +#modal-logs.popout-host .crash-voll, +.popout-host .mods-list, +.popout-host .record-stage{flex:1 1 auto;height:auto;max-height:none;min-height:0} +/* Screenshots/Aufzeichnungen NICHT auf die volle Fensterhöhe zwingen (Grid- + Zeilen strecken sich sonst nicht mit, es bliebe bei wenigen Einträgen ein + großer sichtbar leerer Kasten stehen) - nur die feste 460px-Deckelung + aufheben, damit bei VIELEN Einträgen trotzdem mehr Platz genutzt wird */ +.popout-host .screens-grid{max-height:none} +/* Mods-Fenster: die aktive Ansicht (Katalog/Installiert) muss selbst ein + Flex-Container werden, damit ihre eigene Liste (.mods-list) die Regel oben + nutzen und den restlichen Platz füllen kann */ +#modal-mods.popout-host #m-pane-catalog, +#modal-mods.popout-host #m-pane-installed{display:flex;flex-direction:column;flex:1 1 auto;min-height:0} +.popout-host .modal-head{-webkit-app-region:drag;cursor:default} +.popout-host .modal-head button,.popout-host .modal-head input,.popout-host .modal-head select{-webkit-app-region:no-drag} + +/* Formular */ +.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px} +.field>span{font-size:12px;color:#333} +.field input,.field select,.field textarea{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px} +.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)} +.field textarea{resize:vertical} +.field-row{display:flex;gap:12px;flex-wrap:wrap}.field-row .field{flex:1 1 0;min-width:0} +.field-icon{max-width:210px;min-width:0} +.backup-select-field{margin-top:14px;margin-bottom:0} +.backup-select-field select{width:100%} +.hint{font-size:12px} +.path-row{display:flex;gap:8px} +.path-row input{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px} +.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb} +.advanced legend{color:var(--dim);font-size:12px;padding:0 6px} +.version-row{display:flex;gap:8px}.version-row #f-version-type{max-width:120px}.version-row #f-version{flex:1} +.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:3px;margin-top:6px; + width:100%;max-width:100%;max-height:132px;overflow-y:auto;overflow-x:hidden;box-sizing:border-box} +.icon-choice{display:flex;align-items:center;justify-content:center;padding:2px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;min-width:0;overflow:hidden} +.icon-choice img{width:24px;height:24px;display:block;object-fit:contain;image-rendering:auto} +.icon-choice:hover{background:rgba(128,128,128,.18)} +.icon-choice.on{border-color:var(--accent);background:var(--sel)} +.icon-choice svg{display:block} +.icon-custom{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap} +.icon-preview{width:34px;height:34px;border-radius:3px;overflow:hidden;background:#e4e4e4;border:1px solid var(--border)} +.icon-preview img{width:100%;height:100%;object-fit:cover} +.appinfo{margin-top:8px;font-size:12px;color:var(--dim);line-height:1.6} +.about-brand{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;margin:0 0 18px;padding:6px 0 14px;border-bottom:1px solid var(--border2);text-align:center} +.about-logo{width:56px;height:56px;border-radius:12px} +.about-name{font-size:18px;font-weight:600} + +/* Buttons in Dialogen */ +.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:3px;background:linear-gradient(#fdfdfd,#e9e9e9);color:var(--text);cursor:pointer;font-family:inherit;font-size:12px} +.btn:hover{background:linear-gradient(#ffffff,#e2e2e2);border-color:#9aa0a8} +.btn:active{background:#dcdcdc} +.btn:disabled{opacity:.5;cursor:default} +.btn svg{width:15px;height:15px} +.btn-primary{background:linear-gradient(#5a9be0,#3a7bc8);border-color:#2f6bb0;color:#fff} +.btn-primary:hover{background:linear-gradient(#69a8ea,#3f83d2)} +.btn-danger{color:var(--danger);border-color:#cbb0ac} +.btn-danger:hover{background:#f7e9e7;border-color:#c0392b} +.btn-lg{padding:9px 18px;font-size:13px} +.btn-mini{padding:4px 8px;font-size:11px} + +/* Badges (dezent) */ +.badge{font-size:10px;padding:1px 6px;border-radius:2px;background:#e2e2e2;color:#555;border:1px solid var(--border)} + +/* Detail-Kopf (Mods/Login) */ +.detail-head{display:flex;align-items:center;gap:12px} +.detail-icon{font-size:28px} +.detail-sub{color:var(--dim);font-size:12px;margin-top:2px} + +/* Mods */ +.mods-body{min-height:420px} +.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px} +.mods-search{display:flex;gap:8px;margin-bottom:10px;align-items:stretch} +.mods-search select{ + flex:0 0 auto;min-width:140px;max-width:180px; + padding:7px 10px;border:1px solid var(--border);border-radius:3px; + background:var(--input,#fff);color:var(--text); + font-family:inherit;font-size:12px; +} +.mods-search select:focus{outline:none;border-color:var(--accent)} +.mods-filters{display:flex;gap:8px;margin-bottom:12px} +.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} +.mods-filters select:focus{outline:none;border-color:var(--accent)} +#m-more{display:block;width:100%;margin-top:8px} +.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)} +.mods-search input:focus{outline:none;border-color:var(--accent)} +.mods-columns{display:grid;grid-template-columns:1fr 1fr;gap:14px} +.mods-col h4{margin:0 0 8px;font-size:12px;color:#333;font-weight:600;display:flex;align-items:center;gap:6px} +.mods-tabs{display:flex;gap:2px;margin-bottom:10px;border-bottom:1px solid var(--border)} +.mtab{padding:7px 14px;border:1px solid transparent;border-bottom:none;border-radius:3px 3px 0 0;background:none;color:var(--dim);cursor:pointer;font-family:inherit;font-size:12px;margin-bottom:-1px} +.mtab:hover{color:var(--text)} +.mtab.on{color:var(--text);background:var(--bar);border-color:var(--border);font-weight:600} +.mods-list{height:340px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:3px;background:#fff;padding:6px} +.mods-empty{color:var(--dim);text-align:center;padding:24px 8px;font-size:12px} +.mod-item{display:flex;gap:10px;align-items:center;padding:8px;border-radius:3px;max-width:100%;overflow:hidden} +.mod-item.clickable{cursor:pointer} +.mod-item.installing{opacity:.6} +.mod-item+.mod-item{border-top:1px solid #eee} +.mod-item:hover{background:#f0f4f8} +.mod-icon{width:38px;height:38px;border-radius:3px;flex:none;background:#e4e4e4;object-fit:cover;display:flex;align-items:center;justify-content:center;font-size:18px} +.mod-info{flex:1;min-width:0} +.mod-name{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.mod-desc{font-size:12px;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.mod-meta{font-size:11px;color:var(--dim);margin-top:2px} +.mod-new{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#2f7d32;color:#fff;vertical-align:middle} +.mod-off{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#6b6b6b;color:#fff;vertical-align:middle} +.crash-kern{margin:10px 0;padding:10px 12px;border-left:3px solid #c0392b;background:rgba(192,57,43,.08);border-radius:0 4px 4px 0} +.crash-zeile{display:flex;gap:8px;margin-bottom:5px;font-size:12px;line-height:1.45} +.crash-zeile:last-child{margin-bottom:0} +.crash-titel{flex:0 0 132px;color:var(--dim)} +.crash-wert{flex:1;word-break:break-word} +.crash-verlauf{margin:0;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--dim);white-space:pre-wrap;word-break:break-all} +.skin-vorschau{display:flex;gap:14px;margin-top:12px} +.skin-spalte{flex:1;display:flex;flex-direction:column;gap:4px;min-width:0} +.skin-label{font-size:11px;color:var(--dim)} +.skin-bild{height:300px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,.15);padding:6px;text-align:center;overflow:hidden} +/* Die gerenderte Figur soll das Feld ausfüllen; eine rohe 64x64-Textur wird + ohne Weichzeichnen vergrößert, damit sie nicht verschwommen wirkt. */ +.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated} +.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab} +.skin-bild canvas:active{cursor:grabbing} +.crash-details{margin-top:12px} +.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)} +.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word} +.mod-aus .mod-name,.mod-aus .mod-meta{opacity:.55} +.mod-aus .mod-icon{filter:grayscale(1);opacity:.6} +.mod-action{flex:none} + +/* Import */ +.import-pane{min-height:180px} +.import-copy{margin:0 0 10px;font-size:13px;line-height:1.5} + +/* MultiMC-Import */ +.mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px} +.mmc-bar select{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} +.mmc-bar select:focus{outline:none;border-color:var(--accent)} +.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px} +.mmc-item+.mmc-item{border-top:1px solid var(--border2)} +.mmc-item:hover{background:rgba(128,128,128,.10)} +.mmc-item input{width:16px;height:16px;flex:none} +.mmc-name{font-weight:600;font-size:13px} +.mmc-meta{font-size:11px;color:var(--dim)} + +/* Bedrock */ +.bd-actions{display:flex;gap:8px;align-items:center;margin-bottom:14px} +.bd-meta{font-size:11px;color:var(--dim);margin-top:2px} + +/* Login / Konten */ +.login-code{text-align:center} +.login-link{color:var(--link);font-size:15px;display:inline-block;margin-bottom:12px;cursor:pointer;text-decoration:none} +.login-link:hover{text-decoration:underline} +.login-usercode{font-family:var(--mono);font-size:28px;font-weight:700;letter-spacing:3px;color:#222;background:#fff;border:1px solid var(--border);border-radius:4px;padding:12px;margin:6px 0 12px} +.login-error{color:var(--danger)!important;font-weight:600} +.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:10px} +.account-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:3px;background:#fff;border:1px solid var(--border)} +.account-item.active{border-color:var(--accent)} +.account-av{width:34px;height:34px;border-radius:3px;flex:none;background:#e4e4e4;display:flex;align-items:center;justify-content:center;font-size:18px} +img.account-av{object-fit:cover;image-rendering:auto} +.account-main{flex:1;min-width:0} +.account-name{font-weight:600} +.account-tag{font-size:11px;color:var(--link)} +.account-actions{display:flex;gap:6px} +.account-empty{color:var(--dim);text-align:center;padding:10px} + +/* Screenshots */ +.screens-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;max-height:460px;overflow:auto} +.screen-thumb{border:1px solid var(--border);border-radius:3px;overflow:hidden;background:#fff;cursor:pointer;position:relative} +.screen-thumb img{width:100%;height:100px;object-fit:cover;display:block} +.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer} +.screen-thumb:hover .screen-del{opacity:1} +.screen-thumb.selected{outline:2px solid var(--sel-br);outline-offset:1px;background:var(--sel)} +.screen-thumb .screen-check{position:absolute;top:4px;left:4px;width:18px;height:18px;margin:0;cursor:pointer;z-index:2} +.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap} +.screens-toolbar .hint{font-size:11px} +.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1} +.record-thumb-img-wrap{position:relative} +.record-thumb-img-wrap img{background:#000} +.record-thumb-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;text-shadow:0 1px 4px rgba(0,0,0,.7);pointer-events:none} +.record-thumb-size{font-size:10px;color:var(--dim);padding:0 6px 4px} + +/* Freunde */ +.tb-badge{display:inline-block;min-width:16px;height:16px;padding:0 4px;margin-left:6px;border-radius:8px;background:#d9453c;color:#fff;font-size:11px;font-weight:600;line-height:16px;text-align:center;box-sizing:border-box} +#modal-friends .field-row{margin-bottom:14px} +#modal-friends .field-row input{flex:1} +/* ---- Spielzeit-Statistik: Kennzahlen + Balken pro Instanz ---- */ +:root{--stat-bar:#3a6ea5} +:root[data-theme="dark"]{--stat-bar:#3d8fe0} +.stats-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:6px} +.stat-tile{border:1px solid var(--border);border-radius:4px;padding:8px 10px;min-width:0} +.stat-tile .stat-label{font-size:11px;color:var(--dim)} +.stat-tile .stat-value{font-size:18px;font-weight:600;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.stat-tile .stat-sub{font-size:11px;color:var(--dim);line-height:1.35} +.stats-list{display:flex;flex-direction:column;gap:2px} +.stat-row{display:grid;grid-template-columns:minmax(0,38%) 1fr auto;align-items:center;gap:10px;padding:5px 4px;border-radius:3px} +.stat-row:hover{background:var(--sel)} +.stat-name{min-width:0} +.stat-name b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.stat-name span{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block} +.stat-track{height:8px} +.stat-bar{height:8px;background:var(--stat-bar);border-radius:0 4px 4px 0;min-width:2px} +.stat-value-cell{font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;min-width:92px} +.popout-host .stats-list{max-height:none} + +/* Clip-Modus-Leiste im Aufzeichnungsfenster */ +.clip-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px;border:1px solid var(--border);border-radius:4px;background:var(--area)} +.clip-bar.aktiv{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)} +.clip-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;user-select:none} +.clip-switch input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none} +.clip-switch-track{position:relative;width:32px;height:18px;border-radius:9px;background:var(--border);transition:background .15s} +.clip-switch-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.35);transition:left .15s} +.clip-switch input:checked + .clip-switch-track{background:var(--accent)} +.clip-switch input:checked + .clip-switch-track .clip-switch-knob{left:16px} +.clip-switch input:focus-visible + .clip-switch-track{outline:2px solid var(--accent);outline-offset:2px} +.clip-length{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)} +.clip-length select{padding:3px 6px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} +.clip-length select:disabled{opacity:.6} +.clip-status{flex:1;min-width:200px;font-size:12px;color:var(--dim)} +.clip-bar.aktiv .clip-status{color:var(--text)} +.clip-bar.aktiv .clip-status::before{content:"";display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--danger);vertical-align:1px;animation:rec-pulse 1.6s infinite} + +/* ---- Absturz-Assistent ---- */ +.crash-hilfe{display:flex;flex-direction:column;gap:8px;margin-bottom:12px} +.crash-hilfe-titel{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em} +.crash-fund{display:flex;gap:12px;align-items:flex-start;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;background:var(--area)} +.crash-fund-text{flex:1;min-width:0} +.crash-fund-text b{display:block;font-size:13px;margin-bottom:3px} +.crash-fund-text span{font-size:12px;color:var(--dim);line-height:1.45} +.crash-fund .btn{flex:0 0 auto;align-self:center} +.crash-fund.erledigt{border-left-color:var(--play)} +.crash-unklar{padding:10px 12px;border:1px dashed var(--border);border-radius:4px;font-size:12px;color:var(--dim)} + +/* ---- Instanz teilen ---- */ +.share-code-box{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border:1px solid var(--accent);border-radius:4px;background:var(--area);margin:10px 0} +.share-code{font-family:var(--mono);font-size:24px;font-weight:600;letter-spacing:.12em;user-select:all} +.share-warn{color:var(--danger)} +.share-code-input{text-transform:uppercase;letter-spacing:.1em;font-family:var(--mono)} +.share-preview{margin-top:12px;padding:12px 14px;border:1px solid var(--border);border-radius:4px;background:var(--area)} +.share-preview b{font-size:14px} +.share-preview .hint{margin:4px 0 0} + +/* ---- Kurznachrichten ---- */ +.friends-chat{display:flex;flex-direction:column;gap:8px;height:100%;min-height:340px} +.chat-head{display:flex;align-items:center;gap:10px} +.chat-head b{font-size:13px} +.chat-verlauf{flex:1;min-height:200px;max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--area)} +.popout-host .chat-verlauf{max-height:none} +.chat-leer{margin:auto;font-size:12px;color:var(--dim);text-align:center} +.chat-blase{max-width:78%;padding:6px 10px;border-radius:10px;font-size:12.5px;line-height:1.4;word-break:break-word;background:var(--win);border:1px solid var(--border);align-self:flex-start} +.chat-blase.ich{align-self:flex-end;background:var(--accent);border-color:var(--accent);color:#fff} +.chat-zeit{display:block;font-size:10px;opacity:.65;margin-top:2px} +.chat-schnell{display:flex;flex-wrap:wrap;gap:6px} +.chat-btn{position:relative} +.chat-badge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:#e5484d;color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center} +.report-liste{margin:6px 0 10px;padding-left:20px;font-size:12px;line-height:1.6} + +/* ---- Auswahl-Dialog ---- */ +.wahl-liste{display:flex;flex-direction:column;gap:4px;max-height:320px;overflow-y:auto} +.popout-host .wahl-liste{max-height:none} +.wahl-option{display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:4px;background:var(--area);color:var(--text);font-family:inherit;text-align:left;cursor:pointer} +.wahl-option:hover{border-color:var(--accent)} +.wahl-option.an{border-color:var(--accent);background:var(--sel)} +.wahl-option:disabled{opacity:.5;cursor:default} +.wahl-bild{width:26px;height:26px;flex:0 0 auto} +.wahl-label{display:flex;flex-direction:column;min-width:0} +.wahl-label b{font-size:13px;font-weight:600} +.wahl-label small{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* ---- Schnellstart-Taste auf der Kachel ---- */ +.card{position:relative} +.card-taste{position:absolute;top:4px;right:4px;padding:1px 5px;border-radius:3px;background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:14px} + +/* ---- Bilder im Chat ---- */ +.chat-bild{display:block;max-width:100%;max-height:180px;border-radius:6px;margin-bottom:3px;cursor:zoom-in} +.screen-thumb .screen-teilen{right:32px;background:var(--accent)} + +.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px} +.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px} +.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))} +.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto} +.friend-dot.online{background:#3bb54a} +.friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain} +.friend-info{flex:1;min-width:0} +.friend-name{font-size:13px} +.friend-status{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.friends-empty{color:var(--dim);font-size:12px;padding:8px 4px} + +/* Spielaufzeichnung */ +.record-search{display:flex;flex-direction:column;align-items:center;gap:10px;padding:48px 0;color:var(--dim)} +.record-stage{display:flex;flex-direction:column;gap:10px} +.record-video{width:100%;flex:1 1 auto;min-height:360px;background:#000;border-radius:4px;object-fit:contain} +.record-toolbar{display:flex;flex-direction:column;align-items:flex-end;gap:6px} +.record-live-badge{display:flex;align-items:center;gap:8px;color:var(--danger);font-size:13px;font-weight:600} +#record-timer{font-variant-numeric:tabular-nums} +.rec-dot{width:10px;height:10px;border-radius:50%;background:var(--danger);animation:rec-pulse 1s infinite} +@keyframes rec-pulse{0%,100%{opacity:1}50%{opacity:.25}} +.record-audio-bar{display:flex;align-items:center;gap:10px;padding:2px 2px} +.record-mic-list{display:flex;flex-direction:column;gap:8px;margin-top:8px} +.record-source-row{display:flex;align-items:center;gap:10px;margin-bottom:8px} +.record-source-row .checkbox-field{margin-bottom:0;flex:1;min-width:0;font-size:13px;gap:6px} +.record-vol{width:110px} + +/* Konsole (dunkel wie ein Log) */ +.console-out{height:440px;overflow:auto;margin:0;background:#1e1e1e;border:1px solid var(--border);border-radius:3px;padding:10px;font-family:var(--mono);font-size:12px;color:#d4d4d4;white-space:pre-wrap;word-break:break-word;line-height:1.5} +.log-line{white-space:pre-wrap} +.log-error{color:#f48771}.log-warn{color:#dcdcaa}.log-info{color:#569cd6} + +/* Einstellungen */ +.settings-body{display:flex;padding:0;min-height:0;background:var(--area)} +.settings-nav{width:190px;flex:none;background:linear-gradient(180deg,var(--bar),rgba(128,128,128,.08));border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:4px} +.settings-nav-label{padding:2px 10px 8px;color:var(--dim);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase} +.snav{text-align:left;padding:10px 12px;border:1px solid transparent;border-radius:8px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;transition:background .15s,border-color .15s,transform .15s} +.snav:hover{background:rgba(128,128,128,.12)} +.snav.on{background:var(--sel);border-color:var(--sel-br);color:var(--text);box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)} +.settings-content{flex:1;padding:20px 22px;overflow:auto} +.settings-hero{margin-bottom:18px} +.settings-hero h4{margin:0 0 6px;font-size:20px;font-weight:700} +.settings-hero p{margin:0;color:var(--dim);font-size:13px;line-height:1.5} +.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start} +.settings-grid-stack{grid-template-columns:repeat(2,minmax(0,1fr))} +.settings-card{background:rgba(255,255,255,.7);border:1px solid var(--border);border-radius:12px;padding:16px 16px 12px;box-shadow:0 1px 0 rgba(255,255,255,.5)} +.settings-card h5{margin:0 0 14px;font-size:13px;font-weight:700;color:var(--text)} +.settings-card-wide{grid-column:span 2} +.settings-card .field:last-child,.settings-card .checkbox-field:last-of-type{margin-bottom:0} +.settings-kv{display:grid;gap:10px} +.settings-kv-row{display:flex;justify-content:space-between;gap:14px;padding-bottom:10px;border-bottom:1px solid var(--border2)} +.settings-kv-row:last-child{padding-bottom:0;border-bottom:none} +.settings-kv-label{color:var(--dim);font-size:12px} +.settings-kv-value{text-align:right;font-weight:600;font-size:12px;word-break:break-word} +.appinfo{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px;font-size:12px;line-height:1.6} +.appinfo-item{padding:10px 12px;border:1px solid var(--border2);border-radius:10px;background:rgba(128,128,128,.06)} +.appinfo-item.full{grid-column:span 2} +.appinfo-label{display:block;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.04em} +.appinfo-value{display:block;margin-top:4px;font-weight:600;word-break:break-word} +.info-card{max-width:760px} +.info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--border2)} +.info-status{font-size:12px;color:var(--dim)} +.modal-sep{border-top:1px solid var(--border2)} +.info-meta{margin-top:10px} +.info-meta .settings-kv-row{align-items:center} +.info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px} +.info-meta .settings-kv-value{text-align:left;font-size:13px;font-weight:600} +.info-meta-link{color:var(--link);text-decoration:none} +.info-meta-link:hover{text-decoration:underline} +.spane h4{margin:0 0 14px;font-size:14px;font-weight:600} +.status-ok{color:#2e8b2e!important;font-weight:600} +.status-bad{color:var(--danger)!important;font-weight:600} +:root[data-theme="dark"] .status-ok{color:#5cd65c!important} +.checkbox-field{display:flex;align-items:center;gap:8px;margin-bottom:14px;cursor:pointer;font-size:13px} +.checkbox-field input{width:16px;height:16px} +.field-port{max-width:110px;flex:none} + +:root[data-theme="dark"] .settings-body{background:#252528} +:root[data-theme="dark"] .settings-nav{background:linear-gradient(180deg,#2f2f34,#242428)} +:root[data-theme="dark"] .settings-card{background:rgba(30,30,30,.88);border-color:#3b3b42;box-shadow:none} +:root[data-theme="dark"] .settings-kv-row{border-bottom-color:#36363c} +:root[data-theme="dark"] .about-brand{border-bottom-color:#36363c} +:root[data-theme="dark"] .appinfo-item{background:rgba(255,255,255,.03);border-color:#36363c} +:root[data-theme="dark"] .info-meta-link{color:#7fc7ff} + +@media (max-width: 900px){ + #modal-settings .modal-card{max-width:calc(100vw - 20px);max-height:calc(100vh - 20px)} + .settings-body{flex-direction:column;min-height:0} + .settings-nav{width:auto;border-right:none;border-bottom:1px solid var(--border);padding:10px;flex-direction:row;flex-wrap:wrap} + .settings-nav-label{width:100%;padding:0 4px 6px} + .snav{flex:1 1 150px} + .settings-content{padding:16px} + .settings-grid,.settings-grid-stack{grid-template-columns:1fr} + .settings-card-wide{grid-column:auto} + .appinfo{grid-template-columns:1fr} + .appinfo-item.full{grid-column:auto} + .field-row{flex-direction:column;gap:0} + .path-row{flex-direction:column} +} + +/* Popup-Fenster sind eigenständige, meist schmalere Fenster -- die + Zeilenumbruch-Regel oben ist für die (deutlich breitere) Haupt-App gedacht + und soll hier nicht greifen (sonst z. B. Version/Icon im Instanz-Fenster + untereinander statt nebeneinander) */ +.is-popout .field-row{flex-direction:row} +.is-popout .path-row{flex-direction:row} + +/* Konto-Button mit 3D-Skin-Kopf */ +.acct-btn{gap:8px} +.acct-av{display:inline-flex;align-items:center;justify-content:center} +.acct-av svg{width:18px;height:18px;color:#4b5563} +.acct-head{width:24px;height:26px;object-fit:contain;image-rendering:auto;margin-top:-2px;margin-bottom:-2px} + +/* Update-Dialog */ +.upd-version{display:flex;align-items:center;justify-content:center;gap:12px;margin:4px 0 14px;font-size:15px} +.upd-old{color:var(--dim)} +.upd-arrow{color:var(--dim)} +.upd-newv{font-weight:700;color:var(--accent)} +.upd-channel{margin:-6px 0 12px;text-align:center} +.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px} +.upd-progress{margin:10px 0} +.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden} +.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s} +.backup-progress{margin-top:12px} +.backup-progress-bar{height:10px;background:var(--border2);border-radius:999px;overflow:hidden} +.backup-progress-fill{height:100%;width:0;background:linear-gradient(90deg,#3a7bc8,#5aa1e6);transition:width .2s;background-size:220% 100%} +.backup-progress-fill-busy{animation:backup-progress-shift 1.2s linear infinite} +.backup-progress-text{margin-top:6px} + +@keyframes backup-progress-shift{ + from{background-position:0 0} + to{background-position:220% 0} +} + +/* Kontextmenü (Rechtsklick auf Instanz) */ +.ctx{ + position:fixed;z-index:200;min-width:210px;padding:4px; + background:var(--win);border:1px solid var(--border);border-radius:4px; + box-shadow:0 8px 24px rgba(0,0,0,.35); +} +.ctx-item{ + display:block;width:100%;text-align:left;padding:6px 12px; + border:none;border-radius:3px;background:none;color:var(--text); + font-family:inherit;font-size:12px;cursor:pointer;white-space:nowrap; +} +.ctx-item:hover{background:var(--sel);color:var(--text)} +.ctx-item:disabled{opacity:.45;cursor:default;background:none} +.ctx-strong{font-weight:600} +.ctx-danger:hover{background:var(--danger);color:#fff} +.ctx-sep{height:1px;background:var(--border);margin:4px 6px} + +/* Toast */ +.toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px} +.toast.err{background:#a5342a} + +/* Scrollbars (hell) */ +::-webkit-scrollbar{width:14px;height:14px} +::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px} +::-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}