Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ef51edede | ||
|
|
93c74f94a1 | ||
|
|
384354f44e | ||
|
|
9afe91a592 |
@@ -2,6 +2,38 @@
|
||||
|
||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||
|
||||
## [1.2.1] - 2026-10-05
|
||||
|
||||
### Neu
|
||||
- Garderobe im Skin-Fenster: Skins (samt Armform) und eigene AeroMC-Umhänge speichern und mit einem Klick wieder anziehen – vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch gesichert, denn Mojang merkt sich keine alten Skins
|
||||
- Erfolge: 17 Erfolge (z. B. Spielzeit, Starts, Instanzen, Mods, Modpack, erster Freund, eigener Umhang, Skin aus dem Editor) mit „Erfolg erzielt!“-Meldung passend zum Design (im Minecraft-Design wie im Spiel); ein Klick auf die Meldung öffnet die Übersicht
|
||||
- Freunde mit 3D-Figur: Neben der Freundesliste dreht sich groß die Figur des ausgewählten Freundes mit Skin, 2. Ebene und Umhang (online läuft sie, offline steht sie) – das Freunde-Fenster ist dafür breiter, schmal gezogen steht die Figur über der Liste; die Köpfe in der Liste zeigen ebenfalls die 2. Ebene
|
||||
- Leistungs-Paket im Mod-Dialog: installiert mit einem Klick passende Performance-Mods – Sodium (bzw. Embeddium), Lithium (bzw. Radium), FerriteCore, Entity Culling, ImmediatelyFast, ModernFix und Dynamic FPS; Vorhandenes und für die Version nicht Verfügbares wird übersprungen, mit OptiFine bleibt Sodium weg
|
||||
- Starten-Knopf mit Fortschritt: füllt sich während Download und Vorbereitung und zeigt die Phase (z. B. „Assets 57 %“), danach kurz „Läuft ✓“
|
||||
- Mehr Leben in der Oberfläche (alles über „Bewegte Oberfläche“ abschaltbar):
|
||||
- Laufende Instanzen haben einen pulsierenden Rand – man sieht sofort, was gerade gespielt wird
|
||||
- Instanzen löschen, verschieben oder Gruppen auf-/zuklappen: Karten schrumpfen weg bzw. blenden ein, die übrigen rutschen weich nach
|
||||
- Skin übernommen: Die Figur dreht sich einmal um sich selbst; nach einem Umhang-Wechsel weht der Umhang kurz auf
|
||||
- Konto gewechselt: Der Spielerkopf oben rechts dreht sich wie eine Münze zum neuen Konto
|
||||
- Erfolg erzielt: Funken um die Meldung, im Minecraft-Design grüne XP-Kugeln
|
||||
- Freund kommt online: Sein Kopf hüpft in der Freundesliste, der grüne Punkt pulsiert
|
||||
- AeroMC/Nachtlila: dezent treibende Lichtpunkte im Instanzbereich
|
||||
- Minecraft-Design: Wolken ziehen durch die Werkzeugleiste, und beim Download zeigt das Block-Symbol der Instanz Abbau-Risse als Fortschritt
|
||||
- Das Zahnrad dreht sich beim Darüberfahren, das Block-Symbol einer Instanz kippt leicht zur Maus
|
||||
- Armform-Prüfung im Skin-Fenster: Passt die eingestellte Armform nicht zur Skin-Datei (gequetschte Arme oder dunkle Streifen), zeigt AeroMC einen Hinweis und stellt sie mit einem Klick richtig
|
||||
- Absturz-Assistent erkennt zu alte Forge-Versionen, die mit aktuellem Java 8 abstürzen („ManifestEntryVerifier“), und aktualisiert Forge auf Wunsch auf die neueste Version für dieselbe Minecraft-Version
|
||||
|
||||
### Geändert
|
||||
- Statistik-Fenster mit zwei Reitern: „Spielzeit“ und „Erfolge (x/17)“
|
||||
- Update-Meldung zeigt die Änderungshinweise formatiert an (Überschriften, Listen, fett/kursiv, Code, Links) statt als rohen Markdown-Text; das Fenster ist dafür größer, Links öffnen im Browser
|
||||
- Einstellungen aufgeräumt: jede Option als eigene Zeile mit kurzer Erklärung und Schalter statt Häkchen, alles einspaltig statt verstreuter Kacheln; neu sortiert in Allgemein, Darstellung, Spiel (inkl. Konsole), Java & Erweitert, Freunde & Umhänge (neu), Netzwerk, Konten, Backup (Welt-Sicherung jetzt bei „Automatisch sichern“)
|
||||
|
||||
### Behoben
|
||||
- Alte Forge-Versionen vor Minecraft 1.13 (z. B. 1.12.2) starteten nicht („Hauptklasse net.minecraft.launchwrapper.Launch konnte nicht gefunden werden“) – Klassenpfad und Startargumente fehlten
|
||||
- Bei Forge und NeoForge wurde ohne festgelegte Version teils die älteste statt der neuesten Version gewählt – die Versionsliste ist jetzt richtig sortiert
|
||||
- Der Mod-Katalog zeigte bei von Hand kopierten oder importierten Mods „Installieren“, obwohl sie schon da waren – AeroMC erkennt sie jetzt per Prüfsumme über Modrinth
|
||||
- Der ZIP-Import (AeroMC-Export oder gezippte MultiMC/Prism-Instanz) blockierte bei großen Archiven den Launcher – die Dateien werden jetzt im Hintergrund verschoben bzw. kopiert
|
||||
|
||||
## [1.2.0] - 2026-10-04
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -67,6 +67,8 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
|
||||
- **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
|
||||
- **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“
|
||||
- Offiziellen Mojang-Umhang direkt im Launcher wechseln oder ablegen
|
||||
- **Garderobe**: Skins und Umhänge speichern und mit einem Klick wieder anziehen – der bisherige Skin wird vor jedem Wechsel automatisch gesichert
|
||||
- Armform-Prüfung: Hinweis und Ein-Klick-Korrektur, wenn die Armform nicht zur Skin-Datei passt
|
||||
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
|
||||
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
|
||||
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"projects": {
|
||||
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
|
||||
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.0")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.1.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.1")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.0.0")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.1.0")]
|
||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -1 +1 @@
|
||||
e1b134ff0a2fb2387ee195d0721c9af9546a7821126b2fd5bae3f90099e4d686
|
||||
a17a2bc3b05635d3c29adc5c6884bde0a77826489d54b55a9b7744bfaa1ef206
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
"C:\\Users\\Viper\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "IwFtzT9tvgw=",
|
||||
"dgSpecHash": "zBMXx+S6DFU=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"expectedPackageFiles": [
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aeromc",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
|
||||
+227
-218
@@ -1,218 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* crashhelp.js – Absturz-Assistent
|
||||
* ---------------------------------
|
||||
* Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts
|
||||
* und erkennt die häufigsten Ursachen. Jeder Fund bekommt eine verständliche
|
||||
* Erklärung und – wo möglich – eine Aktion, die der Launcher mit einem Klick
|
||||
* ausführen kann:
|
||||
*
|
||||
* { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen
|
||||
* { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht)
|
||||
* { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern
|
||||
* { typ: 'java-auto' } passendes Java automatisch wählen lassen
|
||||
* { typ: 'mods-ordner' } Mods-Ordner öffnen
|
||||
*
|
||||
* Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen.
|
||||
*/
|
||||
|
||||
// Mod-IDs, die keine installierbaren Mods sind
|
||||
const KEINE_MODS = new Set(['minecraft', 'java', 'fabricloader', 'fabric-loader', 'forge', 'neoforge', 'quilt_loader', 'quilt-loader', 'fml', 'mixinextras']);
|
||||
|
||||
// Mod-ID -> Modrinth-Projekt, wo sich beides unterscheidet
|
||||
const PROJEKT_FUER_ID = {
|
||||
fabric: 'fabric-api',
|
||||
'fabric-api-base': 'fabric-api',
|
||||
quilted_fabric_api: 'qsl',
|
||||
cloth_config: 'cloth-config',
|
||||
'cloth-config2': 'cloth-config',
|
||||
architectury: 'architectury-api',
|
||||
geckolib3: 'geckolib',
|
||||
};
|
||||
|
||||
// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …)
|
||||
const javaAusKlassenversion = (v) => Number(v) - 44;
|
||||
|
||||
function projektFuer(modId) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
// installierte Mod zu einer ID/einem Namen finden (für "abschalten")
|
||||
function findeMod(mods, modId, name) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
const kurz = id.replace(/[-_]/g, '');
|
||||
const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (mods || []).find((m) => {
|
||||
if (m.enabled === false) return false;
|
||||
const datei = String(m.filename || '').toLowerCase();
|
||||
const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz)))
|
||||
|| (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n)));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* text: zusammengeführter Text aus Bericht, Log und Konsole
|
||||
* ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] }
|
||||
* -> [{ schluessel, titel, text, aktion? }]
|
||||
*/
|
||||
function analysiere(text, ctx = {}) {
|
||||
const t = String(text || '');
|
||||
const funde = [];
|
||||
const schonDa = new Set();
|
||||
const fund = (schluessel, titel, erklaerung, aktion) => {
|
||||
if (schonDa.has(schluessel)) return;
|
||||
schonDa.add(schluessel);
|
||||
funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null });
|
||||
};
|
||||
const mods = ctx.mods || [];
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Fabric/Quilt) ---
|
||||
// "Mod 'Mod Menu' (modmenu) 7.2.2 requires any version of fabric-api, which is missing!"
|
||||
// neuere Fassung: "... requires any version of mod 'Fabric API' (fabric-api), which is missing!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version ([^\n,]+?)|any version) of (?:mod '([^']+)' \(([\w.-]+)\)|([\w.-]+)), which is missing/gi)) {
|
||||
const [, name, , version, fehltName, fehltIdNeu, fehltIdAlt] = m;
|
||||
const fehlt = fehltIdNeu || fehltIdAlt;
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
const anzeige = fehltName || fehlt;
|
||||
fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`,
|
||||
`Der Mod „${name}“ funktioniert nur zusammen mit „${anzeige}“${version ? ` (Version ${version.trim()})` : ''} – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: anzeige });
|
||||
}
|
||||
// Lösungsvorschlag von Fabric: "- Install fabric-api, any version."
|
||||
for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) {
|
||||
const fehlt = m[1];
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`,
|
||||
`Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
}
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Forge/NeoForge) ---
|
||||
// "Mod ID: 'architectury', Requested by: 'rei', Expected range: '[9.1.12,)', Actual version: '[MISSING]'"
|
||||
for (const m of t.matchAll(/Mod ID: '([\w.-]+)', Requested by: '([\w.-]+)', Expected range: '([^']*)', Actual version: '([^']*)'/gi)) {
|
||||
const [, fehlt, von, bereich, ist] = m;
|
||||
if (fehlt.toLowerCase() === 'minecraft') {
|
||||
const mod = findeMod(mods, von);
|
||||
fund(`mcversion:${von}`, `„${von}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`Der Mod „${von}“ ist für Minecraft ${bereich} gemacht, deine Instanz hat ${ctx.version || ist}.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || von } : null);
|
||||
continue;
|
||||
}
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
if (/MISSING/i.test(ist)) {
|
||||
fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`,
|
||||
`Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
} else {
|
||||
fund(`alt:${fehlt}`, `„${fehlt}“ ist zu alt oder zu neu`,
|
||||
`„${von}“ braucht „${fehlt}“ in der Version ${bereich}, installiert ist ${ist}. Aktualisiere „${fehlt}“ im Inhalts-Dialog („Nach Updates suchen“).`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mod für eine andere Minecraft-Version (Fabric) ---
|
||||
// "Mod 'X' (x) 1.0 requires version 1.20.4 of minecraft, but only the wrong version is present: 1.21!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version |any version of )?([^\n]*?) of (?:mod )?'?minecraft'?/gi)) {
|
||||
const [, name, id, fuer] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`mcversion:${id}`, `„${name}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`„${name}“ ist für Minecraft ${fuer.replace(/^version\s+/i, '').trim() || 'eine andere Version'} gemacht, deine Instanz hat ${ctx.version || 'eine andere'}. Abschalten oder eine passende Version des Mods installieren.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// Lösungsvorschlag: "- Remove mod 'X' (x)."
|
||||
for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) {
|
||||
const [, name, id] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`entfernen:${id}`, `„${name}“ verursacht den Fehler`,
|
||||
`Der Mod-Loader schlägt vor, „${name}“ zu entfernen – er verträgt sich nicht mit den anderen Mods oder dieser Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// "Mod 'A' (a) is incompatible with any version of mod 'B' (b)"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? is incompatible with [^\n]*?mod '([^']+)' \(([\w.-]+)\)/gi)) {
|
||||
const [, a, idA, b] = m;
|
||||
const mod = findeMod(mods, idA, a);
|
||||
fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`,
|
||||
`Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null);
|
||||
}
|
||||
|
||||
// --- Doppelte Mods ---
|
||||
if (/Duplicate mods found|Found duplicate mods|is provided by multiple mods|Mod ID ['"]?[\w.-]+['"]? is (?:provided|present) multiple times/i.test(t)) {
|
||||
fund('doppelt', 'Ein Mod ist doppelt installiert',
|
||||
'Mindestens ein Mod liegt zweimal im Mods-Ordner (z. B. zwei Versionen desselben Mods). Lösche die ältere Datei.',
|
||||
{ typ: 'mods-ordner' });
|
||||
}
|
||||
|
||||
// --- Falsche Java-Version ---
|
||||
const klasse = t.match(/UnsupportedClassVersionError[\s\S]{0,300}?class file version (\d+)\.\d+/i);
|
||||
const javaPflicht = t.match(/requires (?:at least )?Java (\d+)|Java (\d+) (?:or (?:newer|later|above)) is required|Unsupported Java detected \((\d+)/i);
|
||||
if (klasse || javaPflicht) {
|
||||
const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0);
|
||||
fund('java', noetig ? `Diese Instanz braucht Java ${noetig}` : 'Falsche Java-Version',
|
||||
`Minecraft oder ein Mod wurde für ${noetig ? `Java ${noetig}` : 'eine andere Java-Version'} gebaut, gestartet wurde mit einer älteren. AeroMC kann das passende Java automatisch wählen und bei Bedarf herunterladen.`,
|
||||
{ typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Zu wenig Arbeitsspeicher ---
|
||||
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 2048;
|
||||
const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6));
|
||||
const neu = Math.min(grenze, jetzt + 2048);
|
||||
fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher',
|
||||
`Der Instanz waren ${Math.round(jetzt / 102.4) / 10} GB zugewiesen – zu wenig für diese Mods oder Welt.`
|
||||
+ (neu > jetzt ? ` Empfohlen: ${Math.round(neu / 102.4) / 10} GB.` : ' Mehr lässt dein PC kaum zu – weniger Mods oder eine kleinere Sichtweite helfen.'),
|
||||
neu > jetzt ? { typ: 'ram', mb: neu } : null);
|
||||
}
|
||||
if (/Could not reserve enough space for (?:\d+KB )?object heap|Invalid maximum heap size|Initial heap size set to a larger value than the maximum/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 4096;
|
||||
const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5)));
|
||||
fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt',
|
||||
`Java konnte die eingestellten ${Math.round(jetzt / 102.4) / 10} GB nicht reservieren – entweder ist gerade nicht genug frei oder es läuft ein 32-Bit-Java.`,
|
||||
neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Mixin-Fehler: ein bestimmter Mod passt nicht ---
|
||||
for (const m of t.matchAll(/Mixin (?:apply|transformation)[^\n]*? (?:for|from) mod ([\w.-]+) failed|from mod ([\w.-]+) failed injection check|mixins?\.json:[^\n]*? from mod ([\w.-]+)/gi)) {
|
||||
const id = m[1] || m[2] || m[3];
|
||||
if (!id || KEINE_MODS.has(id.toLowerCase())) continue;
|
||||
const mod = findeMod(mods, id);
|
||||
fund(`mixin:${id}`, `„${mod ? (mod.title || id) : id}“ passt nicht zu dieser Version`,
|
||||
`Der Mod „${id}“ greift tief in Minecraft ein und ist mit dieser Minecraft-Version oder einem anderen Mod nicht kompatibel. Schalte ihn ab oder installiere eine passende Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null);
|
||||
}
|
||||
|
||||
// --- Grafiktreiber ---
|
||||
if (/Pixel format not accelerated|WGL: The driver does not appear to support OpenGL|GLFW error 65542|OpenGL 3\.2 (?:is )?not supported|Couldn't set pixel format|No OpenGL context found/i.test(t)) {
|
||||
fund('treiber', 'Problem mit dem Grafiktreiber',
|
||||
'Minecraft konnte OpenGL nicht starten. Meist hilft es, den Grafiktreiber direkt vom Hersteller (NVIDIA, AMD oder Intel) zu aktualisieren. Bei Laptops: in den Windows-Grafikeinstellungen Java auf „Hohe Leistung“ stellen.');
|
||||
}
|
||||
|
||||
// --- Bekannte Unverträglichkeiten anhand der installierten Mods ---
|
||||
const aktive = mods.filter((m) => m.enabled !== false);
|
||||
const hat = (re) => aktive.find((m) => re.test(String(m.filename || '') + ' ' + String(m.title || '')));
|
||||
const optifine = hat(/optifine|optifabric/i);
|
||||
const sodium = hat(/sodium|embeddium|rubidium/i);
|
||||
if (optifine && sodium) {
|
||||
fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert',
|
||||
'OptiFine und Sodium (bzw. Embeddium/Rubidium) machen dasselbe und vertragen sich nicht. Schalte OptiFine ab – für Shader gibt es Iris als Ergänzung zu Sodium.',
|
||||
{ typ: 'mod-deaktivieren', dateiname: optifine.filename, name: optifine.title || 'OptiFine' });
|
||||
}
|
||||
|
||||
// --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges ---
|
||||
if (!funde.length) {
|
||||
const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im);
|
||||
if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) {
|
||||
const name = verdacht[1].split(/[,(]/)[0].trim();
|
||||
const mod = findeMod(mods, '', name);
|
||||
fund('verdacht', `Vermutlich verursacht von „${name}“`,
|
||||
`Laut Absturzbericht steckt wahrscheinlich „${verdacht[1].trim()}“ dahinter. Schalte den Mod testweise ab und starte erneut.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || name } : null);
|
||||
}
|
||||
}
|
||||
|
||||
return funde;
|
||||
}
|
||||
|
||||
module.exports = { analysiere, projektFuer };
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* crashhelp.js – Absturz-Assistent
|
||||
* ---------------------------------
|
||||
* Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts
|
||||
* und erkennt die häufigsten Ursachen. Jeder Fund bekommt eine verständliche
|
||||
* Erklärung und – wo möglich – eine Aktion, die der Launcher mit einem Klick
|
||||
* ausführen kann:
|
||||
*
|
||||
* { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen
|
||||
* { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht)
|
||||
* { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern
|
||||
* { typ: 'java-auto' } passendes Java automatisch wählen lassen
|
||||
* { typ: 'mods-ordner' } Mods-Ordner öffnen
|
||||
*
|
||||
* Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen.
|
||||
*/
|
||||
|
||||
// Mod-IDs, die keine installierbaren Mods sind
|
||||
const KEINE_MODS = new Set(['minecraft', 'java', 'fabricloader', 'fabric-loader', 'forge', 'neoforge', 'quilt_loader', 'quilt-loader', 'fml', 'mixinextras']);
|
||||
|
||||
// Mod-ID -> Modrinth-Projekt, wo sich beides unterscheidet
|
||||
const PROJEKT_FUER_ID = {
|
||||
fabric: 'fabric-api',
|
||||
'fabric-api-base': 'fabric-api',
|
||||
quilted_fabric_api: 'qsl',
|
||||
cloth_config: 'cloth-config',
|
||||
'cloth-config2': 'cloth-config',
|
||||
architectury: 'architectury-api',
|
||||
geckolib3: 'geckolib',
|
||||
};
|
||||
|
||||
// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …)
|
||||
const javaAusKlassenversion = (v) => Number(v) - 44;
|
||||
|
||||
function projektFuer(modId) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
// installierte Mod zu einer ID/einem Namen finden (für "abschalten")
|
||||
function findeMod(mods, modId, name) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
const kurz = id.replace(/[-_]/g, '');
|
||||
const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (mods || []).find((m) => {
|
||||
if (m.enabled === false) return false;
|
||||
const datei = String(m.filename || '').toLowerCase();
|
||||
const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz)))
|
||||
|| (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n)));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* text: zusammengeführter Text aus Bericht, Log und Konsole
|
||||
* ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] }
|
||||
* -> [{ schluessel, titel, text, aktion? }]
|
||||
*/
|
||||
function analysiere(text, ctx = {}) {
|
||||
const t = String(text || '');
|
||||
const funde = [];
|
||||
const schonDa = new Set();
|
||||
const fund = (schluessel, titel, erklaerung, aktion) => {
|
||||
if (schonDa.has(schluessel)) return;
|
||||
schonDa.add(schluessel);
|
||||
funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null });
|
||||
};
|
||||
const mods = ctx.mods || [];
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Fabric/Quilt) ---
|
||||
// "Mod 'Mod Menu' (modmenu) 7.2.2 requires any version of fabric-api, which is missing!"
|
||||
// neuere Fassung: "... requires any version of mod 'Fabric API' (fabric-api), which is missing!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version ([^\n,]+?)|any version) of (?:mod '([^']+)' \(([\w.-]+)\)|([\w.-]+)), which is missing/gi)) {
|
||||
const [, name, , version, fehltName, fehltIdNeu, fehltIdAlt] = m;
|
||||
const fehlt = fehltIdNeu || fehltIdAlt;
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
const anzeige = fehltName || fehlt;
|
||||
fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`,
|
||||
`Der Mod „${name}“ funktioniert nur zusammen mit „${anzeige}“${version ? ` (Version ${version.trim()})` : ''} – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: anzeige });
|
||||
}
|
||||
// Lösungsvorschlag von Fabric: "- Install fabric-api, any version."
|
||||
for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) {
|
||||
const fehlt = m[1];
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`,
|
||||
`Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
}
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Forge/NeoForge) ---
|
||||
// "Mod ID: 'architectury', Requested by: 'rei', Expected range: '[9.1.12,)', Actual version: '[MISSING]'"
|
||||
for (const m of t.matchAll(/Mod ID: '([\w.-]+)', Requested by: '([\w.-]+)', Expected range: '([^']*)', Actual version: '([^']*)'/gi)) {
|
||||
const [, fehlt, von, bereich, ist] = m;
|
||||
if (fehlt.toLowerCase() === 'minecraft') {
|
||||
const mod = findeMod(mods, von);
|
||||
fund(`mcversion:${von}`, `„${von}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`Der Mod „${von}“ ist für Minecraft ${bereich} gemacht, deine Instanz hat ${ctx.version || ist}.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || von } : null);
|
||||
continue;
|
||||
}
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
if (/MISSING/i.test(ist)) {
|
||||
fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`,
|
||||
`Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
} else {
|
||||
fund(`alt:${fehlt}`, `„${fehlt}“ ist zu alt oder zu neu`,
|
||||
`„${von}“ braucht „${fehlt}“ in der Version ${bereich}, installiert ist ${ist}. Aktualisiere „${fehlt}“ im Inhalts-Dialog („Nach Updates suchen“).`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mod für eine andere Minecraft-Version (Fabric) ---
|
||||
// "Mod 'X' (x) 1.0 requires version 1.20.4 of minecraft, but only the wrong version is present: 1.21!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version |any version of )?([^\n]*?) of (?:mod )?'?minecraft'?/gi)) {
|
||||
const [, name, id, fuer] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`mcversion:${id}`, `„${name}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`„${name}“ ist für Minecraft ${fuer.replace(/^version\s+/i, '').trim() || 'eine andere Version'} gemacht, deine Instanz hat ${ctx.version || 'eine andere'}. Abschalten oder eine passende Version des Mods installieren.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// Lösungsvorschlag: "- Remove mod 'X' (x)."
|
||||
for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) {
|
||||
const [, name, id] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`entfernen:${id}`, `„${name}“ verursacht den Fehler`,
|
||||
`Der Mod-Loader schlägt vor, „${name}“ zu entfernen – er verträgt sich nicht mit den anderen Mods oder dieser Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// "Mod 'A' (a) is incompatible with any version of mod 'B' (b)"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? is incompatible with [^\n]*?mod '([^']+)' \(([\w.-]+)\)/gi)) {
|
||||
const [, a, idA, b] = m;
|
||||
const mod = findeMod(mods, idA, a);
|
||||
fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`,
|
||||
`Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null);
|
||||
}
|
||||
|
||||
// --- Doppelte Mods ---
|
||||
if (/Duplicate mods found|Found duplicate mods|is provided by multiple mods|Mod ID ['"]?[\w.-]+['"]? is (?:provided|present) multiple times/i.test(t)) {
|
||||
fund('doppelt', 'Ein Mod ist doppelt installiert',
|
||||
'Mindestens ein Mod liegt zweimal im Mods-Ordner (z. B. zwei Versionen desselben Mods). Lösche die ältere Datei.',
|
||||
{ typ: 'mods-ordner' });
|
||||
}
|
||||
|
||||
// --- Falsche Java-Version ---
|
||||
const klasse = t.match(/UnsupportedClassVersionError[\s\S]{0,300}?class file version (\d+)\.\d+/i);
|
||||
const javaPflicht = t.match(/requires (?:at least )?Java (\d+)|Java (\d+) (?:or (?:newer|later|above)) is required|Unsupported Java detected \((\d+)/i);
|
||||
if (klasse || javaPflicht) {
|
||||
const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0);
|
||||
fund('java', noetig ? `Diese Instanz braucht Java ${noetig}` : 'Falsche Java-Version',
|
||||
`Minecraft oder ein Mod wurde für ${noetig ? `Java ${noetig}` : 'eine andere Java-Version'} gebaut, gestartet wurde mit einer älteren. AeroMC kann das passende Java automatisch wählen und bei Bedarf herunterladen.`,
|
||||
{ typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Alte Forge-Version verträgt aktuelles Java 8 nicht ---
|
||||
// Forge 1.16.5 vor 36.2.26 (und ähnlich alte Fassungen) nutzt eine interne
|
||||
// Java-Klasse, die es seit Java 8u321 so nicht mehr gibt.
|
||||
if (/sun\.security\.util\.ManifestEntryVerifier\.<init>/i.test(t) && /forge/i.test(String(ctx.loader || ''))) {
|
||||
fund('forge-alt', 'Diese Forge-Version ist zu alt für das aktuelle Java',
|
||||
'Ältere Forge-Versionen laufen mit aktuellem Java 8 nicht mehr (Fehler „ManifestEntryVerifier“). Eine neuere Forge-Version für dieselbe Minecraft-Version behebt das – Mods und Welten bleiben erhalten.',
|
||||
{ typ: 'forge-aktualisieren' });
|
||||
}
|
||||
|
||||
// --- Zu wenig Arbeitsspeicher ---
|
||||
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 2048;
|
||||
const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6));
|
||||
const neu = Math.min(grenze, jetzt + 2048);
|
||||
fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher',
|
||||
`Der Instanz waren ${Math.round(jetzt / 102.4) / 10} GB zugewiesen – zu wenig für diese Mods oder Welt.`
|
||||
+ (neu > jetzt ? ` Empfohlen: ${Math.round(neu / 102.4) / 10} GB.` : ' Mehr lässt dein PC kaum zu – weniger Mods oder eine kleinere Sichtweite helfen.'),
|
||||
neu > jetzt ? { typ: 'ram', mb: neu } : null);
|
||||
}
|
||||
if (/Could not reserve enough space for (?:\d+KB )?object heap|Invalid maximum heap size|Initial heap size set to a larger value than the maximum/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 4096;
|
||||
const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5)));
|
||||
fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt',
|
||||
`Java konnte die eingestellten ${Math.round(jetzt / 102.4) / 10} GB nicht reservieren – entweder ist gerade nicht genug frei oder es läuft ein 32-Bit-Java.`,
|
||||
neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Mixin-Fehler: ein bestimmter Mod passt nicht ---
|
||||
for (const m of t.matchAll(/Mixin (?:apply|transformation)[^\n]*? (?:for|from) mod ([\w.-]+) failed|from mod ([\w.-]+) failed injection check|mixins?\.json:[^\n]*? from mod ([\w.-]+)/gi)) {
|
||||
const id = m[1] || m[2] || m[3];
|
||||
if (!id || KEINE_MODS.has(id.toLowerCase())) continue;
|
||||
const mod = findeMod(mods, id);
|
||||
fund(`mixin:${id}`, `„${mod ? (mod.title || id) : id}“ passt nicht zu dieser Version`,
|
||||
`Der Mod „${id}“ greift tief in Minecraft ein und ist mit dieser Minecraft-Version oder einem anderen Mod nicht kompatibel. Schalte ihn ab oder installiere eine passende Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null);
|
||||
}
|
||||
|
||||
// --- Grafiktreiber ---
|
||||
if (/Pixel format not accelerated|WGL: The driver does not appear to support OpenGL|GLFW error 65542|OpenGL 3\.2 (?:is )?not supported|Couldn't set pixel format|No OpenGL context found/i.test(t)) {
|
||||
fund('treiber', 'Problem mit dem Grafiktreiber',
|
||||
'Minecraft konnte OpenGL nicht starten. Meist hilft es, den Grafiktreiber direkt vom Hersteller (NVIDIA, AMD oder Intel) zu aktualisieren. Bei Laptops: in den Windows-Grafikeinstellungen Java auf „Hohe Leistung“ stellen.');
|
||||
}
|
||||
|
||||
// --- Bekannte Unverträglichkeiten anhand der installierten Mods ---
|
||||
const aktive = mods.filter((m) => m.enabled !== false);
|
||||
const hat = (re) => aktive.find((m) => re.test(String(m.filename || '') + ' ' + String(m.title || '')));
|
||||
const optifine = hat(/optifine|optifabric/i);
|
||||
const sodium = hat(/sodium|embeddium|rubidium/i);
|
||||
if (optifine && sodium) {
|
||||
fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert',
|
||||
'OptiFine und Sodium (bzw. Embeddium/Rubidium) machen dasselbe und vertragen sich nicht. Schalte OptiFine ab – für Shader gibt es Iris als Ergänzung zu Sodium.',
|
||||
{ typ: 'mod-deaktivieren', dateiname: optifine.filename, name: optifine.title || 'OptiFine' });
|
||||
}
|
||||
|
||||
// --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges ---
|
||||
if (!funde.length) {
|
||||
const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im);
|
||||
if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) {
|
||||
const name = verdacht[1].split(/[,(]/)[0].trim();
|
||||
const mod = findeMod(mods, '', name);
|
||||
fund('verdacht', `Vermutlich verursacht von „${name}“`,
|
||||
`Laut Absturzbericht steckt wahrscheinlich „${verdacht[1].trim()}“ dahinter. Schalte den Mod testweise ab und starte erneut.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || name } : null);
|
||||
}
|
||||
}
|
||||
|
||||
return funde;
|
||||
}
|
||||
|
||||
module.exports = { analysiere, projektFuer };
|
||||
|
||||
+39
-4
@@ -9,8 +9,8 @@
|
||||
|
||||
/* ===================== Design-Auswahl in den Optionen ===================== */
|
||||
.design-raster{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
||||
.settings-card-wide .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
@media (max-width:900px){.settings-card-wide .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.settings-card .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
@media (max-width:900px){.settings-card .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.design-wahl{display:flex;flex-direction:column;gap:5px;padding:6px;border:1px solid var(--border);border-radius:8px;background:none;color:var(--text);font-family:inherit;font-size:12px;cursor:pointer;text-align:left}
|
||||
.design-wahl:hover{border-color:var(--sel-br)}
|
||||
.design-wahl.on{border-color:var(--sel-br);box-shadow:0 0 0 1px var(--sel-br);background:var(--sel)}
|
||||
@@ -94,6 +94,10 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card.sel{background:linear-gradient(180deg,rgba(var(--p-akzent),.24),rgba(var(--p-tief),.14));border-color:rgba(var(--p-akzent2),.55);box-shadow:0 10px 28px -14px rgba(var(--p-akzent),.95),0 0 0 1px rgba(var(--p-hell),.08) inset}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card.sel .card-name{background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));color:#fff;border-radius:999px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card-icon-img{border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card.laeuft::after{border-color:rgb(var(--p-akzent2));--accent:rgb(var(--p-akzent2))}
|
||||
:root.ui-animiert:is([data-design="aero"],[data-design="lila"]) .lichtpunkte{display:block}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .lichtpunkte i{background:rgb(var(--p-hell));box-shadow:0 0 calc(var(--g) * 3) 1px rgba(var(--p-akzent2),.85)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg .funke{box-shadow:0 0 5px 2px rgb(var(--p-hell)),0 0 14px 4px rgba(var(--p-akzent2),.8)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card-taste{border-radius:999px;background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .empty h2{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));-webkit-background-clip:text;background-clip:text;color:transparent;font-size:26px}
|
||||
|
||||
@@ -132,7 +136,7 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mods-filters select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mmc-bar select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .clip-length select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,:root:is([data-design="aero"],[data-design="lila"]) .path-row select,:root:is([data-design="aero"],[data-design="lila"]) select.s-wert,:root:is([data-design="aero"],[data-design="lila"]) input.s-wert,:root:is([data-design="aero"],[data-design="lila"]) .s-paar input,:root:is([data-design="aero"],[data-design="lila"]) .s-einheit input,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-farbzeile input[type=text],
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-feld select{background:var(--input);color:var(--text);border-color:var(--border);border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px}
|
||||
@@ -145,6 +149,9 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) input[type=checkbox],:root:is([data-design="aero"],[data-design="lila"]) input[type=radio],:root:is([data-design="aero"],[data-design="lila"]) input[type=range]{accent-color:rgb(var(--p-akzent))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes li::marker{color:rgb(var(--p-akzent2))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
|
||||
|
||||
/* Knöpfe – rund wie auf der Webseite */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn{border-radius:999px;padding:7px 15px;background:var(--surface);border-color:var(--border);color:var(--text);transition:background .15s,border-color .15s,transform .15s}
|
||||
@@ -190,6 +197,7 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .snav:hover{background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .snav.on{background:linear-gradient(120deg,rgba(var(--p-akzent),.30),rgba(var(--p-tief),.18));border-color:rgba(var(--p-akzent2),.40);box-shadow:none}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-card{background:var(--surface);border-color:var(--border);border-radius:16px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .schalter input:checked+i{background:linear-gradient(120deg,rgb(var(--p-akzent)),rgb(var(--p-akzent2)));box-shadow:0 0 10px -2px rgba(var(--p-akzent),.8)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-kv-row,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .about-brand{border-bottom-color:var(--border2)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .appinfo-item{background:rgba(var(--p-linie),.04);border-color:var(--border2);border-radius:12px}
|
||||
@@ -197,6 +205,12 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-hero h4{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 70%);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
|
||||
/* Kontextmenü, Hinweis, Bildlaufleisten */
|
||||
/* Erfolg-Meldung: Glas-Karte mit Leuchtrand wie die Webseite */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg{background:linear-gradient(135deg,rgba(var(--p-akzent),.22),var(--surface) 55%);border:1px solid rgba(var(--p-akzent),.45);border-radius:14px;
|
||||
box-shadow:0 0 0 1px rgba(255,255,255,.04) inset,0 18px 40px -12px rgba(0,0,0,.8),0 0 24px -8px rgba(var(--p-akzent),.7)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-symbol{border-radius:10px;background:rgba(var(--p-akzent),.18)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-kopf{color:rgb(var(--p-hell))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-karte{border-radius:12px;background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ctx{background:var(--surface);border-color:var(--border);border-radius:12px;box-shadow:0 18px 40px -12px rgba(0,0,0,.8)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item{border-radius:8px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)}
|
||||
@@ -267,6 +281,10 @@
|
||||
:root[data-design="minecraft"] .toolbar{
|
||||
background:url(assets/design/gras.png) 0 0/32px 8px repeat-x,url(assets/design/schiefer.png) 0 0/64px 64px;
|
||||
image-rendering:pixelated;border-bottom:2px solid #000;padding:11px 8px 6px}
|
||||
:root[data-design="minecraft"] .toolbar{position:relative;isolation:isolate}
|
||||
:root.ui-animiert[data-design="minecraft"] .toolbar::before{content:'';position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:.18;
|
||||
background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='512' height='48' shape-rendering='crispEdges' fill='white'><rect x='20' y='8' width='56' height='8'/><rect x='8' y='16' width='96' height='8'/><rect x='170' y='22' width='40' height='6'/><rect x='158' y='28' width='80' height='8'/><rect x='184' y='36' width='36' height='4'/><rect x='300' y='4' width='72' height='8'/><rect x='288' y='12' width='120' height='8'/><rect x='312' y='20' width='64' height='6'/><rect x='438' y='26' width='48' height='8'/><rect x='426' y='34' width='70' height='6'/></svg>") 0 0/512px 100% repeat-x;animation:wolken-ziehen 70s steps(512) infinite}
|
||||
@keyframes wolken-ziehen{to{background-position:512px 0}}
|
||||
:root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto}
|
||||
:root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff}
|
||||
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
|
||||
@@ -288,7 +306,7 @@
|
||||
:root[data-design="minecraft"] .mods-filters select,
|
||||
:root[data-design="minecraft"] .mmc-bar select,
|
||||
:root[data-design="minecraft"] .clip-length select,
|
||||
:root[data-design="minecraft"] .path-row input,
|
||||
:root[data-design="minecraft"] .path-row input,:root[data-design="minecraft"] .path-row select,:root[data-design="minecraft"] select.s-wert,:root[data-design="minecraft"] input.s-wert,:root[data-design="minecraft"] .s-paar input,:root[data-design="minecraft"] .s-einheit input,
|
||||
:root[data-design="minecraft"] .login-usercode,
|
||||
:root[data-design="minecraft"] .upd-notes,
|
||||
:root[data-design="minecraft"] .se-farbzeile input[type=text],
|
||||
@@ -319,6 +337,11 @@
|
||||
:root[data-design="minecraft"] .card.sel .card-name{background:none;color:#ffff7f;border-radius:0}
|
||||
:root[data-design="minecraft"] .card-taste{border-radius:0;background:#3c8527;box-shadow:inset -1px -1px 0 #1d4d13}
|
||||
:root[data-design="minecraft"] .card-icon-img{border-radius:0}
|
||||
:root[data-design="minecraft"] .card.laeuft::after{border-color:#7fd858;--accent:#7fd858;border-radius:0}
|
||||
/* Abbau-Risse: Download-Fortschritt auf dem Block, beim Fertigwerden springt er kurz */
|
||||
:root[data-design="minecraft"] .card.abbau .card-riss{display:block}
|
||||
:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon,:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon-img{animation:block-bricht .45s cubic-bezier(.3,.7,.4,1)}
|
||||
@keyframes block-bricht{0%{transform:scale(.8)}45%{transform:scale(1.14)}100%{transform:none}}
|
||||
:root[data-design="minecraft"] .empty h2{font-family:var(--pixel);font-weight:400;font-size:24px;color:#fff;text-shadow:3px 3px 0 #3f3f3f}
|
||||
:root[data-design="minecraft"] .empty p{color:#d0d0d0}
|
||||
|
||||
@@ -332,6 +355,8 @@
|
||||
:root[data-design="minecraft"] .hr{background:#000;height:2px;margin:6px 0}
|
||||
:root[data-design="minecraft"] .sbtn{margin-bottom:3px;padding:5px 2px}
|
||||
:root[data-design="minecraft"] .sbtn.play{font-family:var(--pixel);font-size:16px;font-weight:400;padding:6px 4px 7px;text-shadow:var(--mc-schatten)}
|
||||
:root[data-design="minecraft"] .sbtn.play::before,:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play::before{background:#fff;opacity:.28}
|
||||
:root[data-design="minecraft"] .sbtn.play.laedt .play-text{font-size:13px}
|
||||
|
||||
/* Statusleiste */
|
||||
:root[data-design="minecraft"] .statusbar{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-top:2px solid #000}
|
||||
@@ -391,6 +416,10 @@
|
||||
:root[data-design="minecraft"] .settings-hero h4{font-family:var(--pixel);font-size:24px;font-weight:400;text-shadow:3px 3px 0 #3f3f3f}
|
||||
:root[data-design="minecraft"] .settings-hero p{color:#d0d0d0}
|
||||
:root[data-design="minecraft"] .settings-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f}
|
||||
:root[data-design="minecraft"] .schalter i{border-radius:0;background:#1e1e1f;box-shadow:inset 2px 2px 0 #000}
|
||||
:root[data-design="minecraft"] .schalter i::after{border-radius:0;background:#c6c6c6;box-shadow:inset -2px -2px 0 #555,inset 2px 2px 0 #fff}
|
||||
:root[data-design="minecraft"] .schalter input:checked+i{background:#3c8527}
|
||||
:root[data-design="minecraft"] .s-zeile{border-top-color:rgba(0,0,0,.55)}
|
||||
:root[data-design="minecraft"] .settings-card h5{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
||||
:root[data-design="minecraft"] .settings-kv-row,
|
||||
:root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b}
|
||||
@@ -399,6 +428,12 @@
|
||||
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
||||
|
||||
/* Kontextmenü, Hinweis, Bildlaufleisten */
|
||||
/* Erfolg-Meldung wie im Spiel („Erfolg erzielt!“) */
|
||||
:root[data-design="minecraft"] .erfolg{background:#212121;border:2px solid #555;border-radius:0;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #141414,0 10px 24px rgba(0,0,0,.45);color:#fff;font-family:"AeroPixel","Segoe UI",sans-serif}
|
||||
:root[data-design="minecraft"] .erfolg-symbol{border-radius:0;background:#8b8b8b;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #fff}
|
||||
:root[data-design="minecraft"] .erfolg-kopf{color:#ffff55;font-size:14px;font-weight:400;letter-spacing:0;text-transform:none}
|
||||
:root[data-design="minecraft"] .erfolg-name{font-size:14px;font-weight:400}
|
||||
:root[data-design="minecraft"] .erfolg-karte{border-radius:0;border:2px solid #000;background:rgba(0,0,0,.35)}
|
||||
:root[data-design="minecraft"] .ctx{background:#1e1e1f;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a,0 8px 24px rgba(0,0,0,.5)}
|
||||
:root[data-design="minecraft"] .ctx-item{border-radius:0;text-shadow:1px 1px 0 #000}
|
||||
:root[data-design="minecraft"] .ctx-item:hover{background:#3c8527}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Erfolge im Minecraft-Stil („Erfolg erzielt!“).
|
||||
*
|
||||
* Bewertet wird aus dem, was der Launcher ohnehin weiß (Instanzen, Spielzeit,
|
||||
* Starts, Mods), plus ein paar Merkern für einzelne Aktionen (Umhang
|
||||
* hochgeladen, Skin im Editor übernommen, Leistungs-Paket …). Freigeschaltete
|
||||
* Erfolge landen mit Datum in userData/erfolge.json.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STUNDE = 3600;
|
||||
const LISTE = [
|
||||
{ id: 'erster-start', symbol: 'grass', titel: 'Los geht’s!', text: 'Starte zum ersten Mal ein Spiel mit AeroMC.', pruefe: (w) => w.starts >= 1 },
|
||||
{ id: 'starts-10', symbol: 'pickaxe', titel: 'Stammgast', text: 'Starte 10-mal ein Spiel.', pruefe: (w) => w.starts >= 10 },
|
||||
{ id: 'starts-100', symbol: 'gold', titel: 'Ohne mich geht nichts', text: 'Starte 100-mal ein Spiel.', pruefe: (w) => w.starts >= 100 },
|
||||
{ id: 'zeit-1', symbol: 'map', titel: 'Erste Stunde', text: 'Spiele insgesamt eine Stunde.', pruefe: (w) => w.sekunden >= STUNDE },
|
||||
{ id: 'zeit-10', symbol: 'sword', titel: 'Zeit vergeht wie im Flug', text: 'Spiele insgesamt 10 Stunden.', pruefe: (w) => w.sekunden >= 10 * STUNDE },
|
||||
{ id: 'zeit-50', symbol: 'diamond', titel: 'Abenteurer', text: 'Spiele insgesamt 50 Stunden.', pruefe: (w) => w.sekunden >= 50 * STUNDE },
|
||||
{ id: 'zeit-100', symbol: 'star', titel: 'Lebt in Minecraft', text: 'Spiele insgesamt 100 Stunden.', pruefe: (w) => w.sekunden >= 100 * STUNDE },
|
||||
{ id: 'instanzen-5', symbol: 'chest', titel: 'Sammler', text: 'Lege 5 Instanzen an.', pruefe: (w) => w.instanzen >= 5 },
|
||||
{ id: 'instanzen-10', symbol: 'book', titel: 'Archivar', text: 'Lege 10 Instanzen an.', pruefe: (w) => w.instanzen >= 10 },
|
||||
{ id: 'versionen-3', symbol: 'end', titel: 'Zeitreisender', text: 'Spiele 3 verschiedene Minecraft-Versionen.', pruefe: (w) => w.versionen >= 3 },
|
||||
{ id: 'erste-mod', symbol: 'redstone', titel: 'Bastler', text: 'Habe die erste Mod in einer Instanz.', pruefe: (w) => w.mods >= 1 },
|
||||
{ id: 'mods-50', symbol: 'potion', titel: 'Mod-Meister', text: 'Habe 50 Mods in deinen Instanzen.', pruefe: (w) => w.mods >= 50 },
|
||||
{ id: 'modpack', symbol: 'nether', titel: 'Das volle Paket', text: 'Installiere ein Modpack.', pruefe: (w) => w.modpacks >= 1 },
|
||||
{ id: 'freund', symbol: 'creeper', titel: 'Gemeinsam statt einsam', text: 'Füge deinen ersten Freund hinzu.', pruefe: (w) => w.freunde >= 1 },
|
||||
{ id: 'umhang', symbol: 'star', titel: 'Modeschöpfer', text: 'Lade einen eigenen AeroMC-Umhang hoch.', pruefe: (w) => w.merker.umhang },
|
||||
{ id: 'skin-editor', symbol: 'map', titel: 'Pixelkünstler', text: 'Übernimm einen im Skin-Editor gemalten Skin.', pruefe: (w) => w.merker['skin-editor'] },
|
||||
{ id: 'leistung', symbol: 'tnt', titel: 'Turbo', text: 'Installiere das Leistungs-Paket.', pruefe: (w) => w.merker.leistung },
|
||||
];
|
||||
|
||||
let datei = null;
|
||||
function init(userDataDir) { datei = path.join(userDataDir, 'erfolge.json'); }
|
||||
|
||||
function lies() {
|
||||
try { return JSON.parse(fs.readFileSync(datei, 'utf8')); } catch { return null; }
|
||||
}
|
||||
function schreibe(stand) {
|
||||
try { fs.writeFileSync(datei, JSON.stringify(stand, null, 1)); } catch { /* nicht schlimm */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* werte: { starts, sekunden, instanzen, versionen, mods, modpacks, freunde }
|
||||
* merker: zusätzliche Einzel-Aktionen ({ umhang: true } …)
|
||||
* -> { neu: [Erfolge], ersteAuswertung }
|
||||
*/
|
||||
function pruefe(werte, merker = {}) {
|
||||
const vorher = lies();
|
||||
const stand = vorher || { freigeschaltet: {}, merker: {} };
|
||||
Object.assign(stand.merker, merker);
|
||||
const w = Object.assign({ starts: 0, sekunden: 0, instanzen: 0, versionen: 0, mods: 0, modpacks: 0, freunde: 0 }, werte, { merker: stand.merker });
|
||||
const neu = [];
|
||||
const jetzt = new Date().toISOString();
|
||||
for (const e of LISTE) {
|
||||
if (stand.freigeschaltet[e.id]) continue;
|
||||
let erreicht = false;
|
||||
try { erreicht = !!e.pruefe(w); } catch { /* Wert fehlt */ }
|
||||
if (erreicht) { stand.freigeschaltet[e.id] = jetzt; neu.push(oeffentlich(e, jetzt)); }
|
||||
}
|
||||
if (neu.length || !vorher || Object.keys(merker).length) schreibe(stand);
|
||||
return { neu, ersteAuswertung: !vorher };
|
||||
}
|
||||
|
||||
const oeffentlich = (e, am) => ({ id: e.id, symbol: e.symbol, titel: e.titel, text: e.text, am: am || null });
|
||||
|
||||
/** Alle Erfolge mit Datum (null = noch offen). */
|
||||
function liste() {
|
||||
const stand = lies() || { freigeschaltet: {} };
|
||||
return LISTE.map((e) => oeffentlich(e, stand.freigeschaltet[e.id] || null));
|
||||
}
|
||||
|
||||
module.exports = { init, pruefe, liste };
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Garderobe: gespeicherte Skins und Umhänge zum schnellen Wechseln.
|
||||
*
|
||||
* Mojang merkt sich keine früheren Skins – wer einen neuen hochlädt, verliert
|
||||
* den alten. Die Garderobe hält Skins (samt Armform) und eigene AeroMC-Umhänge
|
||||
* als PNG-Dateien im Launcher-Datenordner fest; vor jedem Skinwechsel im
|
||||
* Launcher wird der bisherige Skin automatisch gesichert. Gleiche Dateien
|
||||
* (gleiche Prüfsumme) werden nicht doppelt abgelegt.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const MAX_AUTOMATISCH = 20; // ältere automatische Sicherungen fliegen raus
|
||||
let ordner = null;
|
||||
|
||||
function init(userDataDir) {
|
||||
ordner = path.join(userDataDir, 'garderobe');
|
||||
}
|
||||
|
||||
const indexDatei = () => path.join(ordner, 'garderobe.json');
|
||||
function lies() {
|
||||
try { return JSON.parse(fs.readFileSync(indexDatei(), 'utf8')); } catch { return []; }
|
||||
}
|
||||
function schreibe(liste) {
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
fs.writeFileSync(indexDatei(), JSON.stringify(liste, null, 1));
|
||||
}
|
||||
|
||||
/** Alle Einträge, neueste zuerst, jeweils mit Bild als data:-Adresse. */
|
||||
function liste() {
|
||||
return lies()
|
||||
.filter((e) => fs.existsSync(path.join(ordner, e.datei)))
|
||||
.sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)))
|
||||
.map((e) => Object.assign({}, e, {
|
||||
dataUri: 'data:image/png;base64,' + fs.readFileSync(path.join(ordner, e.datei)).toString('base64'),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Skin/Umhang ab. art: 'skin' | 'umhang', daten: PNG-Buffer.
|
||||
* Gibt es dieselbe Datei schon, wird nur ggf. aus "automatisch" ein fester Eintrag.
|
||||
*/
|
||||
function hinzufuegen({ art, daten, name, variante, automatisch }) {
|
||||
const hash = crypto.createHash('sha1').update(daten).digest('hex');
|
||||
const eintraege = lies();
|
||||
const vorhanden = eintraege.find((e) => e.art === art && e.hash === hash);
|
||||
if (vorhanden) {
|
||||
if (!automatisch && vorhanden.automatisch) {
|
||||
vorhanden.automatisch = false;
|
||||
if (name) vorhanden.name = name;
|
||||
schreibe(eintraege);
|
||||
}
|
||||
return { eintrag: vorhanden, neu: false };
|
||||
}
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
const id = Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
|
||||
const datei = `${art}-${id}.png`;
|
||||
fs.writeFileSync(path.join(ordner, datei), daten);
|
||||
const eintrag = {
|
||||
id, art, hash, datei,
|
||||
name: String(name || '').trim().slice(0, 40) || (art === 'skin' ? 'Skin' : 'Umhang'),
|
||||
variante: art === 'skin' ? (variante === 'slim' ? 'slim' : 'classic') : undefined,
|
||||
automatisch: !!automatisch,
|
||||
erstellt: new Date().toISOString(),
|
||||
};
|
||||
eintraege.push(eintrag);
|
||||
// automatische Sicherungen begrenzen (feste Einträge bleiben immer)
|
||||
const auto = eintraege.filter((e) => e.automatisch).sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)));
|
||||
for (const alt of auto.slice(MAX_AUTOMATISCH)) loescheIntern(eintraege, alt.id);
|
||||
schreibe(eintraege);
|
||||
return { eintrag, neu: true };
|
||||
}
|
||||
|
||||
function loescheIntern(eintraege, id) {
|
||||
const i = eintraege.findIndex((e) => e.id === id);
|
||||
if (i < 0) return false;
|
||||
try { fs.unlinkSync(path.join(ordner, eintraege[i].datei)); } catch { /* schon weg */ }
|
||||
eintraege.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function loeschen(id) {
|
||||
const eintraege = lies();
|
||||
const ok = loescheIntern(eintraege, id);
|
||||
if (ok) schreibe(eintraege);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function umbenennen(id, name) {
|
||||
const eintraege = lies();
|
||||
const e = eintraege.find((x) => x.id === id);
|
||||
if (!e) return false;
|
||||
e.name = String(name || '').trim().slice(0, 40) || e.name;
|
||||
e.automatisch = false; // umbenannt = bewusst behalten
|
||||
schreibe(eintraege);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Eintrag samt Dateipfad (zum Anziehen). */
|
||||
function holen(id) {
|
||||
const e = lies().find((x) => x.id === id);
|
||||
return e ? Object.assign({}, e, { pfad: path.join(ordner, e.datei) }) : null;
|
||||
}
|
||||
|
||||
module.exports = { init, liste, hinzufuegen, loeschen, umbenennen, holen };
|
||||
+273
-281
@@ -38,6 +38,7 @@
|
||||
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
|
||||
<main class="workspace">
|
||||
<div class="list-area">
|
||||
<div id="lichtpunkte" class="lichtpunkte" aria-hidden="true"></div>
|
||||
<canvas id="skin-live" class="skin-live hidden"></canvas>
|
||||
<div id="grid" class="list"></div>
|
||||
<section id="empty" class="empty hidden">
|
||||
@@ -54,7 +55,7 @@
|
||||
<div id="p-name" class="side-title">Instanz</div>
|
||||
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
|
||||
<div class="hr"></div>
|
||||
<button id="p-play" class="sbtn play">Starten</button>
|
||||
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
|
||||
<button id="p-offline" class="sbtn">Offline starten</button>
|
||||
<div class="hr"></div>
|
||||
<button id="p-edit" class="sbtn">Instanz bearbeiten</button>
|
||||
@@ -246,87 +247,62 @@
|
||||
<button class="snav on" data-pane="pane-general">Allgemein</button>
|
||||
<button class="snav" data-pane="pane-appearance">Darstellung</button>
|
||||
<button class="snav" data-pane="pane-minecraft">Spiel</button>
|
||||
<button class="snav" data-pane="pane-start">Start & Java</button>
|
||||
<button class="snav" data-pane="pane-start">Java & Erweitert</button>
|
||||
<button class="snav" data-pane="pane-online">Freunde & Umhänge</button>
|
||||
<button class="snav" data-pane="pane-network">Netzwerk</button>
|
||||
<button class="snav" data-pane="pane-accounts">Konten</button>
|
||||
<button class="snav" data-pane="pane-backup">Backup</button>
|
||||
<button class="snav" data-pane="pane-info">Info</button>
|
||||
</nav>
|
||||
<div class="settings-content">
|
||||
<!-- Aufbau: jede Karte enthält Zeilen – links Name + kurze Erklärung, rechts das Bedienelement -->
|
||||
<div id="pane-general" class="spane">
|
||||
<div class="settings-hero">
|
||||
<h4>Allgemein</h4>
|
||||
<p>Globale Launcher-Optionen, Instanzverwaltung und sichtbare Bereiche.</p>
|
||||
<p>Wie sich der Launcher verhält und wo deine Instanzen liegen.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Instanzliste</h5>
|
||||
<label class="field">
|
||||
<span>Instanzen sortieren</span>
|
||||
<select id="s-sortby">
|
||||
<option value="lastPlayed">Nach letztem Start</option>
|
||||
<option value="name">Nach Name</option>
|
||||
</select>
|
||||
</label>
|
||||
<h5>Launcher</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Mit Windows starten</b><small>Startet AeroMC beim Anmelden im Infobereich neben der Uhr. Solange aktiv, schließt das X den Launcher nicht, sondern legt ihn dort ab.</small></div>
|
||||
<label class="schalter"><input id="s-autostart" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Windows-Benachrichtigungen</b><small>Meldet, wenn ein großer Download oder ein Backup im Hintergrund fertig ist – nur, solange der Launcher nicht im Vordergrund ist.</small></div>
|
||||
<label class="schalter"><input id="s-notify" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Bedrock-Bereich anzeigen</b><small id="s-bedrock-hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small></div>
|
||||
<label class="schalter"><input id="s-bedrock" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>Instanzen</h5>
|
||||
<label class="field">
|
||||
<span>Instanz-Verzeichnis</span>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Instanz-Verzeichnis</b><small>Jede Instanz liegt hier als Unterordner mit ihrem Namen.</small></div>
|
||||
<div class="path-row">
|
||||
<input id="s-instances-dir" type="text" readonly />
|
||||
<button id="s-pick-dir" class="btn">Wählen…</button>
|
||||
</div>
|
||||
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Bereiche</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-bedrock" type="checkbox" /> <span>Bedrock-Bereich anzeigen</span>
|
||||
</label>
|
||||
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Benachrichtigungen</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-notify" type="checkbox" /> <span>Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird</span>
|
||||
</label>
|
||||
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Freundesystem</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-friends" type="checkbox" /> <span>Freundesystem aktivieren</span>
|
||||
</label>
|
||||
<small class="hint">Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.</small>
|
||||
<label class="checkbox-field" style="margin-top:10px">
|
||||
<input id="s-friends-server" type="checkbox" /> <span>Server, auf dem ich spiele, für Freunde sichtbar</span>
|
||||
</label>
|
||||
<small class="hint">Ausgeschaltet sehen Freunde nur, dass du spielst – nicht wo. „Beitreten“ ist dann bei dir nicht möglich, Einladungen kannst du aber weiterhin verschicken.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Autostart</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-autostart" type="checkbox" /> <span>Mit Windows starten (im Infobereich)</span>
|
||||
</label>
|
||||
<small class="hint">Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden – über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.</small>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Sortierung</b><small>Reihenfolge der Instanzen in der Übersicht.</small></div>
|
||||
<select id="s-sortby" class="s-wert">
|
||||
<option value="lastPlayed">Nach letztem Start</option>
|
||||
<option value="name">Nach Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-appearance" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Darstellung</h4>
|
||||
<p>Optik der Oberfläche und visuelle Extras für die Instanzansicht.</p>
|
||||
<p>Design, Bewegung und der Skin im Hintergrund.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>Design</h5>
|
||||
<div class="field">
|
||||
<select id="s-theme" class="hidden">
|
||||
@@ -356,130 +332,121 @@
|
||||
</div>
|
||||
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Icons</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-iso3d" type="checkbox" /> <span>Block-Symbole räumlich darstellen (3D)</span>
|
||||
</label>
|
||||
<h5>Bewegung & Effekte</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Startanimation</b><small>Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf. Gilt ab dem nächsten Start.</small></div>
|
||||
<label class="schalter"><input id="s-start-animation" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Bewegte Oberfläche</b><small>Die Skin-Figur atmet und schaut zum Mauszeiger, Fenster wachsen aus dem Knopf, Designs blenden weich über.</small></div>
|
||||
<label class="schalter"><input id="s-ui-animationen" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Räumliche Block-Symbole</b><small>Zeigt die Block-Symbole der Instanzen in 3D.</small></div>
|
||||
<label class="schalter"><input id="s-iso3d" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Animationen</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-start-animation" type="checkbox" /> <span>Startanimation beim Öffnen des Launchers</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-ui-animationen" type="checkbox" /> <span>Bewegte Oberfläche</span>
|
||||
</label>
|
||||
<small class="hint">Startanimation: Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (ab dem nächsten Start, nicht im Infobereich). Bewegte Oberfläche: Die Skin-Figur atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden ein, Designs blenden weich über.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Skin-Hintergrund</h5>
|
||||
<label class="field">
|
||||
<span>Quelle <small class="opt">(optional)</small></span>
|
||||
<select id="s-bgskin"><option value="">Aus</option></select>
|
||||
<small class="hint">Zeigt den Minecraft-Skin des gewählten Kontos als Hintergrund im Instanzbereich.</small>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Pose</span>
|
||||
<select id="s-bgskin-pose">
|
||||
<option value="walking">Laufend</option>
|
||||
<option value="standing">Stehend</option>
|
||||
<option value="legends">Heldenhaft</option>
|
||||
<option value="bedwars">Kampfhaltung</option>
|
||||
<option value="hoverboard">Schwebend</option>
|
||||
<option value="sitting">Sitzend</option>
|
||||
<option value="spooky">Unheimlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Armform</span>
|
||||
<select id="s-bgskin-model" title="Bei „Automatisch“ wird die Armform übernommen, die beim Konto eingestellt ist">
|
||||
<option value="auto">Automatisch (vom Konto)</option>
|
||||
<option value="classic">Normal (breite Arme, Steve)</option>
|
||||
<option value="slim">Schmal (Alex)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Deckkraft: <span id="s-bgskin-val">55</span> %</span>
|
||||
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
|
||||
</label>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Quelle</b><small>Zeigt den Skin eines Kontos groß im Instanzbereich.</small></div>
|
||||
<select id="s-bgskin" class="s-wert"><option value="">Aus</option></select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Pose</b></div>
|
||||
<select id="s-bgskin-pose" class="s-wert">
|
||||
<option value="walking">Laufend</option>
|
||||
<option value="standing">Stehend</option>
|
||||
<option value="legends">Heldenhaft</option>
|
||||
<option value="bedwars">Kampfhaltung</option>
|
||||
<option value="hoverboard">Schwebend</option>
|
||||
<option value="sitting">Sitzend</option>
|
||||
<option value="spooky">Unheimlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Armform</b><small>„Automatisch“ übernimmt die Einstellung des Kontos.</small></div>
|
||||
<select id="s-bgskin-model" class="s-wert">
|
||||
<option value="auto">Automatisch (vom Konto)</option>
|
||||
<option value="classic">Normal (breite Arme, Steve)</option>
|
||||
<option value="slim">Schmal (Alex)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Deckkraft</b></div>
|
||||
<div class="s-wert s-regler"><input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" /><span><span id="s-bgskin-val">55</span> %</span></div>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Konten durchwechseln</b><small>Bei 2 oder mehr angemeldeten Konten wechselt der Skin der Reihe nach.</small></div>
|
||||
<label class="schalter"><input id="s-bgskin-cycle" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wechsel alle</b></div>
|
||||
<div class="s-wert s-einheit"><input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" /><span>Sekunden</span></div>
|
||||
</div>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-bgskin-cycle" type="checkbox" /> <span>Bei mehreren angemeldeten Konten automatisch durchwechseln</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Wechsel alle <small class="opt">(Sekunden)</small></span>
|
||||
<input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" />
|
||||
<small class="hint">Läuft nur, wenn 2 oder mehr Konten angemeldet sind, und beginnt nach dem letzten Konto wieder von vorn.</small>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-minecraft" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Spiel</h4>
|
||||
<p>Standardwerte für neue und bestehende Instanzen ohne eigene Überschreibung.</p>
|
||||
<p>Standardwerte für alle Instanzen ohne eigene Einstellung – und was beim Spielen passiert.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Speicher</h5>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Standard-RAM min. (MB)</span>
|
||||
<input id="s-min-mem" type="number" min="512" step="256" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Standard-RAM max. (MB)</span>
|
||||
<input id="s-max-mem" type="number" min="512" step="256" />
|
||||
</label>
|
||||
<h5>Leistung & Fenster</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Arbeitsspeicher</b><small>Mindestens / höchstens, in MB.</small></div>
|
||||
<div class="s-wert s-paar"><input id="s-min-mem" type="number" min="512" step="256" aria-label="RAM min. (MB)" /><span>–</span><input id="s-max-mem" type="number" min="512" step="256" aria-label="RAM max. (MB)" /></div>
|
||||
</div>
|
||||
<small class="hint">Gilt für alle Instanzen ohne eigene RAM-Einstellung.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Fenster</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-mc-max" type="checkbox" /> <span>Minecraft maximiert starten</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Fensterbreite</span><input id="s-mc-width" type="number" min="320" step="10" /></label>
|
||||
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Maximiert starten</b><small>Minecraft öffnet sich bildschirmfüllend.</small></div>
|
||||
<label class="schalter"><input id="s-mc-max" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Fenstergröße</b><small>Breite × Höhe, wenn nicht maximiert.</small></div>
|
||||
<div class="s-wert s-paar"><input id="s-mc-width" type="number" min="320" step="10" aria-label="Fensterbreite" /><span>×</span><input id="s-mc-height" type="number" min="240" step="10" aria-label="Fensterhöhe" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Mods</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-auto-mod-updates" type="checkbox" /> <span>Vor jedem Start nach Mod-Updates suchen und installieren</span>
|
||||
</label>
|
||||
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
|
||||
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Umhänge</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-aeromc-capes" type="checkbox" /> <span>AeroMC-Umhänge im Spiel anzeigen</span>
|
||||
</label>
|
||||
<small class="hint">Zeigt deinen eigenen Umhang und die anderer AeroMC-Spieler – in jeder Minecraft-Version und mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small>
|
||||
<h5>Konsole</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Während des Spiels anzeigen</b></div>
|
||||
<label class="schalter"><input id="s-console-launch" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Nach dem Beenden schließen</b></div>
|
||||
<label class="schalter"><input id="s-console-autoclose" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Bei einem Absturz anzeigen</b></div>
|
||||
<label class="schalter"><input id="s-console-crash" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Zeilen merken</b><small>Ältere Zeilen fallen aus der Konsole heraus.</small></div>
|
||||
<input id="s-console-lines" class="s-wert s-zahl" type="number" min="1000" step="1000" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-start" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Start & Java</h4>
|
||||
<p>Runtime-Auswahl, Startverhalten und globale Befehle für alle Instanzen.</p>
|
||||
<h4>Java & Erweitert</h4>
|
||||
<p>Welches Java startet und was zusätzlich vor oder nach dem Spiel läuft.</p>
|
||||
</div>
|
||||
<div class="settings-grid settings-grid-stack">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Java</h5>
|
||||
<label class="field">
|
||||
<span>Java (java.exe)</span>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Java-Programm</b><small id="s-java-status">–</small></div>
|
||||
<div class="path-row">
|
||||
<input id="s-java" type="text" placeholder="leer = java aus PATH" />
|
||||
<button id="s-pick-java" class="btn">Wählen…</button>
|
||||
@@ -488,102 +455,100 @@
|
||||
<select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
|
||||
<button id="s-scan-java" class="btn">Erkennen</button>
|
||||
</div>
|
||||
<small id="s-java-status" class="hint">–</small>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-auto-java" type="checkbox" /> <span>Fehlende Java-Version automatisch herunterladen</span>
|
||||
</label>
|
||||
<small class="hint">Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.</small>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Fehlendes Java herunterladen</b><small>Holt beim Start die passende Version von Eclipse Temurin, wenn keine geeignete gefunden wird (rund 45 MB je Version).</small></div>
|
||||
<label class="schalter"><input id="s-auto-java" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Konsole</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-launch" type="checkbox" /> <span>Konsole anzeigen, während das Spiel läuft</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-autoclose" type="checkbox" /> <span>Konsole automatisch schließen, nachdem das Spiel beendet wurde</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-crash" type="checkbox" /> <span>Konsole anzeigen, wenn das Spiel abstürzt</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Historie-Limit (Zeilen)</span>
|
||||
<input id="s-console-lines" type="number" min="1000" step="1000" />
|
||||
</label>
|
||||
<h5>Befehle</h5>
|
||||
<small class="hint s-vorab">Für Fortgeschrittene. Befehle aus dem Bearbeiten-Dialog einer Instanz haben Vorrang.</small>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Vor dem Start</b></div>
|
||||
<input id="s-global-pre" class="s-wert s-breit" type="text" placeholder="läuft vor jedem Spielstart" />
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Nach dem Beenden</b></div>
|
||||
<input id="s-global-post" class="s-wert s-breit" type="text" placeholder="läuft nach jedem Beenden" />
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wrapper</b><small>Startet Java über dieses Programm.</small></div>
|
||||
<input id="s-wrapper" class="s-wert s-breit" type="text" placeholder="z. B. prime-run" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="pane-online" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Freunde & Umhänge</h4>
|
||||
<p>Was andere AeroMC-Spieler von dir sehen.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Globale Befehle</h5>
|
||||
<label class="field">
|
||||
<span>Pre-Launch-Befehl</span>
|
||||
<input id="s-global-pre" type="text" placeholder="läuft vor jedem Spielstart" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Post-Exit-Befehl</span>
|
||||
<input id="s-global-post" type="text" placeholder="läuft nach jedem Beenden" />
|
||||
</label>
|
||||
<small class="hint">Instanzspezifische Befehle aus dem Bearbeiten-Dialog haben Vorrang.</small>
|
||||
<h5>Freundesystem</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Freundesystem aktivieren</b><small>Aus: niemand kann dich finden oder hinzufügen. Bestehende Freunde bleiben, sehen dich aber als offline.</small></div>
|
||||
<label class="schalter"><input id="s-friends" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Server für Freunde sichtbar</b><small>Aus: Freunde sehen nur, dass du spielst, nicht wo – „Beitreten“ geht dann nicht, Einladungen schon.</small></div>
|
||||
<label class="schalter"><input id="s-friends-server" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Externe Programme</h5>
|
||||
<label class="field">
|
||||
<span>Wrapper-Befehl</span>
|
||||
<input id="s-wrapper" type="text" placeholder="z. B. prime-run (leer = kein Wrapper)" />
|
||||
</label>
|
||||
<small class="hint">Startet Java über dieses Programm. Java-Pfad und Argumente werden automatisch angehängt.</small>
|
||||
<section class="settings-card">
|
||||
<h5>Umhänge</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>AeroMC-Umhänge im Spiel</b><small>Zeigt deinen Umhang und den anderer AeroMC-Spieler – in jeder Version, mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small></div>
|
||||
<label class="schalter"><input id="s-aeromc-capes" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-network" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Netzwerk</h4>
|
||||
<p>Verbindungsoptionen für Downloads und externe Dienste.</p>
|
||||
<p>Proxy für Downloads und der Zugang zu CurseForge.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>Proxy</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-proxy-enabled" type="checkbox" /> <span>HTTP-Proxy für Downloads verwenden</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
|
||||
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>HTTP-Proxy verwenden</b><small>Nur nötig, wenn dein Netzwerk Downloads nur über einen Proxy erlaubt.</small></div>
|
||||
<label class="schalter"><input id="s-proxy-enabled" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
|
||||
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
||||
<div class="s-felder">
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
|
||||
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
|
||||
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>CurseForge</h5>
|
||||
<label class="field">
|
||||
<span>Eigener API-Key <small class="opt">(optional)</small></span>
|
||||
<small class="hint s-vorab">Nur für CurseForge-Modpacks und -Mods nötig. Modrinth funktioniert immer ohne.</small>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Eigener API-Key</b><small>Von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder ein Organisationsschlüssel. Nach dem Eintragen speichern.</small></div>
|
||||
<div class="path-row">
|
||||
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
|
||||
<button type="button" id="s-cf-test" class="btn">Prüfen</button>
|
||||
</div>
|
||||
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
|
||||
<small id="s-cf-test-status" class="hint"></small>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Weiterleitung <small class="opt">(optional)</small></span>
|
||||
<input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Token <small class="opt">(falls verlangt)</small></span>
|
||||
<input id="s-cf-proxy-token" type="password" />
|
||||
</label>
|
||||
</div>
|
||||
<small class="hint">Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.</small>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Weiterleitung</b><small>Ein Server, der den Schlüssel für dich hält.</small></div>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Adresse</span><input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" /></label>
|
||||
<label class="field"><span>Token <small class="opt">(falls verlangt)</small></span><input id="s-cf-proxy-token" type="password" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-accounts" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Konten</h4>
|
||||
@@ -604,66 +569,58 @@
|
||||
<div id="pane-backup" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Backup</h4>
|
||||
<p>Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.</p>
|
||||
<p>Sicherungen aller Instanzen und deiner Welten.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Globales Backup</h5>
|
||||
<small class="hint">Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.</small>
|
||||
<div class="path-row" style="margin-top:14px">
|
||||
<section class="settings-card">
|
||||
<h5>Jetzt sichern</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Komplett-Backup</b><small>Alle Instanzen und Launcher-Einstellungen als ZIP unter Dokumente\AeroMC Launcher\Backups. Konten und Anmeldedaten werden nicht gesichert.</small></div>
|
||||
<button id="s-backup-global" class="btn btn-primary">Backup erstellen</button>
|
||||
<span id="s-backup-status" class="hint">Noch kein Backup erstellt.</span>
|
||||
</div>
|
||||
<small id="s-backup-status" class="hint s-status">Noch kein Backup erstellt.</small>
|
||||
<div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true">
|
||||
<div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div>
|
||||
<div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Automatisch sichern</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-autobackup" type="checkbox" /> <span>Sicherungen nach Zeitplan erstellen</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Wie oft</span>
|
||||
<select id="s-autobackup-every">
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Wie viele behalten</span>
|
||||
<input id="s-autobackup-keep" type="number" min="1" max="50" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-autobackup-launch" type="checkbox" /> <span>Zusätzlich vor jedem Spielstart sichern</span>
|
||||
</label>
|
||||
<small class="hint">Eine Sicherung umfasst <em>alle</em> Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.</small>
|
||||
<small id="s-autobackup-last" class="hint">Noch keine automatische Sicherung.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Welten</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-auto-world" type="checkbox" /> <span>Nach jeder Spielsitzung die zuletzt gespielte Welt sichern</span>
|
||||
</label>
|
||||
<small class="hint">Läuft im Hintergrund, sobald Minecraft beendet ist. Pro Welt bleiben die letzten 5 Stände erhalten – unter Dokumente\AeroMC Launcher\Welten-Sicherungen\<Instanz>\Automatisch.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Wiederherstellen</h5>
|
||||
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small>
|
||||
<label class="field backup-select-field">
|
||||
<span>Verfügbares Backup</span>
|
||||
<select id="s-restore-backup"></select>
|
||||
</label>
|
||||
<div class="path-row" style="margin-top:14px">
|
||||
<button id="s-restore-global" class="btn">Backup wiederherstellen</button>
|
||||
<span id="s-restore-status" class="hint">Noch kein Backup wiederhergestellt.</span>
|
||||
<h5>Automatisch sichern</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Nach Zeitplan sichern</b><small id="s-autobackup-last">Noch keine automatische Sicherung.</small></div>
|
||||
<label class="schalter"><input id="s-autobackup" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wie oft</b></div>
|
||||
<select id="s-autobackup-every" class="s-wert">
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wie viele behalten</b><small>Ältere automatische Sicherungen werden gelöscht.</small></div>
|
||||
<input id="s-autobackup-keep" class="s-wert s-zahl" type="number" min="1" max="50" />
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Zusätzlich vor jedem Start</b><small>Der Start wartet, bis die Sicherung fertig ist – bei vielen Instanzen kann das einige Minuten dauern.</small></div>
|
||||
<label class="schalter"><input id="s-autobackup-launch" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Welt nach dem Spielen sichern</b><small>Sichert nach jeder Sitzung die zuletzt gespielte Welt im Hintergrund. Je Welt bleiben die letzten 5 Stände (Dokumente\AeroMC Launcher\Welten-Sicherungen).</small></div>
|
||||
<label class="schalter"><input id="s-auto-world" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<h5>Wiederherstellen</h5>
|
||||
<small class="hint s-vorab s-warnung">Achtung: Alle vorhandenen Instanzen und die Launcher-Einstellungen werden mit dem Inhalt des Backups überschrieben. Konten bleiben unverändert.</small>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Verfügbares Backup</b></div>
|
||||
<div class="path-row">
|
||||
<select id="s-restore-backup"></select>
|
||||
<button id="s-restore-global" class="btn">Wiederherstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
<small id="s-restore-status" class="hint s-status">Noch kein Backup wiederhergestellt.</small>
|
||||
<div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true">
|
||||
<div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div>
|
||||
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
|
||||
@@ -671,7 +628,6 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-info" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Info</h4>
|
||||
@@ -821,6 +777,7 @@
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="m-open-folder" class="btn" data-ic="folder">mods-Ordner</button>
|
||||
<button id="m-leistung" class="btn" title="Bewährte Performance-Mods passend zu Version und Loader installieren (Sodium, Lithium, FerriteCore …)">⚡ Leistungs-Paket</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
</div>
|
||||
@@ -1115,10 +1072,19 @@
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="stats-tiles" class="stats-tiles"></div>
|
||||
<div class="friends-section-title">Spielzeit pro Instanz</div>
|
||||
<div id="stats-list" class="stats-list" role="list"></div>
|
||||
<p id="stats-unplayed" class="hint"></p>
|
||||
<div class="mods-tabs stats-tabs">
|
||||
<button class="mtab on" data-stats-reiter="spielzeit">Spielzeit</button>
|
||||
<button class="mtab" data-stats-reiter="erfolge">Erfolge <span id="erfolge-zahl"></span></button>
|
||||
</div>
|
||||
<div id="stats-reiter-spielzeit">
|
||||
<div id="stats-tiles" class="stats-tiles"></div>
|
||||
<div class="friends-section-title">Spielzeit pro Instanz</div>
|
||||
<div id="stats-list" class="stats-list" role="list"></div>
|
||||
<p id="stats-unplayed" class="hint"></p>
|
||||
</div>
|
||||
<div id="stats-reiter-erfolge" class="hidden">
|
||||
<div id="erfolge-liste" class="erfolge-liste"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="spacer"></div>
|
||||
@@ -1136,13 +1102,20 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="friends-disabled-hint" class="hint hidden">Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.</p>
|
||||
<div id="friends-content">
|
||||
<div class="field-row">
|
||||
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
|
||||
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
|
||||
<div id="friends-content" class="freunde-raster">
|
||||
<div class="freunde-spalte">
|
||||
<div class="field-row">
|
||||
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
|
||||
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
|
||||
</div>
|
||||
<div id="friends-requests-box"></div>
|
||||
<div id="friends-list-box"></div>
|
||||
</div>
|
||||
<!-- 3D-Figur des ausgewählten Freundes (Skin + Umhang), neben der Liste -->
|
||||
<div id="freund-buehne" class="freund-buehne hidden">
|
||||
<canvas id="freund-figur"></canvas>
|
||||
<div class="freund-buehne-text"><b id="freund-buehne-name"></b><span id="freund-buehne-status"></span></div>
|
||||
</div>
|
||||
<div id="friends-requests-box"></div>
|
||||
<div id="friends-list-box"></div>
|
||||
</div>
|
||||
<!-- Unterhaltung mit einem Freund -->
|
||||
<div id="friends-chat" class="friends-chat hidden">
|
||||
@@ -1242,6 +1215,10 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="skin-konto" class="hint">–</p>
|
||||
<div id="skin-armform-hinweis" class="skin-armform-hinweis hidden">
|
||||
<span id="skin-armform-text"></span>
|
||||
<button id="skin-armform-fix" class="btn btn-mini btn-primary"></button>
|
||||
</div>
|
||||
<div class="skin-vorschau">
|
||||
<div class="skin-spalte">
|
||||
<div class="skin-label">Aktuell</div>
|
||||
@@ -1291,6 +1268,18 @@
|
||||
</div>
|
||||
<small id="skin-cape-status" class="hint">Den AeroMC-Umhang sehen im Spiel alle, die AeroMC nutzen oder den Mod „AeroMC Umhänge“ (Fabric/Quilt) installiert haben – dort hat er Vorrang vor dem offiziellen.</small>
|
||||
</div>
|
||||
|
||||
<div class="skin-garderobe">
|
||||
<div class="garderobe-kopf">
|
||||
<div class="skin-label">Garderobe <small class="opt">(gespeicherte Skins und Umhänge – mit einem Klick wieder anziehen)</small></div>
|
||||
<div class="garderobe-knoepfe">
|
||||
<button id="gard-skin-speichern" class="btn btn-mini" title="Den Skin, den du gerade trägst, in der Garderobe ablegen">Aktuellen Skin speichern</button>
|
||||
<button id="gard-umhang-speichern" class="btn btn-mini" title="Deinen eigenen AeroMC-Umhang in der Garderobe ablegen">Aktuellen Umhang speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="garderobe-liste" class="garderobe-liste"></div>
|
||||
<small class="hint">Vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch hier gesichert – Mojang selbst merkt sich keine alten Skins.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
|
||||
@@ -1851,6 +1840,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erfolg-Meldungen im Minecraft-Stil (oben rechts) -->
|
||||
<div id="erfolg-meldungen" class="erfolg-meldungen"></div>
|
||||
|
||||
<!-- Konto-Menü (Klick auf den Kontoknopf oben rechts) -->
|
||||
<div id="konto-menu" class="ctx konto-menu hidden" role="menu"></div>
|
||||
|
||||
|
||||
+12
-4
@@ -301,10 +301,18 @@ function collectArgs(argList, map) {
|
||||
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
|
||||
function mergeLoader(vj, lp) {
|
||||
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
|
||||
merged.arguments = {
|
||||
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
|
||||
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
|
||||
};
|
||||
if (vj.arguments || lp.arguments) {
|
||||
merged.arguments = {
|
||||
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
|
||||
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
|
||||
};
|
||||
} else if (lp.minecraftArguments) {
|
||||
// altes Format (< 1.13, z. B. Forge 1.12.2): Die Loader-Fassung enthält die
|
||||
// kompletten Spielargumente samt --tweakClass. Früher entstand hier eine
|
||||
// leere "arguments"-Liste – dann fehlten Klassenpfad (-cp) und Natives im
|
||||
// Startbefehl, und Java fand net.minecraft.launchwrapper.Launch nicht.
|
||||
merged.minecraftArguments = lp.minecraftArguments;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
+117
-103
@@ -1,103 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* loaders.js – Mod-Loader-Profile auflösen
|
||||
* ----------------------------------------
|
||||
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
|
||||
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
|
||||
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
|
||||
*
|
||||
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
|
||||
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
|
||||
* daher hier noch als „in Arbeit" gekennzeichnet.
|
||||
*/
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.5';
|
||||
|
||||
const META = {
|
||||
fabric: 'https://meta.fabricmc.net/v2',
|
||||
quilt: 'https://meta.quiltmc.org/v3',
|
||||
};
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
|
||||
const NEOFORGE_META = 'https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml';
|
||||
|
||||
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
|
||||
function neoforgePrefix(mcVersion) {
|
||||
const p = mcVersion.split('.');
|
||||
return `${p[1]}.${p[2] || '0'}.`;
|
||||
}
|
||||
|
||||
// "group:artifact:version[:classifier]" -> { relPath, url }
|
||||
function mavenToUrl(base, name) {
|
||||
const parts = name.split(':');
|
||||
const group = parts[0].replace(/\./g, '/');
|
||||
const artifact = parts[1];
|
||||
const version = parts[2];
|
||||
const classifier = parts[3] ? '-' + parts[3] : '';
|
||||
const file = `${artifact}-${version}${classifier}.jar`;
|
||||
const relPath = `${group}/${artifact}/${version}/${file}`;
|
||||
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
|
||||
return { relPath, url: `${root}/${relPath}` };
|
||||
}
|
||||
|
||||
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
|
||||
async function listLoaderVersions(loader, mcVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
|
||||
}
|
||||
if (loader === 'forge') {
|
||||
const xml = await fetchText(FORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = mcVersion + '-';
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).reverse();
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
|
||||
}
|
||||
if (loader === 'neoforge') {
|
||||
const xml = await fetchText(NEOFORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = neoforgePrefix(mcVersion);
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).reverse();
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// löst das Profil (mainClass, libraries, arguments) auf
|
||||
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
let lv = loaderVersion;
|
||||
if (!lv) {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
|
||||
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
|
||||
lv = stable.loader.version;
|
||||
}
|
||||
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
|
||||
return { profile, loaderVersion: lv };
|
||||
}
|
||||
if (loader === 'forge' || loader === 'neoforge') {
|
||||
const e = new Error(
|
||||
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
|
||||
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
|
||||
);
|
||||
e.notImplemented = true;
|
||||
throw e;
|
||||
}
|
||||
throw new Error('Unbekannter Loader: ' + loader);
|
||||
}
|
||||
|
||||
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* loaders.js – Mod-Loader-Profile auflösen
|
||||
* ----------------------------------------
|
||||
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
|
||||
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
|
||||
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
|
||||
*
|
||||
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
|
||||
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
|
||||
* daher hier noch als „in Arbeit" gekennzeichnet.
|
||||
*/
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.5';
|
||||
|
||||
const META = {
|
||||
fabric: 'https://meta.fabricmc.net/v2',
|
||||
quilt: 'https://meta.quiltmc.org/v3',
|
||||
};
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
|
||||
const NEOFORGE_META = 'https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml';
|
||||
|
||||
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
|
||||
function neoforgePrefix(mcVersion) {
|
||||
const p = mcVersion.split('.');
|
||||
return `${p[1]}.${p[2] || '0'}.`;
|
||||
}
|
||||
|
||||
// "group:artifact:version[:classifier]" -> { relPath, url }
|
||||
function mavenToUrl(base, name) {
|
||||
const parts = name.split(':');
|
||||
const group = parts[0].replace(/\./g, '/');
|
||||
const artifact = parts[1];
|
||||
const version = parts[2];
|
||||
const classifier = parts[3] ? '-' + parts[3] : '';
|
||||
const file = `${artifact}-${version}${classifier}.jar`;
|
||||
const relPath = `${group}/${artifact}/${version}/${file}`;
|
||||
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
|
||||
return { relPath, url: `${root}/${relPath}` };
|
||||
}
|
||||
|
||||
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
|
||||
// Versionsnummern zahlenweise vergleichen ("36.2.39" > "36.2.4"), neueste zuerst.
|
||||
// Die Maven-Listen von Forge/NeoForge sind nicht verlässlich sortiert – früher
|
||||
// landete bei Forge 1.16.5 dadurch die ÄLTESTE Version (36.0.0) vorn.
|
||||
function neuesteZuerst(a, b) {
|
||||
const ta = String(a).split(/[.\-+]/), tb = String(b).split(/[.\-+]/);
|
||||
for (let i = 0; i < Math.max(ta.length, tb.length); i++) {
|
||||
const x = ta[i] || '0', y = tb[i] || '0';
|
||||
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
||||
if (nx && ny && Number(x) !== Number(y)) return Number(y) - Number(x);
|
||||
if (!(nx && ny) && x !== y) return nx ? -1 : ny ? 1 : y.localeCompare(x); // reine Zahl vor "beta"
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function listLoaderVersions(loader, mcVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
|
||||
}
|
||||
if (loader === 'forge') {
|
||||
const xml = await fetchText(FORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = mcVersion + '-';
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).sort(neuesteZuerst);
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
|
||||
}
|
||||
if (loader === 'neoforge') {
|
||||
const xml = await fetchText(NEOFORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = neoforgePrefix(mcVersion);
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).sort(neuesteZuerst);
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// löst das Profil (mainClass, libraries, arguments) auf
|
||||
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
let lv = loaderVersion;
|
||||
if (!lv) {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
|
||||
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
|
||||
lv = stable.loader.version;
|
||||
}
|
||||
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
|
||||
return { profile, loaderVersion: lv };
|
||||
}
|
||||
if (loader === 'forge' || loader === 'neoforge') {
|
||||
const e = new Error(
|
||||
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
|
||||
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
|
||||
);
|
||||
e.notImplemented = true;
|
||||
throw e;
|
||||
}
|
||||
throw new Error('Unbekannter Loader: ' + loader);
|
||||
}
|
||||
|
||||
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
|
||||
|
||||
+277
-10
@@ -31,6 +31,9 @@ const bedrock = require('./bedrock');
|
||||
const backup = require('./backup');
|
||||
const mmcimport = require('./mmcimport');
|
||||
const kopieren = require('./kopieren');
|
||||
const moderkennung = require('./moderkennung');
|
||||
const garderobe = require('./garderobe');
|
||||
const erfolge = require('./erfolge');
|
||||
const cfimport = require('./cfimport');
|
||||
const mrimport = require('./mrimport');
|
||||
const mcimport = require('./mcimport');
|
||||
@@ -121,9 +124,9 @@ const POPOUT_SPEC = {
|
||||
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
|
||||
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
|
||||
instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 },
|
||||
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 },
|
||||
update: { title: 'AeroMC – Update', width: 600, height: 640, minWidth: 440, minHeight: 440 },
|
||||
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
|
||||
friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 },
|
||||
friends: { title: 'AeroMC – Freunde', width: 780, height: 620, minWidth: 360, minHeight: 480 },
|
||||
stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 },
|
||||
};
|
||||
const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 };
|
||||
@@ -262,6 +265,10 @@ const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i;
|
||||
ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null);
|
||||
|
||||
const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses
|
||||
// Instanzen, deren Spiel gerade läuft (für den pulsierenden Rand der Karte)
|
||||
const laufendeInstanzen = new Set();
|
||||
function meldeLaufende() { broadcast('game:laufend', [...laufendeInstanzen]); }
|
||||
ipcMain.handle('game:laufend', () => [...laufendeInstanzen]);
|
||||
const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss auf DEM Fenster
|
||||
// des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-Fenster
|
||||
// (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein
|
||||
@@ -946,6 +953,9 @@ app.whenReady().then(() => {
|
||||
// selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen
|
||||
services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java')));
|
||||
// Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden)
|
||||
moderkennung.init(app.getPath('userData'));
|
||||
garderobe.init(app.getPath('userData'));
|
||||
erfolge.init(app.getPath('userData'));
|
||||
auth.init(app.getPath('userData'), {
|
||||
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
|
||||
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
|
||||
@@ -1131,7 +1141,14 @@ ipcMain.handle('mods:search', async (_e, opts) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
|
||||
// Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden
|
||||
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
|
||||
async function modsMitErkennung(id, art) {
|
||||
const liste = store.listMods(id, art);
|
||||
try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); }
|
||||
catch { return liste; }
|
||||
}
|
||||
ipcMain.handle('mods:list', (_e, id, art) => modsMitErkennung(id, art || 'mod'));
|
||||
|
||||
ipcMain.handle('mods:install', (_e, id, project, art) => installiereInhalt(id, project, art));
|
||||
|
||||
@@ -1151,7 +1168,7 @@ async function installiereInhalt(id, project, art) {
|
||||
}
|
||||
|
||||
const modsDir = store.inhaltsDir(id, art || 'mod');
|
||||
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId));
|
||||
const done = new Set((await modsMitErkennung(id, art || 'mod')).map((m) => m.projectId));
|
||||
const added = [];
|
||||
let firstError = null;
|
||||
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
|
||||
@@ -1210,6 +1227,7 @@ async function installiereInhalt(id, project, art) {
|
||||
|
||||
await installOne(project, false);
|
||||
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
|
||||
pruefeErfolge();
|
||||
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
|
||||
}
|
||||
|
||||
@@ -1568,6 +1586,16 @@ ipcMain.handle('crash:fix', async (_e, id, aktion) => {
|
||||
broadcast('instances:changed');
|
||||
return { ok: true, text: 'Beim nächsten Start wählt AeroMC das passende Java und lädt es bei Bedarf herunter.' };
|
||||
}
|
||||
case 'forge-aktualisieren': {
|
||||
// neueste Forge-Version für dieselbe Minecraft-Version eintragen
|
||||
const liste = await loaders.listLoaderVersions(inst.minecraft.loader || 'forge', inst.minecraft.version);
|
||||
const neueste = liste && liste[0] && liste[0].version;
|
||||
if (!neueste) return { ok: false, message: 'Keine neuere Forge-Version gefunden.' };
|
||||
if (neueste === inst.minecraft.loaderVersion) return { ok: false, message: `Forge ${neueste} ist bereits die neueste Version für Minecraft ${inst.minecraft.version}.` };
|
||||
store.updateInstance(id, { minecraft: { loaderVersion: neueste } });
|
||||
broadcast('instances:changed');
|
||||
return { ok: true, text: `Forge auf ${neueste} aktualisiert (vorher ${inst.minecraft.loaderVersion || 'unbekannt'}). Beim nächsten Start wird es eingerichtet.` };
|
||||
}
|
||||
case 'mods-ordner': {
|
||||
const dir = store.inhaltsDir(id, 'mod');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -1582,6 +1610,115 @@ ipcMain.handle('crash:fix', async (_e, id, aktion) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Erfolge ----------
|
||||
// Werte aus dem, was der Launcher weiß; mehrere Anlässe kurz hintereinander
|
||||
// werden zu einer Prüfung zusammengefasst.
|
||||
let erfolgMerker = {};
|
||||
let erfolgFreunde = 0;
|
||||
let erfolgTimer = null;
|
||||
function pruefeErfolge(merker) {
|
||||
Object.assign(erfolgMerker, merker || {});
|
||||
clearTimeout(erfolgTimer);
|
||||
erfolgTimer = setTimeout(() => {
|
||||
try {
|
||||
const alle = store.listInstances();
|
||||
const gespielt = alle.filter((i) => (i.launchCount || 0) > 0);
|
||||
let mods = 0;
|
||||
for (const i of alle) {
|
||||
if (!i.minecraft || !i.minecraft.loader || i.minecraft.loader === 'vanilla') continue;
|
||||
try { mods += store.listMods(i.id, 'mod').filter((m) => !m.fehlt).length; } catch { /* egal */ }
|
||||
}
|
||||
const werte = {
|
||||
starts: alle.reduce((s, i) => s + (i.launchCount || 0), 0),
|
||||
sekunden: alle.reduce((s, i) => s + (i.totalPlaySeconds || 0), 0),
|
||||
instanzen: alle.length,
|
||||
versionen: new Set(gespielt.map((i) => i.minecraft && i.minecraft.version).filter(Boolean)).size,
|
||||
mods,
|
||||
modpacks: alle.filter((i) => i.modpack && (i.modpack.projectId || i.modpack.name)).length,
|
||||
freunde: erfolgFreunde,
|
||||
};
|
||||
const merkerJetzt = erfolgMerker;
|
||||
erfolgMerker = {};
|
||||
const { neu, ersteAuswertung } = erfolge.pruefe(werte, merkerJetzt);
|
||||
if (!neu.length) return;
|
||||
broadcast('erfolge:neu', { liste: neu, ersteAuswertung });
|
||||
if (neu.length === 1) notify('Erfolg erzielt!', `${neu[0].titel} – ${neu[0].text}`);
|
||||
else notify(`${neu.length} Erfolge erzielt!`, neu.map((e) => e.titel).join(', '));
|
||||
} catch { /* Erfolge sind Zierde */ }
|
||||
}, 1500);
|
||||
}
|
||||
ipcMain.handle('erfolge:liste', () => erfolge.liste());
|
||||
// Hauptfenster bereit -> einmal prüfen (u. a. für bestehende Nutzer nach dem Update)
|
||||
hauptfensterBereit.then(() => setTimeout(async () => {
|
||||
// Freunde einmal zählen (sonst erst beim Öffnen des Freunde-Fensters)
|
||||
try {
|
||||
const konto = store.getSettings().friendsEnabled !== false ? aktivesFreundeKonto() : null;
|
||||
if (konto) {
|
||||
const res = await friends.getFriends(konto.uuid);
|
||||
if (res.ok && Array.isArray(res.data)) erfolgFreunde = res.data.length;
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
pruefeErfolge();
|
||||
}, 4000));
|
||||
|
||||
// ---------- Leistungs-Paket: bewährte Performance-Mods mit einem Klick ----------
|
||||
// Je Platz die erste Mod, die es für Version + Loader gibt (z. B. Sodium, sonst
|
||||
// Embeddium). Kurznamen werden erst in Modrinth-IDs umgewandelt – installierte
|
||||
// Mods (auch von Hand kopierte, siehe moderkennung) tragen diese IDs.
|
||||
const LEISTUNGS_PAKET = [
|
||||
{ zweck: 'Grafik', mods: ['sodium', 'embeddium', 'rubidium'], grafik: true },
|
||||
{ zweck: 'Spiellogik', mods: ['lithium', 'radium'] },
|
||||
{ zweck: 'Arbeitsspeicher', mods: ['ferrite-core'] },
|
||||
{ zweck: 'Unsichtbares nicht zeichnen', mods: ['entityculling'] },
|
||||
{ zweck: 'Oberfläche/Text', mods: ['immediatelyfast'] },
|
||||
{ zweck: 'Ladezeit', mods: ['modernfix'] },
|
||||
{ zweck: 'Weniger Last im Hintergrund', mods: ['dynamic-fps'] },
|
||||
];
|
||||
|
||||
ipcMain.handle('mods:leistungsPaket', async (_e, id) => {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||
const loader = inst.minecraft.loader;
|
||||
if (!loader || loader === 'vanilla' || loader === 'optifine') {
|
||||
return { ok: false, message: 'Das Leistungs-Paket braucht einen Mod-Loader (Fabric, Quilt, Forge oder NeoForge).' };
|
||||
}
|
||||
// Kurznamen -> Modrinth-IDs (ein Abruf für alle)
|
||||
const slugs = [...new Set(LEISTUNGS_PAKET.flatMap((p) => p.mods))];
|
||||
let ids = {};
|
||||
try {
|
||||
const res = await fetch(`https://api.modrinth.com/v2/projects?ids=${encodeURIComponent(JSON.stringify(slugs))}`, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||
for (const p of await res.json()) ids[p.slug] = { id: p.id, title: p.title };
|
||||
} catch { return { ok: false, message: 'Modrinth ist gerade nicht erreichbar.' }; }
|
||||
|
||||
const vorhanden = await modsMitErkennung(id, 'mod');
|
||||
const hatOptifine = vorhanden.some((m) => m.enabled !== false && /optifine|optifabric/i.test(String(m.filename) + ' ' + String(m.title)));
|
||||
const installiert = new Set(vorhanden.map((m) => m.projectId).filter(Boolean));
|
||||
const ergebnis = { neu: [], schonDa: [], nichtVerfuegbar: [], uebersprungen: [] };
|
||||
let nr = 0;
|
||||
for (const platz of LEISTUNGS_PAKET) {
|
||||
nr++;
|
||||
if (platz.grafik && hatOptifine) { ergebnis.uebersprungen.push(`${platz.zweck} (OptiFine ist installiert)`); continue; }
|
||||
const kandidaten = platz.mods.map((s) => ids[s]).filter(Boolean);
|
||||
const da = kandidaten.find((k) => installiert.has(k.id));
|
||||
if (da) { ergebnis.schonDa.push(da.title); continue; }
|
||||
let geschafft = false;
|
||||
for (const k of kandidaten) {
|
||||
broadcast('mods:paketFortschritt', { nr, von: LEISTUNGS_PAKET.length, titel: k.title });
|
||||
const r = await installiereInhalt(id, { projectId: k.id, title: k.title }, 'mod').catch((err) => ({ ok: false, message: err.message }));
|
||||
if (r.ok) {
|
||||
ergebnis.neu.push(k.title);
|
||||
for (const a of r.added || []) if (a.projectId) installiert.add(a.projectId);
|
||||
geschafft = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!geschafft) ergebnis.nichtVerfuegbar.push(kandidaten.map((k) => k.title).join(' / ') || platz.mods[0]);
|
||||
}
|
||||
if (ergebnis.neu.length) broadcast('instances:changed');
|
||||
if (ergebnis.neu.length || ergebnis.schonDa.length) pruefeErfolge({ leistung: true });
|
||||
return Object.assign({ ok: true }, ergebnis);
|
||||
});
|
||||
|
||||
// nach einer Lösung gleich erneut starten (Start läuft im Hauptfenster)
|
||||
ipcMain.handle('crash:relaunch', (_e, id) => {
|
||||
if (!store.getInstance(id)) return false;
|
||||
@@ -1856,6 +1993,7 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
try {
|
||||
await sichereAktuellenSkin(token);
|
||||
const res = await skins.setzeSkin(token, dateiPfad, variante);
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
|
||||
return res;
|
||||
@@ -1867,6 +2005,7 @@ ipcMain.handle('skin:reset', async () => {
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
try {
|
||||
await sichereAktuellenSkin(token);
|
||||
const res = await skins.setzeSkinZurueck(token);
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
|
||||
return res;
|
||||
@@ -1874,6 +2013,106 @@ ipcMain.handle('skin:reset', async () => {
|
||||
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
|
||||
});
|
||||
|
||||
// ---------- Passt die Armform zum Skin? ----------
|
||||
// Steht das Konto auf "schmal", die Skin-Datei ist aber für breite Arme gemalt
|
||||
// (oder umgekehrt), sieht man im Spiel gequetschte Arme bzw. dunkle Streifen.
|
||||
async function aktuelleSkinDatei(token) {
|
||||
const p = await profilMitSpielSkin(token);
|
||||
if (!p.ok || !p.skinUrl) return null;
|
||||
const res = await fetch(p.skinUrl);
|
||||
if (!res.ok) return null;
|
||||
const daten = Buffer.from(await res.arrayBuffer());
|
||||
const bild = nativeImage.createFromBuffer(daten);
|
||||
const { width, height } = bild.getSize();
|
||||
return { daten, konto: p.variante === 'slim' ? 'slim' : 'classic', datei: skins.armformDerTextur(bild.toBitmap(), width, height) };
|
||||
}
|
||||
|
||||
ipcMain.handle('skin:armformPruefen', async () => {
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false };
|
||||
try {
|
||||
const s = await aktuelleSkinDatei(token);
|
||||
if (!s) return { ok: false };
|
||||
return { ok: true, konto: s.konto, datei: s.datei, passt: !s.datei || s.datei === s.konto };
|
||||
} catch { return { ok: false }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('skin:armformKorrigieren', async () => {
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-armform.png');
|
||||
try {
|
||||
const s = await aktuelleSkinDatei(token);
|
||||
if (!s || !s.datei) return { ok: false, message: 'Die Armform der Skin-Datei ließ sich nicht eindeutig bestimmen.' };
|
||||
if (s.datei === s.konto) return { ok: true, variante: s.konto, unveraendert: true };
|
||||
fs.writeFileSync(tmp, s.daten);
|
||||
// derselbe Skin, nur mit der passenden Armform neu hochgeladen
|
||||
const res = await skins.setzeSkin(token, tmp, s.datei);
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'armform' }); }
|
||||
return res && res.ok ? { ok: true, variante: s.datei } : res;
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Umstellen fehlgeschlagen: ' + err.message };
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp); } catch { /* egal */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Garderobe (gespeicherte Skins und Umhänge) ----------
|
||||
// Bisherigen Skin vor einem Wechsel sichern – Mojang merkt sich keine alten Skins.
|
||||
// Klappt das nicht (offline …), wird trotzdem gewechselt.
|
||||
async function sichereAktuellenSkin(token, name, automatisch = true) {
|
||||
try {
|
||||
const s = await aktuelleSkinDatei(token);
|
||||
if (!s) return null;
|
||||
// Armform aus der Datei, wenn eindeutig – sonst die des Kontos (so kommt beim
|
||||
// Wieder-Anziehen nicht ein falsch eingestellter, gequetschter Zustand zurück)
|
||||
return garderobe.hinzufuegen({ art: 'skin', daten: s.daten, variante: s.datei || s.konto, automatisch,
|
||||
name: name || 'Gesichert am ' + new Date().toLocaleDateString('de-DE') });
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
ipcMain.handle('garderobe:liste', () => garderobe.liste());
|
||||
|
||||
ipcMain.handle('garderobe:skinSpeichern', async (_e, name) => {
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
const r = await sichereAktuellenSkin(token, name, false);
|
||||
if (!r) return { ok: false, message: 'Dein Konto nutzt den Standard-Skin oder er ist gerade nicht abrufbar.' };
|
||||
return { ok: true, neu: r.neu, name: r.eintrag.name };
|
||||
});
|
||||
|
||||
ipcMain.handle('garderobe:umhangSpeichern', async (_e, name) => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
const r = await umhangFuer(konto.uuid);
|
||||
if (!r || !r.dataUri || r.quelle !== 'aeromc') return { ok: false, message: 'Du trägst gerade keinen eigenen AeroMC-Umhang.' };
|
||||
const daten = Buffer.from(r.dataUri.split(',')[1], 'base64');
|
||||
const e = garderobe.hinzufuegen({ art: 'umhang', daten, name: name || 'Umhang', automatisch: false });
|
||||
return { ok: true, neu: e.neu, name: e.eintrag.name };
|
||||
});
|
||||
|
||||
ipcMain.handle('garderobe:anziehen', async (_e, id) => {
|
||||
const eintrag = garderobe.holen(id);
|
||||
if (!eintrag) return { ok: false, message: 'Eintrag nicht gefunden.' };
|
||||
if (eintrag.art === 'umhang') {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
const base64 = fs.readFileSync(eintrag.pfad).toString('base64');
|
||||
const res = await friends.uploadCape(konto.uuid, base64);
|
||||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||||
return res.ok ? { ok: true } : umhangFehler(res);
|
||||
}
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
await sichereAktuellenSkin(token);
|
||||
const res = await skins.setzeSkin(token, eintrag.pfad, eintrag.variante);
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'garderobe' }); }
|
||||
return res && res.ok ? { ok: true } : (res || { ok: false, message: 'Skin konnte nicht gesetzt werden.' });
|
||||
});
|
||||
|
||||
ipcMain.handle('garderobe:umbenennen', (_e, id, name) => ({ ok: garderobe.umbenennen(id, name) }));
|
||||
ipcMain.handle('garderobe:loeschen', (_e, id) => ({ ok: garderobe.loeschen(id) }));
|
||||
|
||||
// ---------- Skin-Editor ----------
|
||||
// Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net)
|
||||
// würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt
|
||||
@@ -2017,7 +2256,7 @@ ipcMain.handle('cape:upload', async (_e, base64) => {
|
||||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' };
|
||||
const res = await friends.uploadCape(konto.uuid, base64);
|
||||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); pruefeErfolge({ umhang: true }); }
|
||||
return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res);
|
||||
});
|
||||
|
||||
@@ -2100,8 +2339,9 @@ ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
|
||||
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png');
|
||||
try {
|
||||
fs.writeFileSync(tmp, daten);
|
||||
await sichereAktuellenSkin(token);
|
||||
const res = await skins.setzeSkin(token, tmp, variante);
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); pruefeErfolge({ 'skin-editor': true }); }
|
||||
return res;
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
|
||||
@@ -2717,6 +2957,7 @@ ipcMain.handle('friends:list', async () => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||||
const res = await friends.getFriends(konto.uuid);
|
||||
if (res.ok && Array.isArray(res.data) && res.data.length > erfolgFreunde) { erfolgFreunde = res.data.length; pruefeErfolge(); }
|
||||
return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
|
||||
});
|
||||
|
||||
@@ -3537,6 +3778,22 @@ ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => {
|
||||
* Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per
|
||||
* Drag&Drop aufs Fenster kommt.
|
||||
*/
|
||||
// Inhalt eines (eigenen, temporären) Ordners in den Zielordner übernehmen:
|
||||
// verschieben statt kopieren – auf demselben Laufwerk ist das sofort erledigt,
|
||||
// sonst (anderes Laufwerk) wird im Hintergrund kopiert. Nichts davon blockiert.
|
||||
async function verschiebeInhalt(quelle, ziel, nur) {
|
||||
await fs.promises.mkdir(ziel, { recursive: true });
|
||||
for (const name of nur || await fs.promises.readdir(quelle)) {
|
||||
const von = path.join(quelle, name);
|
||||
const nach = path.join(ziel, name);
|
||||
try {
|
||||
await fs.promises.rename(von, nach);
|
||||
} catch {
|
||||
await kopieren.kopiere(von, nach, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importInstanceZipFromPath(filePath) {
|
||||
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
|
||||
try {
|
||||
@@ -3556,8 +3813,10 @@ async function importInstanceZipFromPath(filePath) {
|
||||
if (!fs.existsSync(path.join(base, 'instance.json'))) {
|
||||
const mmc = mmcimport.scanSingle(tmp);
|
||||
if (mmc.ok) {
|
||||
const done = mmcimport.importOne(store, mmc.instance, true);
|
||||
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: done.copied };
|
||||
const done = mmcimport.importOne(store, mmc.instance, false);
|
||||
const quellen = mmcimport.kopierQuellen(mmc.instance);
|
||||
if (quellen.length) await verschiebeInhalt(mmc.instance.gameDir, store.gameDir(done.id), quellen.map((q) => q.name));
|
||||
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: quellen.length > 0 };
|
||||
}
|
||||
throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).');
|
||||
}
|
||||
@@ -3571,7 +3830,7 @@ async function importInstanceZipFromPath(filePath) {
|
||||
if (imported.java) store.updateInstance(created.id, { java: imported.java });
|
||||
|
||||
const mcSrc = path.join(base, 'minecraft');
|
||||
if (fs.existsSync(mcSrc)) fs.cpSync(mcSrc, store.gameDir(created.id), { recursive: true });
|
||||
if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
|
||||
const modsSrc = path.join(base, 'mods.json');
|
||||
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
|
||||
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) {
|
||||
@@ -3582,7 +3841,8 @@ async function importInstanceZipFromPath(filePath) {
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Import fehlgeschlagen: ' + err.message };
|
||||
} finally {
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
// Reste im Hintergrund löschen (bei großen Archiven sonst minutenlang blockiert)
|
||||
fs.promises.rm(tmp, { recursive: true, force: true }).catch(() => { /* egal */ });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3895,6 +4155,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||||
const res = await launcher.prepareAndLaunch(opts, (evt) => {
|
||||
if (evt.phase === 'launch') {
|
||||
launchStart = Date.now();
|
||||
laufendeInstanzen.add(id);
|
||||
meldeLaufende();
|
||||
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
|
||||
discordpresence.setActivity({
|
||||
details: 'Spielt Minecraft',
|
||||
@@ -3930,6 +4192,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||||
herzschlagJetzt = null;
|
||||
if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId);
|
||||
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
|
||||
laufendeInstanzen.delete(id);
|
||||
meldeLaufende();
|
||||
// beim nächsten Start dieses Kontos sollen bereits online befindliche
|
||||
// Freunde wieder gemeldet werden
|
||||
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
|
||||
@@ -3944,6 +4208,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||||
}
|
||||
const cur = store.getInstance(id);
|
||||
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
|
||||
pruefeErfolge();
|
||||
}
|
||||
}
|
||||
if (evt.phase === 'log') {
|
||||
@@ -3970,12 +4235,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||||
if (res.ok) {
|
||||
const aktuell = store.getInstance(id);
|
||||
store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 });
|
||||
pruefeErfolge();
|
||||
}
|
||||
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
|
||||
if (res.ok && res.pid) laufendePid.set(id, res.pid);
|
||||
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (laufendeInstanzen.delete(id)) meldeLaufende();
|
||||
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
|
||||
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
||||
if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Erkennt Mods, die nicht über AeroMC installiert wurden (von Hand kopiert,
|
||||
* aus MultiMC/CurseForge/Modrinth App übernommen, aus einem Modpack …).
|
||||
*
|
||||
* Solche Dateien haben keine Projekt-Zuordnung, deshalb zeigte der Mod-Katalog
|
||||
* z. B. bei Fabric API „Installieren“, obwohl sie längst im mods-Ordner lag.
|
||||
* Hier wird je Datei die SHA-1-Prüfsumme gebildet und gesammelt bei Modrinth
|
||||
* nachgeschlagen (POST /v2/version_files). Ergebnisse werden zwischengespeichert
|
||||
* (Pfad + Größe + Änderungszeit), damit nicht bei jedem Öffnen neu gerechnet wird.
|
||||
* Ohne Netz bleibt einfach alles wie bisher.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const API = 'https://api.modrinth.com/v2';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
const NICHT_GEFUNDEN_NEU_PRUEFEN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let cacheDatei = null;
|
||||
let cache = null;
|
||||
|
||||
function init(userDataDir) {
|
||||
cacheDatei = path.join(userDataDir, 'mod-erkennung.json');
|
||||
}
|
||||
|
||||
function ladeCache() {
|
||||
if (cache) return cache;
|
||||
try { cache = JSON.parse(fs.readFileSync(cacheDatei, 'utf8')); } catch { cache = {}; }
|
||||
return cache;
|
||||
}
|
||||
function speichereCache() {
|
||||
try { fs.writeFileSync(cacheDatei, JSON.stringify(cache)); } catch { /* nur Zwischenspeicher */ }
|
||||
}
|
||||
|
||||
function sha1(datei) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash('sha1');
|
||||
fs.createReadStream(datei).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function holeJson(url, optionen) {
|
||||
const res = await fetch(url, Object.assign({ headers: { 'User-Agent': UA, 'Content-Type': 'application/json', Accept: 'application/json' } }, optionen));
|
||||
if (!res.ok) throw new Error('Modrinth ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergänzt Einträge ohne projectId um projectId, Titel und Versionsnummer, sofern
|
||||
* Modrinth die Datei kennt. `dir` ist der Ordner, in dem die Dateien liegen.
|
||||
*/
|
||||
async function ergaenze(eintraege, dir) {
|
||||
const offen = eintraege.filter((e) => e && !e.projectId && e.filename && !e.fehlt);
|
||||
if (!offen.length || !cacheDatei) return eintraege;
|
||||
const c = ladeCache();
|
||||
const jetzt = Date.now();
|
||||
const zuFragen = new Map(); // sha1 -> [schlüssel]
|
||||
const schluesselVon = new Map();
|
||||
|
||||
for (const e of offen) {
|
||||
const voll = path.join(dir, e.enabled === false ? e.filename + '.disabled' : e.filename);
|
||||
let st;
|
||||
try { st = fs.statSync(voll); } catch { continue; }
|
||||
const schluessel = `${voll}|${st.size}|${Math.round(st.mtimeMs)}`;
|
||||
schluesselVon.set(e, schluessel);
|
||||
const bekannt = c[schluessel];
|
||||
if (bekannt && (bekannt.projectId || jetzt - bekannt.geprueft < NICHT_GEFUNDEN_NEU_PRUEFEN_MS)) continue;
|
||||
try {
|
||||
const hash = (bekannt && bekannt.sha1) || await sha1(voll);
|
||||
c[schluessel] = { sha1: hash, projectId: null, geprueft: 0 };
|
||||
if (!zuFragen.has(hash)) zuFragen.set(hash, []);
|
||||
zuFragen.get(hash).push(schluessel);
|
||||
} catch { /* Datei gerade gesperrt – beim nächsten Mal */ }
|
||||
}
|
||||
|
||||
if (zuFragen.size) {
|
||||
try {
|
||||
const versionen = await holeJson(`${API}/version_files`, {
|
||||
method: 'POST', body: JSON.stringify({ hashes: [...zuFragen.keys()], algorithm: 'sha1' }),
|
||||
});
|
||||
const projektIds = [...new Set(Object.values(versionen).map((v) => v.project_id))];
|
||||
const titel = {};
|
||||
for (let i = 0; i < projektIds.length; i += 100) {
|
||||
const teil = projektIds.slice(i, i + 100);
|
||||
const projekte = await holeJson(`${API}/projects?ids=${encodeURIComponent(JSON.stringify(teil))}`);
|
||||
for (const p of projekte) titel[p.id] = p.title;
|
||||
}
|
||||
for (const [hash, schluessel] of zuFragen) {
|
||||
const v = versionen[hash];
|
||||
for (const s of schluessel) {
|
||||
c[s] = v
|
||||
? { sha1: hash, projectId: v.project_id, versionId: v.id, versionNumber: v.version_number, title: titel[v.project_id] || null, geprueft: jetzt }
|
||||
: { sha1: hash, projectId: null, geprueft: jetzt };
|
||||
}
|
||||
}
|
||||
speichereCache();
|
||||
} catch {
|
||||
return eintraege.map((e) => anwenden(e, c, schluesselVon));
|
||||
}
|
||||
}
|
||||
return eintraege.map((e) => anwenden(e, c, schluesselVon));
|
||||
}
|
||||
|
||||
function anwenden(e, c, schluesselVon) {
|
||||
const s = schluesselVon.get(e);
|
||||
const t = s && c[s];
|
||||
if (!t || !t.projectId) return e;
|
||||
return Object.assign({}, e, {
|
||||
projectId: t.projectId,
|
||||
title: t.title || e.title,
|
||||
versionNumber: e.versionNumber === 'Importiert' && t.versionNumber ? t.versionNumber : e.versionNumber,
|
||||
erkannt: true, // per Prüfsumme bei Modrinth erkannt, nicht über AeroMC installiert
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, ergaenze };
|
||||
@@ -166,6 +166,32 @@ contextBridge.exposeInMainWorld('api', {
|
||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
||||
skinArmformPruefen: () => ipcRenderer.invoke('skin:armformPruefen'),
|
||||
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
|
||||
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
|
||||
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
|
||||
onLaufendeInstanzen: (cb) => {
|
||||
const l = (_e, d) => cb(d || []);
|
||||
ipcRenderer.on('game:laufend', l);
|
||||
return () => ipcRenderer.removeListener('game:laufend', l);
|
||||
},
|
||||
onErfolgeNeu: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('erfolge:neu', l);
|
||||
return () => ipcRenderer.removeListener('erfolge:neu', l);
|
||||
},
|
||||
modsLeistungsPaket: (id) => ipcRenderer.invoke('mods:leistungsPaket', id),
|
||||
onPaketFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:paketFortschritt', l);
|
||||
return () => ipcRenderer.removeListener('mods:paketFortschritt', l);
|
||||
},
|
||||
garderobeSkinSpeichern: (name) => ipcRenderer.invoke('garderobe:skinSpeichern', name),
|
||||
garderobeUmhangSpeichern: (name) => ipcRenderer.invoke('garderobe:umhangSpeichern', name),
|
||||
garderobeAnziehen: (id) => ipcRenderer.invoke('garderobe:anziehen', id),
|
||||
garderobeUmbenennen: (id, name) => ipcRenderer.invoke('garderobe:umbenennen', id, name),
|
||||
garderobeLoeschen: (id) => ipcRenderer.invoke('garderobe:loeschen', id),
|
||||
skinArmformKorrigieren: () => ipcRenderer.invoke('skin:armformKorrigieren'),
|
||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||
|
||||
+686
-11
@@ -493,6 +493,7 @@ let pendingImagePath = null; // im Dialog gewähltes eigenes Bild (noc
|
||||
let pendingClearImage = false; // eigenes Bild soll entfernt werden
|
||||
let loginVerificationUri = null; // aktueller Device-Code-Link
|
||||
const collapsedGroups = new Set(); // eingeklappte Gruppen
|
||||
let laufendeIds = new Set(); // Instanzen, deren Spiel gerade läuft
|
||||
let consoleBuffer = ''; // gesammelte Log-Ausgabe (roh, für Kopieren)
|
||||
let consolePending = ''; // noch nicht abgeschlossene Zeile
|
||||
|
||||
@@ -1585,6 +1586,93 @@ let pendingUpdate = null;
|
||||
|
||||
const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' };
|
||||
|
||||
// ---------- Markdown für die Update-Hinweise ----------
|
||||
// Kleiner Renderer für die Release-Notizen aus Gitea: Überschriften, Listen
|
||||
// (auch verschachtelt), Absätze, **fett**, *kursiv*, ~~durchgestrichen~~,
|
||||
// `Code`, Codeblöcke, Zitate, Trennlinien und Links. Der Text wird ZUERST
|
||||
// komplett escaped – HTML aus den Notizen landet nie ungefiltert in der Seite,
|
||||
// erzeugt werden nur die Tags von hier.
|
||||
function mdInline(t) {
|
||||
const codes = [];
|
||||
t = t.replace(/`([^`]+)`/g, (_, c) => { codes.push(c); return `\u0000${codes.length - 1}\u0000`; });
|
||||
t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, txt, url) => `<a href="${url}" data-extern>${txt}</a>`);
|
||||
// nackte Adressen verlinken (nicht die eben erzeugten href="…" bzw. Linktexte)
|
||||
t = t.replace(/(?<![="'>\w])(https?:\/\/[^\s<]*[^\s<.,;:!?)])/g, '<a href="$1" data-extern>$1</a>');
|
||||
t = t.replace(/\*\*(.+?)\*\*|__(.+?)__/g, (_, a, b) => `<strong>${a || b}</strong>`);
|
||||
t = t.replace(/(^|[^*\w])\*(?!\s)(.+?)(?<!\s)\*(?!\*)/g, '$1<em>$2</em>');
|
||||
t = t.replace(/(^|[^_\w])_(?!\s)(.+?)(?<!\s)_(?!\w)/g, '$1<em>$2</em>');
|
||||
t = t.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${codes[i]}</code>`);
|
||||
}
|
||||
|
||||
function mdZuHtml(md) {
|
||||
const zeilen = escapeHtml(String(md || '').replace(/\r\n?/g, '\n')).split('\n');
|
||||
let html = '';
|
||||
let absatz = [];
|
||||
let zitat = [];
|
||||
let code = null; // Zeilen eines ```-Blocks
|
||||
const listen = []; // offene Listen: { typ, einzug }
|
||||
let warLeer = false;
|
||||
const absatzEnde = () => { if (absatz.length) { html += `<p>${mdInline(absatz.join(' '))}</p>`; absatz = []; } };
|
||||
const zitatEnde = () => { if (zitat.length) { html += `<blockquote>${mdInline(zitat.join(' '))}</blockquote>`; zitat = []; } };
|
||||
const listenZu = (bis = -1) => {
|
||||
while (listen.length && listen[listen.length - 1].einzug > bis) html += `</li></${listen.pop().typ}>`;
|
||||
};
|
||||
const alleZu = () => { absatzEnde(); zitatEnde(); listenZu(); };
|
||||
|
||||
for (const zeile of zeilen) {
|
||||
if (/^\s*```/.test(zeile)) {
|
||||
if (code) { html += `<pre><code>${code.join('\n')}</code></pre>`; code = null; } else { alleZu(); code = []; }
|
||||
continue;
|
||||
}
|
||||
if (code) { code.push(zeile); continue; }
|
||||
if (!zeile.trim()) { absatzEnde(); zitatEnde(); warLeer = true; continue; }
|
||||
|
||||
const ueberschrift = zeile.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/);
|
||||
if (ueberschrift) {
|
||||
alleZu();
|
||||
const tag = ['h4', 'h4', 'h5', 'h6', 'h6', 'h6'][ueberschrift[1].length - 1];
|
||||
html += `<${tag}>${mdInline(ueberschrift[2])}</${tag}>`;
|
||||
warLeer = false;
|
||||
continue;
|
||||
}
|
||||
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(zeile)) { alleZu(); html += '<hr>'; warLeer = false; continue; }
|
||||
const zitatZeile = zeile.match(/^\s{0,3}>\s?(.*)$/);
|
||||
if (zitatZeile) { absatzEnde(); listenZu(); zitat.push(zitatZeile[1]); warLeer = false; continue; }
|
||||
|
||||
const punkt = zeile.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||
if (punkt) {
|
||||
absatzEnde(); zitatEnde();
|
||||
const einzug = punkt[1].replace(/\t/g, ' ').length;
|
||||
const typ = /\d/.test(punkt[2]) ? 'ol' : 'ul';
|
||||
listenZu(einzug);
|
||||
const oben = listen[listen.length - 1];
|
||||
if (oben && oben.einzug === einzug && oben.typ === typ) {
|
||||
html += '</li><li>';
|
||||
} else {
|
||||
if (oben && oben.einzug === einzug) listenZu(einzug - 1); // anderer Listentyp auf gleicher Ebene
|
||||
html += `<${typ}><li>`;
|
||||
listen.push({ typ, einzug });
|
||||
}
|
||||
html += mdInline(punkt[3]);
|
||||
warLeer = false;
|
||||
continue;
|
||||
}
|
||||
// Fortsetzung eines Listenpunkts (eingerückt oder direkt darunter)
|
||||
if (listen.length && (/^\s+/.test(zeile) || !warLeer)) {
|
||||
html += ' ' + mdInline(zeile.trim());
|
||||
continue;
|
||||
}
|
||||
listenZu();
|
||||
zitatEnde();
|
||||
absatz.push(zeile.trim());
|
||||
warLeer = false;
|
||||
}
|
||||
if (code) html += `<pre><code>${code.join('\n')}</code></pre>`;
|
||||
alleZu();
|
||||
return html;
|
||||
}
|
||||
|
||||
function showUpdateDialog(info) {
|
||||
if (POPOUT !== 'update') { window.api.popoutOpen('update'); return; }
|
||||
if (!info) return; // Popup geöffnet, aber (aktuell) kein Update-Ergebnis vorhanden
|
||||
@@ -1595,7 +1683,13 @@ function showUpdateDialog(info) {
|
||||
el('upd-channel').textContent = channel
|
||||
? `Update-Kanal: ${channel}`
|
||||
: 'Update-Kanal: unbekannt – automatisches Update nicht möglich';
|
||||
el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.';
|
||||
// erste Überschrift weglassen, wenn sie nur die neue Version nennt ("## [1.2.1] - …") –
|
||||
// die steht oben schon als "1.2.0 → 1.2.1"
|
||||
let notizen = (info.notes || '').trim();
|
||||
const kopf = notizen.match(/^#{1,6}[ \t]+([^\r\n]*)(\r?\n|$)/);
|
||||
if (kopf && info.latestVersion && kopf[1].includes(String(info.latestVersion).replace(/^v/i, ''))) notizen = notizen.slice(kopf[0].length).trim();
|
||||
el('upd-notes').innerHTML = notizen ? mdZuHtml(notizen) : '<p class="hint">Keine Änderungshinweise hinterlegt.</p>';
|
||||
el('upd-notes').scrollTop = 0;
|
||||
el('upd-progress').classList.add('hidden');
|
||||
el('upd-bar-fill').style.width = '0%';
|
||||
|
||||
@@ -1615,6 +1709,14 @@ function showUpdateDialog(info) {
|
||||
show('modal-update');
|
||||
}
|
||||
|
||||
// Links in den Update-Hinweisen im Browser öffnen, nie im Launcher-Fenster
|
||||
function updateLinkKlick(e) {
|
||||
const a = e.target.closest('a');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
if (a.hasAttribute('data-extern') && /^https?:\/\//.test(a.href)) window.api.openExternal(a.href);
|
||||
}
|
||||
|
||||
function openReleasePage() {
|
||||
if (pendingUpdate && pendingUpdate.url) window.api.openExternal(pendingUpdate.url);
|
||||
}
|
||||
@@ -1762,6 +1864,43 @@ function beendeSkinLive() {
|
||||
if (flaeche) flaeche.classList.remove('skin-lebt');
|
||||
}
|
||||
|
||||
// ---------- Auftritt nach einem Skin-/Umhang-Wechsel ----------
|
||||
// Der nächste Aufbau einer Figur (innerhalb kurzer Zeit) dreht sich einmal um
|
||||
// sich selbst bzw. lässt den Umhang flattern.
|
||||
let skinAuftritt = null;
|
||||
function merkeSkinAuftritt(art) { skinAuftritt = { art, bis: Date.now() + 15000 }; }
|
||||
function holeSkinAuftritt() {
|
||||
const a = skinAuftritt;
|
||||
skinAuftritt = null;
|
||||
return a && a.bis > Date.now() && bewegungErlaubt() ? a.art : null;
|
||||
}
|
||||
// liefert pro Bild die Zusatz-Drehung und den Umhang-Ausschlag, danach null.
|
||||
// Die Zeit läuft erst ab dem ersten gezeichneten Bild – ist das Fenster gerade
|
||||
// im Hintergrund, sieht man den Auftritt, sobald man wieder hinschaut.
|
||||
function skinAuftrittWirkung(art) {
|
||||
let start = null;
|
||||
return () => {
|
||||
const jetzt = performance.now();
|
||||
if (start === null) start = jetzt;
|
||||
const s = (jetzt - start) / 1000;
|
||||
if (art === 'drehen') {
|
||||
const d = 1.35;
|
||||
if (s >= d) return null;
|
||||
const x = s / d;
|
||||
const e = x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||||
return { drehung: e * Math.PI * 2, umhang: Math.sin(x * Math.PI) * 0.35 };
|
||||
}
|
||||
const d = 2.4;
|
||||
if (s >= d) return null;
|
||||
const x = s / d;
|
||||
const abklingen = 1 - x;
|
||||
return {
|
||||
drehung: Math.sin(x * Math.PI) * 1.1, // seitlich drehen, damit man den Umhang sieht
|
||||
umhang: (0.55 + Math.sin(s * 15) * 0.22) * abklingen, // hochwehen und flattern
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
|
||||
beendeSkinLive();
|
||||
const canvas = el('skin-live');
|
||||
@@ -1799,6 +1938,8 @@ async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
|
||||
|
||||
const ziel = { yaw: 0, pitch: 0 };
|
||||
const kopf = { yaw: 0, pitch: 0 };
|
||||
const auftritt = holeSkinAuftritt();
|
||||
let wirkung = auftritt ? skinAuftrittWirkung(auftritt) : null;
|
||||
const maus = (e) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const kx = r.left + r.width * 0.5;
|
||||
@@ -1819,6 +1960,11 @@ async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
|
||||
kopf.pitch += (ziel.pitch - kopf.pitch) * 0.07;
|
||||
// ohne Maus schaut die Figur halb zum Betrachter, sonst dem Zeiger hinterher
|
||||
pl.skin.head.rotation.set(kopfBasis.x + kopf.pitch, kopfBasis.y - koerperBasis * 0.45 + kopf.yaw, kopfBasis.z);
|
||||
if (wirkung) {
|
||||
const w = wirkung();
|
||||
if (!w) wirkung = null;
|
||||
else { pl.rotation.y += w.drehung; if (pl.cape) pl.cape.rotation.x += w.umhang; }
|
||||
}
|
||||
});
|
||||
|
||||
const sicht = () => { viewer.renderPaused = document.hidden || !document.hasFocus(); };
|
||||
@@ -2103,6 +2249,7 @@ async function refreshSelectionPanel() {
|
||||
|
||||
const id = selectedIds[0];
|
||||
currentDetailId = id;
|
||||
zeigeStartKnopf(); // Fortschritt gehört zur jeweils gewählten Instanz
|
||||
setSingleSelectionActionsDisabled(false);
|
||||
el('p-delete').disabled = false;
|
||||
el('p-delete').textContent = 'Löschen';
|
||||
@@ -2170,8 +2317,11 @@ function buildCard(inst) {
|
||||
|
||||
card.innerHTML = `
|
||||
${iconHtml}
|
||||
<i class="card-riss" aria-hidden="true"></i>
|
||||
<div class="card-name">${escapeHtml(inst.name)}</div>
|
||||
${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`;
|
||||
if (laufendeIds.has(inst.id)) card.classList.add('laeuft');
|
||||
if (startStand.has(inst.id)) zeigeAbbau(card);
|
||||
|
||||
// einfacher Klick = auswählen (MultiMC), Doppelklick = starten
|
||||
card.addEventListener('click', (e) => {
|
||||
@@ -2207,9 +2357,72 @@ function sucheSchliessen() {
|
||||
|
||||
let instanzenEingeblendet = false;
|
||||
|
||||
// ---------- Weiche Übergänge in der Instanzliste ----------
|
||||
// Vor dem Neuaufbau merken, wo jede Karte/Überschrift stand; danach gleiten
|
||||
// verschobene Karten von dort an ihren neuen Platz, neue blenden ein und
|
||||
// entfernte schrumpfen als "Geist" an ihrer alten Stelle weg (FLIP).
|
||||
function flipSchluessel(k) {
|
||||
if (k.classList.contains('card')) return 'c:' + k.dataset.id;
|
||||
if (k.classList.contains('group-header')) return 'h:' + k.dataset.pfad;
|
||||
return null;
|
||||
}
|
||||
function flipErfassen(grid) {
|
||||
const stand = new Map();
|
||||
if (!instanzenEingeblendet || POPOUT || !bewegungErlaubt()) return stand;
|
||||
grid.querySelectorAll('.card, .group-header').forEach((k) => {
|
||||
const key = flipSchluessel(k);
|
||||
const rect = k.getBoundingClientRect();
|
||||
// zugeklappte Gruppen: Karten stecken unsichtbar im DOM -> zählen als nicht da
|
||||
if (key && rect.width > 0) stand.set(key, { rect, el: k });
|
||||
});
|
||||
return stand;
|
||||
}
|
||||
function flipAbspielen(grid, vorher) {
|
||||
if (!vorher.size) return;
|
||||
const sicht = grid.getBoundingClientRect();
|
||||
const sichtbar = (r) => r.bottom > sicht.top && r.top < sicht.bottom && r.width > 0;
|
||||
const jetzt = new Set();
|
||||
let neuIndex = 0;
|
||||
grid.querySelectorAll('.card, .group-header').forEach((k) => {
|
||||
const key = flipSchluessel(k);
|
||||
if (!key) return;
|
||||
const r = k.getBoundingClientRect();
|
||||
if (r.width === 0) return; // in zugeklappter Gruppe
|
||||
jetzt.add(key);
|
||||
if (!sichtbar(r)) return;
|
||||
const alt = vorher.get(key);
|
||||
if (alt) {
|
||||
const dx = alt.rect.left - r.left;
|
||||
const dy = alt.rect.top - r.top;
|
||||
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
|
||||
k.animate([{ transform: `translate(${dx}px,${dy}px)` }, { transform: 'none' }],
|
||||
{ duration: 340, easing: 'cubic-bezier(.2,.8,.2,1)' });
|
||||
}
|
||||
} else {
|
||||
k.animate([{ opacity: 0, transform: 'scale(.82)' }, { opacity: 1, transform: 'none' }],
|
||||
{ duration: 260, delay: 70 + Math.min(neuIndex++, 12) * 18, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'backwards' });
|
||||
}
|
||||
});
|
||||
// weggefallene Karten (gelöscht, verschoben, Gruppe zugeklappt) schrumpfen weg
|
||||
let geister = 0;
|
||||
for (const [key, alt] of vorher) {
|
||||
if (jetzt.has(key) || !key.startsWith('c:') || !sichtbar(alt.rect) || geister++ > 40) continue;
|
||||
const geist = alt.el;
|
||||
geist.classList.add('flip-geist');
|
||||
Object.assign(geist.style, {
|
||||
left: alt.rect.left + 'px', top: alt.rect.top + 'px', width: alt.rect.width + 'px', height: alt.rect.height + 'px',
|
||||
});
|
||||
document.body.appendChild(geist);
|
||||
const weg = () => geist.remove();
|
||||
geist.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'scale(.55)' }],
|
||||
{ duration: 240, easing: 'cubic-bezier(.5,0,.75,0)', fill: 'forwards' }).finished.then(weg, weg);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = el('search').value.trim().toLowerCase();
|
||||
const grid = el('grid');
|
||||
const vorher = flipErfassen(grid);
|
||||
grid.innerHTML = '';
|
||||
|
||||
const filtered = instances.filter((i) =>
|
||||
@@ -2235,6 +2448,8 @@ function render() {
|
||||
grid.classList.add('einblenden');
|
||||
setTimeout(() => grid.classList.remove('einblenden'), 1400);
|
||||
}
|
||||
} else {
|
||||
flipAbspielen(grid, vorher);
|
||||
}
|
||||
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
|
||||
}
|
||||
@@ -2279,6 +2494,7 @@ function baueGruppenSektion(knoten, tiefe, showHeaders) {
|
||||
if (showHeaders) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'group-header' + (zu ? ' collapsed' : '');
|
||||
header.dataset.pfad = g;
|
||||
header.title = g ? gruppenAnzeige(g) : 'Instanzen ohne Gruppe';
|
||||
header.innerHTML = `<span class="chev">${zu ? '▸' : '▾'}</span> ${escapeHtml(knoten.name)} <span class="group-count">(${anzahlImZweig(knoten)})</span>`;
|
||||
header.addEventListener('click', () => {
|
||||
@@ -2648,8 +2864,11 @@ async function launch(id, accountId, launchOpts) {
|
||||
status('Vorbereiten …');
|
||||
el('status-right').textContent = '';
|
||||
appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n');
|
||||
startStand.set(id, { prozent: 2, text: 'Vorbereiten …' });
|
||||
zeigeStartKnopf();
|
||||
try {
|
||||
const res = await window.api.launchInstance(id, accountId, launchOpts || null);
|
||||
beendeStartFortschritt(id, !!res.ok);
|
||||
if (res.ok) {
|
||||
status('Läuft (PID ' + res.pid + ')');
|
||||
toast(res.modUpdates ? `Minecraft gestartet – ${res.modUpdates} Mod${res.modUpdates === 1 ? '' : 's'} vorher aktualisiert.` : 'Minecraft gestartet.');
|
||||
@@ -2663,6 +2882,7 @@ async function launch(id, accountId, launchOpts) {
|
||||
toast(res.message || 'Start fehlgeschlagen.', true);
|
||||
}
|
||||
} catch (e) {
|
||||
beendeStartFortschritt(id, false);
|
||||
status('Bereit'); toast('Fehler: ' + e.message, true);
|
||||
} finally {
|
||||
launching = false;
|
||||
@@ -3000,6 +3220,21 @@ async function zeigeSkinFigur(bildId, leerId, textur, leerText, umhang) {
|
||||
viewer.controls.enableRotate = true;
|
||||
viewer.controls.enableZoom = true;
|
||||
viewer.controls.enablePan = false;
|
||||
// gerade übernommen -> einmal drehen bzw. Umhang flattern lassen
|
||||
const auftritt = bildId === 'skin-aktuell' ? holeSkinAuftritt() : null;
|
||||
if (auftritt) {
|
||||
const pl = viewer.playerObject;
|
||||
const basisY = pl.rotation.y;
|
||||
const umhangBasis = pl.cape ? pl.cape.rotation.x : 0;
|
||||
let wirkung = skinAuftrittWirkung(auftritt);
|
||||
viewer.animation = new skinview3d.FunctionAnimation((p) => {
|
||||
if (!wirkung) return;
|
||||
const w = wirkung();
|
||||
if (!w) wirkung = null;
|
||||
p.rotation.y = basisY + (w ? w.drehung : 0);
|
||||
if (p.cape) p.cape.rotation.x = umhangBasis + (w ? w.umhang : 0);
|
||||
});
|
||||
}
|
||||
skinViewers[bildId] = viewer;
|
||||
canvas.classList.remove('hidden');
|
||||
leer.classList.add('hidden');
|
||||
@@ -3032,11 +3267,106 @@ async function openSkin() {
|
||||
// Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird
|
||||
if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic';
|
||||
skinTexturAktuell = p.skinUrl || '';
|
||||
pruefeArmform(); // läuft nebenher, zeigt bei Bedarf einen Hinweis
|
||||
zeigeGarderobe();
|
||||
await ladeUmhangInfo();
|
||||
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
|
||||
'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
|
||||
}
|
||||
|
||||
// ---------- Passt die Armform des Kontos zur Skin-Datei? ----------
|
||||
const ARMFORM_NAME = { classic: 'Klassisch (breite Arme, Steve)', slim: 'Schmal (Alex)' };
|
||||
async function pruefeArmform() {
|
||||
const box = el('skin-armform-hinweis');
|
||||
box.classList.add('hidden');
|
||||
const r = await window.api.skinArmformPruefen().catch(() => null);
|
||||
if (!r || !r.ok || r.passt) return;
|
||||
el('skin-armform-text').textContent = r.datei === 'classic'
|
||||
? `⚠ Deine Skin-Datei ist für breite Arme gemalt, dein Konto steht aber auf „${ARMFORM_NAME.slim}“. Im Spiel wirken die Arme dadurch gequetscht – andere Spieler sehen dich genauso.`
|
||||
: `⚠ Deine Skin-Datei ist für schmale Arme gemalt, dein Konto steht aber auf „${ARMFORM_NAME.classic}“. Im Spiel erscheinen dadurch dunkle Streifen an den Armen.`;
|
||||
el('skin-armform-fix').textContent = r.datei === 'classic' ? 'Auf Klassisch umstellen' : 'Auf Schmal umstellen';
|
||||
el('skin-armform-fix').disabled = false;
|
||||
box.classList.remove('hidden');
|
||||
}
|
||||
async function korrigiereArmform() {
|
||||
el('skin-armform-fix').disabled = true;
|
||||
const r = await window.api.skinArmformKorrigieren();
|
||||
if (!r.ok) { toast(r.message || 'Umstellen fehlgeschlagen.', true); el('skin-armform-fix').disabled = false; return; }
|
||||
toast(`Armform auf „${ARMFORM_NAME[r.variante]}“ umgestellt.`);
|
||||
await openSkin();
|
||||
await refreshSkinBackground();
|
||||
}
|
||||
|
||||
// ---------- Garderobe: gespeicherte Skins und Umhänge ----------
|
||||
const garderobeVorschau = new Map(); // hash -> Bild (3D-Figur), damit nicht jedes Mal neu gezeichnet wird
|
||||
async function zeigeGarderobe() {
|
||||
const box = el('garderobe-liste');
|
||||
const eintraege = await window.api.garderobeListe().catch(() => []);
|
||||
box.innerHTML = '';
|
||||
if (!eintraege.length) {
|
||||
box.innerHTML = '<div class="garderobe-leer">Noch nichts gespeichert. „Aktuellen Skin speichern“ legt deinen jetzigen Skin hier ab.</div>';
|
||||
return;
|
||||
}
|
||||
for (const e of eintraege) {
|
||||
const karte = document.createElement('div');
|
||||
karte.className = 'gard-karte';
|
||||
const istSkin = e.art === 'skin';
|
||||
karte.innerHTML = `
|
||||
<button class="gard-mini links" title="Umbenennen">✎</button>
|
||||
<button class="gard-mini rechts" title="Aus der Garderobe löschen">✕</button>
|
||||
<img class="gard-bild ${istSkin ? '' : 'umhang'}" alt="" />
|
||||
<div class="gard-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</div>
|
||||
<div class="gard-merk">${istSkin ? (e.variante === 'slim' ? 'Skin · schmal' : 'Skin · breit') : 'AeroMC-Umhang'}${e.automatisch ? ' · automatisch' : ''}</div>
|
||||
<div class="gard-aktionen"><button class="btn btn-mini btn-primary">${istSkin ? 'Anziehen' : 'Anlegen'}</button></div>`;
|
||||
const bild = karte.querySelector('.gard-bild');
|
||||
if (istSkin) {
|
||||
if (garderobeVorschau.has(e.hash)) bild.src = garderobeVorschau.get(e.hash);
|
||||
else {
|
||||
bild.src = e.dataUri; // erst die Datei, dann die 3D-Figur nachreichen
|
||||
renderSkin3D(e.dataUri, 'standing', { modell: e.variante, breite: 128, hoehe: 192 }).then((b) => {
|
||||
if (b) { garderobeVorschau.set(e.hash, b); bild.src = b; }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
bild.src = e.dataUri;
|
||||
}
|
||||
karte.querySelector('.btn-primary').addEventListener('click', async (ev) => {
|
||||
ev.target.disabled = true;
|
||||
ev.target.textContent = '…';
|
||||
const r = await window.api.garderobeAnziehen(e.id);
|
||||
if (!r.ok) { toast(r.message || 'Fehlgeschlagen.', true); await zeigeGarderobe(); return; }
|
||||
toast(istSkin ? `„${e.name}“ angezogen.` : `Umhang „${e.name}“ angelegt.`);
|
||||
await openSkin();
|
||||
await refreshSkinBackground();
|
||||
});
|
||||
karte.querySelector('.gard-mini.links').addEventListener('click', async () => {
|
||||
const name = await askPrompt({ title: 'Umbenennen', label: 'Name', value: e.name, okLabel: 'Speichern' });
|
||||
if (name == null) return;
|
||||
await window.api.garderobeUmbenennen(e.id, name);
|
||||
await zeigeGarderobe();
|
||||
});
|
||||
karte.querySelector('.gard-mini.rechts').addEventListener('click', () => {
|
||||
askConfirm(`„${e.name}“ löschen?`, 'Der Eintrag wird aus der Garderobe entfernt. Deinen aktuellen Skin betrifft das nicht.', 'Löschen', async () => {
|
||||
await window.api.garderobeLoeschen(e.id);
|
||||
await zeigeGarderobe();
|
||||
});
|
||||
});
|
||||
box.appendChild(karte);
|
||||
}
|
||||
}
|
||||
|
||||
async function garderobeSpeichern(art) {
|
||||
const name = await askPrompt({
|
||||
title: art === 'skin' ? 'Skin in der Garderobe speichern' : 'Umhang in der Garderobe speichern',
|
||||
label: 'Name', value: art === 'skin' ? 'Mein Skin' : 'Mein Umhang', okLabel: 'Speichern',
|
||||
});
|
||||
if (name == null) return;
|
||||
const r = art === 'skin' ? await window.api.garderobeSkinSpeichern(name) : await window.api.garderobeUmhangSpeichern(name);
|
||||
if (!r.ok) { toast(r.message || 'Speichern fehlgeschlagen.', true); return; }
|
||||
toast(r.neu ? `„${r.name}“ gespeichert.` : `Schon in der Garderobe (als „${r.name}“).`);
|
||||
await zeigeGarderobe();
|
||||
}
|
||||
|
||||
// ---------- Umhänge im Skin-Fenster ----------
|
||||
let skinUmhang = null; // data:-Adresse des angezeigten Umhangs (AeroMC vor offiziell)
|
||||
|
||||
@@ -3128,6 +3458,7 @@ async function setzeSkin() {
|
||||
if (res.ok) {
|
||||
el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.';
|
||||
toast('Skin geändert.');
|
||||
merkeSkinAuftritt('drehen');
|
||||
await openSkin(); // Vorschau mit dem neuen Stand auffrischen
|
||||
await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
|
||||
} else {
|
||||
@@ -3316,7 +3647,145 @@ async function zeigeAbsturzbericht(instanzId, seit) {
|
||||
}
|
||||
|
||||
// Fortschrittsmeldungen der Download-/Start-Pipeline
|
||||
// ---------- Laufende Instanzen: pulsierender Rand ----------
|
||||
function setzeLaufende(liste) {
|
||||
laufendeIds = new Set(liste || []);
|
||||
document.querySelectorAll('#grid .card').forEach((c) => c.classList.toggle('laeuft', laufendeIds.has(c.dataset.id)));
|
||||
}
|
||||
|
||||
// ---------- Abbau-Risse (Minecraft-Design): Download-Fortschritt auf dem Block ----------
|
||||
// Eigene 16×16-Pixel-Risse, die Stufe für Stufe von der Mitte nach außen wachsen.
|
||||
let rissBilder = null;
|
||||
function rissStufen() {
|
||||
if (rissBilder) return rissBilder;
|
||||
let saat = 20261005;
|
||||
const zufall = () => (saat = (saat * 16807) % 2147483647) / 2147483647;
|
||||
const pixel = [];
|
||||
const gesehen = new Set();
|
||||
const linien = 8;
|
||||
for (let l = 0; l < linien; l++) {
|
||||
let x = 7.5 + (zufall() - 0.5) * 2;
|
||||
let y = 7.5 + (zufall() - 0.5) * 2;
|
||||
let w = (l / linien) * Math.PI * 2 + zufall() * 0.7;
|
||||
for (let s = 0; s < 11; s++) {
|
||||
w += (zufall() - 0.5) * 0.9; // Risse knicken unregelmäßig
|
||||
x += Math.cos(w) * 0.95;
|
||||
y += Math.sin(w) * 0.95;
|
||||
const px = Math.round(x);
|
||||
const py = Math.round(y);
|
||||
if (px < 0 || py < 0 || px > 15 || py > 15) break;
|
||||
const k = px + ',' + py;
|
||||
if (!gesehen.has(k)) { gesehen.add(k); pixel.push([px, py, s + zufall() * 0.5]); }
|
||||
}
|
||||
}
|
||||
pixel.sort((a, b) => a[2] - b[2]);
|
||||
rissBilder = [];
|
||||
for (let stufe = 1; stufe <= 9; stufe++) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 16;
|
||||
const g = c.getContext('2d');
|
||||
const n = Math.round((pixel.length * stufe) / 9);
|
||||
for (const [x, y] of pixel.slice(0, n)) {
|
||||
g.fillStyle = 'rgba(28,18,10,.62)';
|
||||
g.fillRect(x, y, 1, 1);
|
||||
if (x < 15 && !gesehen.has((x + 1) + ',' + y)) { g.fillStyle = 'rgba(255,255,255,.12)'; g.fillRect(x + 1, y, 1, 1); }
|
||||
}
|
||||
rissBilder.push(c.toDataURL());
|
||||
}
|
||||
return rissBilder;
|
||||
}
|
||||
function zeigeAbbau(card) {
|
||||
const stand = startStand.get(card.dataset.id);
|
||||
const riss = card.querySelector('.card-riss');
|
||||
if (!riss) return;
|
||||
if (stand && stand.prozent < 100) {
|
||||
const stufe = Math.max(0, Math.min(8, Math.floor((stand.prozent / 100) * 9)));
|
||||
riss.style.backgroundImage = `url(${rissStufen()[stufe]})`;
|
||||
card.classList.add('abbau');
|
||||
} else if (card.classList.contains('abbau')) {
|
||||
// fertig: der Block "bricht" kurz auf
|
||||
card.classList.remove('abbau');
|
||||
card.classList.add('abgebaut');
|
||||
setTimeout(() => card.classList.remove('abgebaut'), 450);
|
||||
}
|
||||
}
|
||||
function aktualisiereAbbau() {
|
||||
document.querySelectorAll('#grid .card').forEach((c) => {
|
||||
if (startStand.has(c.dataset.id) || c.classList.contains('abbau')) zeigeAbbau(c);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Block-Symbol kippt leicht zur Maus ----------
|
||||
function kippeZurMaus(e) {
|
||||
if (!bewegungErlaubt()) return;
|
||||
const card = e.target.closest('.card');
|
||||
if (!card) return;
|
||||
const r = card.getBoundingClientRect();
|
||||
card.style.setProperty('--kx', ((e.clientX - r.left) / r.width - 0.5).toFixed(3));
|
||||
card.style.setProperty('--ky', ((e.clientY - r.top) / r.height - 0.5).toFixed(3));
|
||||
}
|
||||
|
||||
// ---------- Lichtpunkte im Hintergrund (AeroMC/Nachtlila) ----------
|
||||
function baueLichtpunkte() {
|
||||
const box = el('lichtpunkte');
|
||||
if (!box || box.childElementCount) return;
|
||||
const z = (a, b) => a + Math.random() * (b - a);
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const p = document.createElement('i');
|
||||
p.style.cssText = `--x:${z(2, 98).toFixed(1)}%;--y:${z(8, 100).toFixed(1)}%;--g:${z(2, 4.2).toFixed(1)}px;`
|
||||
+ `--d:${z(16, 30).toFixed(1)}s;--v:-${z(0, 30).toFixed(1)}s;--o:${z(0.25, 0.6).toFixed(2)};`
|
||||
+ `--dx:${z(-50, 50).toFixed(0)}px;--dy:${z(-170, -60).toFixed(0)}px`;
|
||||
box.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Starten-Knopf mit Fortschritt ----------
|
||||
// Jede Phase des Starts bekommt einen Abschnitt des Balkens; Phasen mit
|
||||
// current/total (Libraries, Assets …) füllen ihren Abschnitt anteilig.
|
||||
const START_PHASEN = {
|
||||
backup: [0, 4, 'Sicherung'], 'mod-updates': [4, 8, 'Mod-Updates'], modpack: [0, 8, 'Modpack'],
|
||||
version: [8, 12, 'Version'], client: [12, 22, 'Client'], libraries: [22, 45, 'Libraries'],
|
||||
assets: [45, 80, 'Assets'], loader: [80, 90, 'Loader'], java: [90, 96, 'Java'], launch: [96, 100, 'Startet'],
|
||||
};
|
||||
const startStand = new Map(); // Instanz-ID -> { prozent, text }
|
||||
function zeigeStartKnopf() {
|
||||
const knopf = el('p-play');
|
||||
if (!knopf) { aktualisiereAbbau(); return; }
|
||||
const stand = currentDetailId ? startStand.get(currentDetailId) : null;
|
||||
knopf.classList.toggle('laedt', !!stand);
|
||||
knopf.style.setProperty('--fortschritt', stand ? stand.prozent + '%' : '0%');
|
||||
knopf.querySelector('.play-text').textContent = stand ? stand.text : 'Starten';
|
||||
aktualisiereAbbau();
|
||||
}
|
||||
function merkeStartFortschritt(evt) {
|
||||
if (!evt.id) return;
|
||||
if (evt.phase === 'exit') { startStand.delete(evt.id); zeigeStartKnopf(); return; }
|
||||
const p = START_PHASEN[evt.phase];
|
||||
if (!p) return;
|
||||
const [von, bis, name] = p;
|
||||
const anteil = evt.total ? Math.min(1, evt.current / evt.total) : 0;
|
||||
const prozent = Math.round(von + (bis - von) * anteil);
|
||||
const vorher = startStand.get(evt.id);
|
||||
startStand.set(evt.id, {
|
||||
prozent: Math.max(prozent, vorher ? vorher.prozent : 0), // nie rückwärts
|
||||
text: evt.phase === 'launch' ? 'Startet …' : `${name} ${Math.max(prozent, vorher ? vorher.prozent : 0)} %`,
|
||||
});
|
||||
zeigeStartKnopf();
|
||||
}
|
||||
function beendeStartFortschritt(id, erfolgreich) {
|
||||
if (!startStand.has(id)) return;
|
||||
if (erfolgreich) {
|
||||
startStand.set(id, { prozent: 100, text: 'Läuft ✓' });
|
||||
zeigeStartKnopf();
|
||||
setTimeout(() => { startStand.delete(id); zeigeStartKnopf(); }, 2500);
|
||||
} else {
|
||||
startStand.delete(id);
|
||||
zeigeStartKnopf();
|
||||
}
|
||||
}
|
||||
|
||||
function onLaunchEvt(evt) {
|
||||
if (!POPOUT && evt.phase !== 'log') merkeStartFortschritt(evt);
|
||||
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung', 'mod-updates': 'Mod-Updates' };
|
||||
if (evt.phase === 'log') {
|
||||
appendConsole(evt.line);
|
||||
@@ -4521,13 +4990,91 @@ async function installiereTeilCode() {
|
||||
// ---------- Spielzeit-Statistik ----------
|
||||
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
|
||||
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
|
||||
async function openStats() {
|
||||
if (POPOUT !== 'stats') { window.api.popoutOpen('stats'); return; }
|
||||
async function openStats(reiter) {
|
||||
if (POPOUT !== 'stats') { window.api.popoutOpen('stats', reiter ? { reiter } : undefined); return; }
|
||||
if (reiter) waehleStatistikReiter(reiter);
|
||||
show('modal-stats');
|
||||
await ladeStatistik();
|
||||
}
|
||||
|
||||
// ---------- Erfolge ----------
|
||||
function zeigeErfolgMeldung(daten) {
|
||||
if (POPOUT) return;
|
||||
const box = el('erfolg-meldungen');
|
||||
const liste = daten.liste || [];
|
||||
// viele auf einmal (z. B. nach dem Update): eine Sammelmeldung statt einer Flut
|
||||
const eintraege = liste.length > 2
|
||||
? [{ symbol: 'star', kopf: daten.ersteAuswertung ? `${liste.length} Erfolge freigeschaltet!` : `${liste.length} Erfolge erzielt!`, name: 'Alle in der Statistik ansehen' }]
|
||||
: liste.map((e) => ({ symbol: e.symbol, kopf: 'Erfolg erzielt!', name: e.titel }));
|
||||
eintraege.forEach((e, i) => {
|
||||
const m = document.createElement('div');
|
||||
m.className = 'erfolg';
|
||||
m.innerHTML = `<div class="erfolg-symbol">${iconOf(e.symbol, 28)}</div><div><div class="erfolg-kopf">${escapeHtml(e.kopf)}</div><div class="erfolg-name">${escapeHtml(e.name)}</div></div>`;
|
||||
m.title = 'Alle Erfolge ansehen';
|
||||
m.addEventListener('click', () => window.api.popoutOpen('stats', { reiter: 'erfolge' }));
|
||||
setTimeout(() => {
|
||||
box.appendChild(m);
|
||||
if (bewegungErlaubt()) erfolgFunken(m);
|
||||
setTimeout(() => { m.classList.add('weg'); setTimeout(() => m.remove(), 400); }, 5500);
|
||||
}, i * 700);
|
||||
});
|
||||
}
|
||||
|
||||
// Funken um die Meldung – im Minecraft-Design grüne XP-Kugeln, die hochhüpfen
|
||||
function erfolgFunken(m) {
|
||||
const mc = document.documentElement.dataset.design === 'minecraft';
|
||||
const z = (a, b) => a + Math.random() * (b - a);
|
||||
const n = mc ? 10 : 14;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = document.createElement('i');
|
||||
f.className = mc ? 'xp-kugel' : 'funke';
|
||||
m.appendChild(f);
|
||||
const weg = () => f.remove();
|
||||
if (mc) {
|
||||
// aus dem Symbol heraus nach oben, dann seitlich herunterfallen
|
||||
f.style.left = z(14, 38) + 'px';
|
||||
f.style.top = '50%';
|
||||
const dx = z(-30, 150);
|
||||
f.animate([
|
||||
{ transform: 'translate(0,0) scale(.4)', opacity: 0 },
|
||||
{ transform: `translate(${dx * 0.45}px,${-z(28, 52)}px) scale(1)`, opacity: 1, offset: 0.4 },
|
||||
{ transform: `translate(${dx}px,${z(4, 22)}px) scale(.7)`, opacity: 0 },
|
||||
], { duration: z(900, 1300), delay: 380 + i * 45, easing: 'cubic-bezier(.3,.6,.5,1)', fill: 'both' }).finished.then(weg, weg);
|
||||
} else {
|
||||
// vom Symbol aus nach allen Seiten aufleuchten
|
||||
f.style.left = '26px';
|
||||
f.style.top = '50%';
|
||||
const w = (i / n) * Math.PI * 2 + z(-0.2, 0.2);
|
||||
const r = z(45, 105);
|
||||
const bahn = (anteil) => `translate(calc(-50% + ${(Math.cos(w) * r * anteil).toFixed(1)}px),calc(-50% + ${(Math.sin(w) * r * 0.65 * anteil).toFixed(1)}px))`;
|
||||
f.animate([
|
||||
{ transform: `${bahn(0)} scale(.3)`, opacity: 0 },
|
||||
{ transform: `${bahn(0.4)} scale(1.25)`, opacity: 1, offset: 0.2 },
|
||||
{ transform: `${bahn(0.8)} scale(.9)`, opacity: 0.9, offset: 0.6 },
|
||||
{ transform: `${bahn(1)} scale(.2)`, opacity: 0 },
|
||||
], { duration: z(1000, 1400), delay: 330 + i * 22, easing: 'cubic-bezier(.2,.7,.3,1)', fill: 'both' }).finished.then(weg, weg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function zeigeErfolgeInStatistik() {
|
||||
const liste = await window.api.erfolgeListe().catch(() => []);
|
||||
const frei = liste.filter((e) => e.am).length;
|
||||
el('erfolge-zahl').textContent = `(${frei}/${liste.length})`;
|
||||
el('erfolge-liste').innerHTML = liste.map((e) => `<div class="erfolg-karte${e.am ? '' : ' offen'}" title="${escapeHtml(e.text)}">
|
||||
<div class="erfolg-symbol">${iconOf(e.symbol, 24)}</div>
|
||||
<div><b>${escapeHtml(e.titel)}</b><small>${escapeHtml(e.text)}${e.am ? ' · ' + escapeHtml(fmtDate(e.am)) : ' · 🔒'}</small></div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function waehleStatistikReiter(name) {
|
||||
document.querySelectorAll('[data-stats-reiter]').forEach((b) => b.classList.toggle('on', b.dataset.statsReiter === name));
|
||||
el('stats-reiter-spielzeit').classList.toggle('hidden', name !== 'spielzeit');
|
||||
el('stats-reiter-erfolge').classList.toggle('hidden', name !== 'erfolge');
|
||||
}
|
||||
|
||||
async function ladeStatistik() {
|
||||
zeigeErfolgeInStatistik();
|
||||
const alle = await window.api.listInstances();
|
||||
const gespielt = alle.filter((i) => (i.totalPlaySeconds || 0) > 0)
|
||||
.sort((a, b) => (b.totalPlaySeconds || 0) - (a.totalPlaySeconds || 0));
|
||||
@@ -4583,6 +5130,58 @@ async function openFriends() {
|
||||
friendsAutoRefresh = setInterval(() => (chatPartner ? ladeChat() : ladeFreunde()), chatPartner ? 10000 : 30000);
|
||||
}
|
||||
|
||||
// ---------- Freunde: 3D-Köpfe und Figur des ausgewählten Freundes ----------
|
||||
function setzeFreundKoepfe() {
|
||||
document.querySelectorAll('#friends-content img[data-kopf]').forEach((img) => {
|
||||
const uuid = img.dataset.kopf;
|
||||
kontoKopf(uuid).then((src) => { img.src = src; });
|
||||
});
|
||||
}
|
||||
|
||||
let freundBuehne = null; // { viewer, uuid }
|
||||
async function zeigeFreundFigur(uuid, name, status, online) {
|
||||
const box = el('freund-buehne');
|
||||
el('freund-buehne-name').textContent = name;
|
||||
el('freund-buehne-status').textContent = status;
|
||||
document.querySelectorAll('.friend-row.gezeigt').forEach((r) => r.classList.remove('gezeigt'));
|
||||
const zeile = document.querySelector(`.friend-row[data-uuid="${CSS.escape(uuid)}"]`);
|
||||
if (zeile) zeile.classList.add('gezeigt');
|
||||
if (freundBuehne && freundBuehne.uuid === uuid) return;
|
||||
if (typeof skinview3d === 'undefined') return;
|
||||
box.classList.remove('hidden');
|
||||
const canvas = el('freund-figur');
|
||||
if (!freundBuehne) {
|
||||
const viewer = new skinview3d.SkinViewer({ canvas, width: box.clientWidth || 400, height: box.clientHeight || 190 });
|
||||
viewer.fov = 40;
|
||||
viewer.zoom = 0.72;
|
||||
viewer.controls.enableZoom = false;
|
||||
viewer.controls.enablePan = false;
|
||||
new ResizeObserver(() => viewer.setSize(box.clientWidth, box.clientHeight)).observe(box);
|
||||
document.addEventListener('visibilitychange', () => { viewer.renderPaused = document.hidden; });
|
||||
freundBuehne = { viewer, uuid: null };
|
||||
}
|
||||
const { viewer } = freundBuehne;
|
||||
freundBuehne.uuid = uuid;
|
||||
let modell = 'classic';
|
||||
try { modell = await window.api.skinModelFor(uuid); } catch { /* breite Arme */ }
|
||||
if (freundBuehne.uuid !== uuid) return; // inzwischen anderer Freund gewählt
|
||||
try { await viewer.loadSkin(`https://crafthead.net/skin/${uuid}`, { model: modell === 'slim' ? 'slim' : 'default' }); } catch { /* Standard bleibt */ }
|
||||
try {
|
||||
const r = await window.api.capeForUuid(uuid);
|
||||
if (freundBuehne.uuid !== uuid) return;
|
||||
if (r && r.dataUri) await viewer.loadCape(r.dataUri); else viewer.resetCape();
|
||||
} catch { viewer.resetCape(); }
|
||||
// online: läuft, offline: steht und atmet – ohne Bewegung (Optionen) ganz ruhig
|
||||
const bewegt = bewegungErlaubt();
|
||||
viewer.autoRotate = bewegt;
|
||||
viewer.autoRotateSpeed = 0.7;
|
||||
viewer.animation = !bewegt ? null : (online ? new skinview3d.WalkingAnimation() : new skinview3d.IdleAnimation());
|
||||
if (viewer.animation) viewer.animation.speed = online ? 0.6 : 1;
|
||||
}
|
||||
|
||||
// zuletzt gesehener Online-Stand je Freund – für das kurze Hüpfen beim Online-Kommen
|
||||
const freundOnlineVorher = new Map();
|
||||
|
||||
async function ladeFreunde() {
|
||||
const aktiv = await window.api.friendsEnabled();
|
||||
el('friends-disabled-hint').classList.toggle('hidden', aktiv);
|
||||
@@ -4611,7 +5210,7 @@ async function ladeFreunde() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'friend-row';
|
||||
const name = spitznamen[inv.fromUuid] || inv.fromUsername || inv.fromUuid;
|
||||
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(inv.fromUuid)}/64" alt="" />
|
||||
row.innerHTML = `<img class="friend-avatar" data-kopf="${escapeHtml(inv.fromUuid)}" alt="" />
|
||||
<div class="friend-info"><div class="friend-name">${escapeHtml(name)}</div><div class="friend-status">lädt dich ein · ${escapeHtml(inv.server)}${inv.version ? ' · ' + escapeHtml(inv.version) : ''}</div></div>
|
||||
<button class="btn btn-mini btn-primary" data-act="join">Beitreten</button>
|
||||
<button class="btn btn-mini" data-act="dismiss" title="Ablehnen">✕</button>`;
|
||||
@@ -4632,7 +5231,7 @@ async function ladeFreunde() {
|
||||
for (const r of anfragenRes.list) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'friend-row';
|
||||
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(r.uuid)}/64" alt="" />
|
||||
row.innerHTML = `<img class="friend-avatar" data-kopf="${escapeHtml(r.uuid)}" alt="" />
|
||||
<div class="friend-info"><div class="friend-name">${escapeHtml(r.username || r.uuid)}</div></div>
|
||||
<button class="btn btn-mini" data-act="accept">Annehmen</button>
|
||||
<button class="btn btn-mini btn-danger" data-act="decline">Ablehnen</button>`;
|
||||
@@ -4656,7 +5255,7 @@ async function ladeFreunde() {
|
||||
for (const r of gesendetRes.list) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'friend-row';
|
||||
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(r.uuid)}/64" alt="" />
|
||||
row.innerHTML = `<img class="friend-avatar" data-kopf="${escapeHtml(r.uuid)}" alt="" />
|
||||
<div class="friend-info"><div class="friend-name">${escapeHtml(r.username || r.uuid)}</div><div class="friend-status">wartet auf Bestätigung</div></div>
|
||||
<button class="btn btn-mini btn-danger" data-act="cancel">Zurückziehen</button>`;
|
||||
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
||||
@@ -4668,8 +5267,10 @@ async function ladeFreunde() {
|
||||
}
|
||||
}
|
||||
|
||||
setzeFreundKoepfe(); // Köpfe bei Einladungen/Anfragen
|
||||
const listBox = el('friends-list-box');
|
||||
listBox.innerHTML = '<div class="friends-section-title">Freunde</div>';
|
||||
if (!freundeRes.ok || !freundeRes.list.length) el('freund-buehne').classList.add('hidden');
|
||||
if (!freundeRes.ok) {
|
||||
listBox.innerHTML += `<div class="friends-empty">${escapeHtml(freundeRes.message || 'Nicht erreichbar.')}</div>`;
|
||||
return;
|
||||
@@ -4678,6 +5279,7 @@ async function ladeFreunde() {
|
||||
listBox.innerHTML += '<div class="friends-empty">Noch keine Freunde. Namen oben eingeben, um eine Anfrage zu senden.</div>';
|
||||
return;
|
||||
}
|
||||
let ersterFreund = null;
|
||||
const sortiert = [...freundeRes.list].sort((a, b) => {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
return (a.username || '').localeCompare(b.username || '');
|
||||
@@ -4692,8 +5294,10 @@ async function ladeFreunde() {
|
||||
: 'offline';
|
||||
const kannBeitreten = f.online && f.server && (!meinServer || meinServer.server !== f.server);
|
||||
const kannEinladen = f.online && meinServer && meinServer.server !== f.server;
|
||||
if (f.online && freundOnlineVorher.get(f.uuid) === false) row.classList.add('kommt-online');
|
||||
freundOnlineVorher.set(f.uuid, !!f.online);
|
||||
row.innerHTML = `<span class="friend-dot${f.online ? ' online' : ''}"></span>
|
||||
<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(f.uuid)}/64" alt="" />
|
||||
<img class="friend-avatar" data-kopf="${escapeHtml(f.uuid)}" alt="" />
|
||||
<div class="friend-info"><div class="friend-name">${escapeHtml(anzeigeName)}</div><div class="friend-status">${escapeHtml(status)}</div></div>
|
||||
${kannBeitreten ? '<button class="btn btn-mini btn-primary" data-act="join" title="Passende Instanz starten und diesem Server beitreten">Beitreten</button>' : ''}
|
||||
${kannEinladen ? `<button class="btn btn-mini" data-act="invite" title="Auf ${escapeHtml(meinServer.server)} einladen">Einladen</button>` : ''}
|
||||
@@ -4701,6 +5305,11 @@ async function ladeFreunde() {
|
||||
<button class="btn btn-mini" data-act="nick" title="Spitzname">✎</button>
|
||||
<button class="btn btn-mini btn-danger" data-act="remove" title="Entfernen">🗑</button>`;
|
||||
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
||||
row.dataset.uuid = f.uuid;
|
||||
const zeigen = () => zeigeFreundFigur(f.uuid, anzeigeName, status, f.online);
|
||||
row.addEventListener('mouseenter', zeigen);
|
||||
row.addEventListener('click', (ev) => { if (!ev.target.closest('button')) zeigen(); });
|
||||
if (!ersterFreund || (f.online && !ersterFreund.online)) ersterFreund = { uuid: f.uuid, name: anzeigeName, status, online: f.online };
|
||||
if (kannBeitreten) row.querySelector('[data-act="join"]').addEventListener('click', () => freundBeitreten(f.server, f.version));
|
||||
row.querySelector('[data-act="chat"]').addEventListener('click', () => oeffneChat(f.uuid, anzeigeName));
|
||||
if (kannEinladen) {
|
||||
@@ -4731,11 +5340,21 @@ async function ladeFreunde() {
|
||||
row.querySelector('[data-act="remove"]').addEventListener('click', () => {
|
||||
askConfirm('Freund entfernen', `„${anzeigeName}" aus der Freundesliste entfernen?`, '🗑 Entfernen', async () => {
|
||||
await window.api.friendsRemove(f.uuid);
|
||||
if (freundBuehne && freundBuehne.uuid === f.uuid) freundBuehne.uuid = null;
|
||||
ladeFreunde();
|
||||
});
|
||||
});
|
||||
listBox.appendChild(row);
|
||||
}
|
||||
setzeFreundKoepfe();
|
||||
// Bühne: bisher gezeigter Freund bleibt, sonst der erste, der online ist
|
||||
const gezeigt = freundBuehne && freundBuehne.uuid && sortiert.find((f) => f.uuid === freundBuehne.uuid);
|
||||
if (gezeigt) {
|
||||
const z = document.querySelector(`.friend-row[data-uuid="${CSS.escape(gezeigt.uuid)}"]`);
|
||||
if (z) z.classList.add('gezeigt');
|
||||
} else if (ersterFreund) {
|
||||
zeigeFreundFigur(ersterFreund.uuid, ersterFreund.name, ersterFreund.status, ersterFreund.online);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -5044,6 +5663,31 @@ function sortInstalled(list) {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
// ---------- Leistungs-Paket ----------
|
||||
function installiereLeistungsPaket() {
|
||||
askConfirm('Leistungs-Paket installieren?',
|
||||
'AeroMC installiert bewährte Performance-Mods passend zu Minecraft-Version und Loader dieser Instanz: '
|
||||
+ 'Sodium (oder Embeddium), Lithium, FerriteCore, Entity Culling, ImmediatelyFast, ModernFix und Dynamic FPS – samt nötiger Abhängigkeiten. '
|
||||
+ 'Was es für diese Version nicht gibt oder schon installiert ist, wird übersprungen.',
|
||||
'Installieren', async () => {
|
||||
const b = el('m-leistung');
|
||||
b.disabled = true;
|
||||
b.textContent = '⚡ Wird vorbereitet …';
|
||||
const r = await window.api.modsLeistungsPaket(currentDetailId).catch((err) => ({ ok: false, message: err.message }));
|
||||
b.disabled = false;
|
||||
b.textContent = '⚡ Leistungs-Paket';
|
||||
if (!r.ok) { toast(r.message || 'Fehlgeschlagen.', true); return; }
|
||||
const teile = [];
|
||||
if (r.neu.length) teile.push(`Installiert: ${r.neu.join(', ')}`);
|
||||
if (r.schonDa.length) teile.push(`schon vorhanden: ${r.schonDa.join(', ')}`);
|
||||
if (r.nichtVerfuegbar.length) teile.push(`für diese Version nicht verfügbar: ${r.nichtVerfuegbar.join(', ')}`);
|
||||
if (r.uebersprungen.length) teile.push(`übersprungen: ${r.uebersprungen.join(', ')}`);
|
||||
toast(teile.join(' · ') || 'Nichts zu tun.', !r.neu.length && !r.schonDa.length);
|
||||
await refreshInstalled();
|
||||
await loadCatalog(false); // Katalog zeigt neue Mods als „✓ Installiert“
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshInstalled() {
|
||||
const list = sortInstalled(await window.api.modList(currentDetailId, modArt));
|
||||
currentModsInstalled = new Set(list.map((m) => m.projectId));
|
||||
@@ -5782,14 +6426,24 @@ async function kontoKopf(uuid) {
|
||||
return auftrag;
|
||||
}
|
||||
|
||||
let kontoKnopfUuid = null;
|
||||
async function refreshAccountButton() {
|
||||
const acc = await window.api.authStatus();
|
||||
const av = el('account-av');
|
||||
const uuid = acc && acc.profile ? acc.profile.id : null;
|
||||
const muenze = !!(kontoKnopfUuid && uuid && kontoKnopfUuid !== uuid && bewegungErlaubt() && !POPOUT);
|
||||
kontoKnopfUuid = uuid;
|
||||
const kopfBild = uuid ? await kontoKopf(uuid) : null; // vor dem Drehen fertig haben
|
||||
if (muenze) {
|
||||
// erste Hälfte: alter Kopf dreht sich weg (Kante zum Betrachter) …
|
||||
await av.animate([{ transform: 'perspective(140px) rotateY(0deg)' }, { transform: 'perspective(140px) translateY(-3px) rotateY(90deg)' }],
|
||||
{ duration: 170, easing: 'cubic-bezier(.5,0,.9,.6)' }).finished.catch(() => {});
|
||||
}
|
||||
if (acc && acc.profile) {
|
||||
av.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'acct-head';
|
||||
img.src = await kontoKopf(acc.profile.id);
|
||||
img.src = kopfBild;
|
||||
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
|
||||
av.appendChild(img);
|
||||
el('account-name').textContent = acc.profile.name;
|
||||
@@ -5797,6 +6451,11 @@ async function refreshAccountButton() {
|
||||
av.innerHTML = svgIcon('user');
|
||||
el('account-name').textContent = 'Konten';
|
||||
}
|
||||
// … zweite Hälfte: der neue Kopf dreht sich herein und federt nach
|
||||
if (muenze) {
|
||||
av.animate([{ transform: 'perspective(140px) translateY(-3px) rotateY(-90deg)' }, { transform: 'perspective(140px) rotateY(12deg)', offset: 0.75 }, { transform: 'perspective(140px) rotateY(0deg)' }],
|
||||
{ duration: 300, easing: 'cubic-bezier(.2,.7,.3,1)' });
|
||||
}
|
||||
}
|
||||
|
||||
// aktualisiert Kontoliste im Login-Dialog und in den Einstellungen
|
||||
@@ -6383,6 +7042,9 @@ function wire() {
|
||||
el('srv-ip').addEventListener('keydown', (e) => { if (e.key === 'Enter') addServer(); });
|
||||
el('srv-copy-do').addEventListener('click', doServerCopy);
|
||||
el('login-skin').addEventListener('click', openSkin);
|
||||
el('skin-armform-fix').addEventListener('click', korrigiereArmform);
|
||||
el('gard-skin-speichern').addEventListener('click', () => garderobeSpeichern('skin'));
|
||||
el('gard-umhang-speichern').addEventListener('click', () => garderobeSpeichern('umhang'));
|
||||
el('skin-pick').addEventListener('click', waehleSkinDatei);
|
||||
el('skin-variante').addEventListener('change', skinVarianteGewechselt);
|
||||
el('skin-cape-offiziell').addEventListener('change', waehleOffiziellenUmhang);
|
||||
@@ -6501,6 +7163,11 @@ function wire() {
|
||||
el('m-installed-sort').addEventListener('change', (e) => { installedSort = e.target.value; refreshInstalled(); });
|
||||
el('m-art').addEventListener('change', modArtGewechselt);
|
||||
el('m-open-folder').addEventListener('click', () => window.api.openGameDir(currentDetailId));
|
||||
el('m-leistung').addEventListener('click', installiereLeistungsPaket);
|
||||
window.api.onPaketFortschritt((p) => {
|
||||
const b = el('m-leistung');
|
||||
if (b.disabled) b.textContent = `⚡ ${p.nr}/${p.von}: ${p.titel} …`;
|
||||
});
|
||||
// Automatische Sicherung: unaufdringlich melden, Fehler deutlich
|
||||
window.api.onAutoBackup((d) => {
|
||||
if (d.ok) toast(d.anlass === 'vor-dem-start' ? 'Sicherung vor dem Start erstellt.' : 'Automatische Sicherung erstellt.');
|
||||
@@ -6855,7 +7522,7 @@ const POPOUT_OPENERS = {
|
||||
},
|
||||
record: () => openRecording(),
|
||||
friends: () => openFriends(),
|
||||
stats: () => openStats(),
|
||||
stats: (p) => openStats(p.reiter),
|
||||
};
|
||||
|
||||
function popoutBoot(params) {
|
||||
@@ -6944,6 +7611,13 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
if (POPOUT === 'friends') window.api.onFriendsMessage(() => (chatPartner ? ladeChat() : ladeFreunde()));
|
||||
await refreshSkinBackground();
|
||||
window.api.onLaunchProgress(onLaunchEvt);
|
||||
window.api.onErfolgeNeu(zeigeErfolgMeldung);
|
||||
el('upd-notes').addEventListener('click', updateLinkKlick);
|
||||
window.api.onLaufendeInstanzen(setzeLaufende);
|
||||
window.api.laufendeInstanzen().then(setzeLaufende).catch(() => {});
|
||||
el('grid').addEventListener('mousemove', kippeZurMaus);
|
||||
baueLichtpunkte();
|
||||
document.querySelectorAll('[data-stats-reiter]').forEach((b) => b.addEventListener('click', () => waehleStatistikReiter(b.dataset.statsReiter)));
|
||||
// Servername live in den Titel nachtragen, falls das Aufzeichnungs-Fenster
|
||||
// schon offen war, BEVOR die Verbindung zum Server erkannt wurde
|
||||
window.api.onRecordingServerDetected(({ id }) => {
|
||||
@@ -6999,9 +7673,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
|
||||
window.api.onSkinChanged((info) => {
|
||||
kontoKoepfe.clear();
|
||||
if (!POPOUT) { refreshSkinBackground(); refreshAccountButton(); }
|
||||
const auftritt = info.quelle === 'umhang' ? 'flattern' : 'drehen';
|
||||
if (!POPOUT) { merkeSkinAuftritt(auftritt); refreshSkinBackground(); refreshAccountButton(); }
|
||||
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
|
||||
if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
|
||||
if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) { merkeSkinAuftritt(auftritt); openSkin(); }
|
||||
});
|
||||
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
|
||||
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
|
||||
|
||||
+29
-1
@@ -176,4 +176,32 @@ function waehleAnzeigeSkin(kontoProfil, spiel, kuerzlichGeaendert) {
|
||||
return Object.assign({}, kontoProfil, { skinUrl: spiel.skinUrl, variante: spiel.variante, skinAusSpiel: true });
|
||||
}
|
||||
|
||||
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin };
|
||||
/*
|
||||
* Für welche Armform ist eine Skin-Datei gemalt? Breite Arme (Steve) sind 4
|
||||
* Pixel breit, schmale (Alex) 3 – bei schmalen bleiben deshalb je Arm zwei
|
||||
* Pixelspalten der Rückseite leer: rechter Arm x 54–55 / y 20–31, linker Arm
|
||||
* x 46–47 / y 52–63. bitmap: Rohdaten mit 4 Byte je Pixel (Alpha an Stelle 3).
|
||||
* -> 'classic' | 'slim' | null (nicht eindeutig, alte 64×32-Skins: classic)
|
||||
*/
|
||||
function armformDerTextur(bitmap, breite, hoehe) {
|
||||
if (!bitmap || !breite || breite % 64 !== 0 || hoehe % 32 !== 0) return null;
|
||||
const f = breite / 64; // HD-Skins (128×128 …) mitskalieren
|
||||
if (Math.round(hoehe / f) === 32) return 'classic';
|
||||
if (Math.round(hoehe / f) !== 64) return null;
|
||||
let deckend = 0, gesamt = 0;
|
||||
const pruefe = (x0, y0) => {
|
||||
for (let y = y0 * f; y < (y0 + 12) * f; y++) {
|
||||
for (let x = x0 * f; x < (x0 + 2) * f; x++) {
|
||||
gesamt++;
|
||||
if (bitmap[(y * breite + x) * 4 + 3] > 0) deckend++;
|
||||
}
|
||||
}
|
||||
};
|
||||
pruefe(54, 20);
|
||||
pruefe(46, 52);
|
||||
if (deckend >= gesamt * 0.85) return 'classic';
|
||||
if (deckend <= gesamt * 0.1) return 'slim';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin, armformDerTextur };
|
||||
|
||||
+160
-3
@@ -32,7 +32,7 @@ body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:mi
|
||||
: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"] .path-row input,:root[data-theme="dark"] .path-row select,:root[data-theme="dark"] select.s-wert,:root[data-theme="dark"] input.s-wert,:root[data-theme="dark"] .s-paar input,:root[data-theme="dark"] .s-einheit 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}
|
||||
@@ -133,8 +133,26 @@ body.is-popout>.titelleiste{display:none}
|
||||
.ui-animiert .list.einblenden .group-header{animation:karte-rein .42s cubic-bezier(.2,.8,.2,1) both;animation-delay:calc(var(--i,0) * 28ms)}
|
||||
@keyframes karte-rein{from{opacity:0;transform:translateY(12px) scale(.94)}}
|
||||
/* Block-Symbol hebt sich beim Darüberfahren und dreht sich ein Stück */
|
||||
/* … und kippt dabei leicht zur Maus (--kx/--ky setzt kippeZurMaus) */
|
||||
.ui-animiert .card-icon,.ui-animiert .card-icon-img{transition:transform .22s cubic-bezier(.2,.8,.2,1),filter .22s}
|
||||
.ui-animiert .card:hover .card-icon,.ui-animiert .card:hover .card-icon-img{transform:translateY(-4px) rotate(-7deg) scale(1.06);filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
|
||||
.ui-animiert .card:hover .card-icon,.ui-animiert .card:hover .card-icon-img{
|
||||
transform:perspective(220px) translateY(-4px) rotateX(calc(var(--ky,0) * -26deg)) rotateY(calc(var(--kx,0) * 30deg)) rotate(-4deg) scale(1.07);
|
||||
filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
|
||||
/* Zahnrad dreht sich beim Darüberfahren */
|
||||
.ui-animiert #btn-settings svg{transition:transform .6s cubic-bezier(.3,.7,.3,1)}
|
||||
.ui-animiert #btn-settings:hover svg{transform:rotate(120deg)}
|
||||
/* Karte einer laufenden Instanz: pulsierender Rand */
|
||||
.card.laeuft::after{content:'';position:absolute;inset:-1px;border:2px solid var(--accent);border-radius:inherit;pointer-events:none;opacity:.85}
|
||||
.ui-animiert .card.laeuft::after{animation:laeuft-puls 1.9s ease-in-out infinite}
|
||||
@keyframes laeuft-puls{0%,100%{opacity:.3;box-shadow:0 0 0 0 transparent}50%{opacity:1;box-shadow:0 0 12px 0 var(--accent)}}
|
||||
/* weggefallene Karte schrumpft an ihrer alten Stelle weg */
|
||||
.flip-geist{position:fixed!important;margin:0!important;pointer-events:none;z-index:50}
|
||||
/* Abbau-Risse auf dem Block (nur im Minecraft-Design sichtbar) */
|
||||
.card-riss{display:none;position:absolute;left:50%;top:14px;width:44px;height:44px;margin-left:-22px;background-size:100% 100%;image-rendering:pixelated;pointer-events:none;z-index:1}
|
||||
/* Lichtpunkte im Instanzbereich (nur AeroMC/Nachtlila mit bewegter Oberfläche) */
|
||||
.lichtpunkte{display:none;position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
|
||||
.lichtpunkte i{position:absolute;left:var(--x);top:var(--y);width:var(--g);height:var(--g);border-radius:50%;opacity:0;will-change:transform,opacity;animation:lichtpunkt var(--d) ease-in-out var(--v) infinite}
|
||||
@keyframes lichtpunkt{0%{opacity:0;transform:translate(0,0)}25%{opacity:var(--o)}75%{opacity:var(--o)}100%{opacity:0;transform:translate(var(--dx),var(--dy))}}
|
||||
/* Designwechsel weich überblenden */
|
||||
::view-transition-old(root),::view-transition-new(root){animation-duration:.45s;animation-timing-function:ease}
|
||||
.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px}
|
||||
@@ -150,6 +168,12 @@ body.is-popout>.titelleiste{display:none}
|
||||
.sbtn:disabled{opacity:.5;cursor:default}
|
||||
.sbtn:disabled:hover{background:none;border-color:transparent}
|
||||
.sbtn.play{font-weight:600}
|
||||
/* Starten-Knopf füllt sich während Download/Vorbereitung */
|
||||
.sbtn.play{position:relative;overflow:hidden;isolation:isolate}
|
||||
.sbtn.play .play-text{position:relative;z-index:1}
|
||||
.sbtn.play::before{content:'';position:absolute;left:0;top:0;bottom:0;width:0;background:var(--accent);opacity:.35;z-index:0;transition:width .35s ease}
|
||||
.sbtn.play.laedt::before{width:var(--fortschritt,0%)}
|
||||
.sbtn.play.laedt .play-text{font-weight:600;font-size:11.5px}
|
||||
.sbtn.del:hover{color:var(--danger)}
|
||||
|
||||
/* ---------- Instanzliste / Gruppen / Kacheln ---------- */
|
||||
@@ -364,6 +388,27 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
|
||||
.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
|
||||
.skin-bild canvas:active{cursor:grabbing}
|
||||
.popout-host .skin-bild{height:240px}
|
||||
.skin-armform-hinweis{display:flex;align-items:center;gap:10px;margin:0 0 10px;padding:8px 10px;border:1px solid #c9a227;border-radius:6px;background:rgba(201,162,39,.12);font-size:12px;line-height:1.45}
|
||||
.skin-armform-hinweis span{flex:1}
|
||||
.skin-armform-hinweis.hidden{display:none}
|
||||
/* Garderobe im Skin-Fenster */
|
||||
.skin-garderobe{margin-top:14px;padding-top:12px;border-top:1px solid var(--border)}
|
||||
.garderobe-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.garderobe-knoepfe{display:flex;gap:6px}
|
||||
.garderobe-liste{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px;margin-bottom:6px}
|
||||
.garderobe-leer{grid-column:1/-1;padding:14px;text-align:center;color:var(--dim);font-size:12px;border:1px dashed var(--border);border-radius:6px}
|
||||
.gard-karte{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 6px 6px;border:1px solid var(--border);border-radius:6px;background:rgba(127,127,127,.06)}
|
||||
.gard-bild{width:64px;height:96px;object-fit:contain;image-rendering:auto}
|
||||
.gard-bild.umhang{width:96px;height:48px;image-rendering:pixelated;object-fit:contain}
|
||||
.gard-name{width:100%;font-size:11.5px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.gard-merk{font-size:10px;color:var(--dim)}
|
||||
.gard-aktionen{display:flex;gap:4px;width:100%}
|
||||
.gard-aktionen .btn{flex:1;padding:3px 0;font-size:11px}
|
||||
.gard-mini{position:absolute;top:3px;border:none;background:none;color:var(--dim);cursor:pointer;font-size:12px;padding:2px 4px;border-radius:3px}
|
||||
.gard-mini:hover{background:rgba(127,127,127,.2);color:var(--text)}
|
||||
.gard-mini.links{left:3px}
|
||||
.gard-mini.rechts{right:3px}
|
||||
|
||||
.skin-umhang{margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)}
|
||||
.skin-umhang .field-row{margin-top:6px}
|
||||
.skin-umhang .field{margin-bottom:6px}
|
||||
@@ -536,8 +581,35 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.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-row.gezeigt{box-shadow:inset 3px 0 0 var(--accent)}
|
||||
/* Bühne mit der 3D-Figur des Freundes */
|
||||
.freund-buehne{position:relative;height:190px;margin:10px 0 6px;border:1px solid var(--border);border-radius:8px;overflow:hidden;
|
||||
background:radial-gradient(ellipse at 50% 85%,rgba(127,127,127,.18),transparent 60%)}
|
||||
.freund-buehne.hidden{display:none}
|
||||
.freund-buehne canvas{display:block;width:100%;height:100%}
|
||||
.freund-buehne-text{position:absolute;left:10px;bottom:8px;display:flex;flex-direction:column;gap:1px;pointer-events:none;text-shadow:0 1px 3px rgba(0,0,0,.6)}
|
||||
.freund-buehne-text b{font-size:13px}
|
||||
.freund-buehne-text span{font-size:11px;color:var(--dim)}
|
||||
/* Popout: Liste links, Figur rechts daneben (füllt die Höhe); schmal -> untereinander */
|
||||
#modal-friends.popout-host .modal-body{display:flex;flex-direction:column}
|
||||
#modal-friends.popout-host .freunde-raster{flex:1 1 auto;min-height:0}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)){display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,42%);gap:14px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freunde-spalte{min-height:0;overflow:auto;padding-right:2px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne{height:auto;margin:0}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne-text{left:14px;bottom:12px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne-text b{font-size:15px}
|
||||
@media (max-width:620px){
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)){display:flex;flex-direction:column-reverse;justify-content:flex-end}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne{flex:none;height:190px;margin:10px 0 6px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freunde-spalte{overflow:visible}
|
||||
}
|
||||
.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto}
|
||||
.friend-dot.online{background:#3bb54a}
|
||||
/* Freund kommt online: Kopf hüpft, Punkt pulsiert einmal */
|
||||
.ui-animiert .friend-row.kommt-online .friend-avatar{animation:kopf-huepfen .75s cubic-bezier(.3,.7,.4,1) 2}
|
||||
@keyframes kopf-huepfen{0%,100%{transform:none}30%{transform:translateY(-7px) scale(1.08)}55%{transform:translateY(0) scale(1.05,.94)}75%{transform:translateY(-2px)}}
|
||||
.ui-animiert .friend-row.kommt-online .friend-dot{animation:punkt-puls 1.1s ease-out 2}
|
||||
@keyframes punkt-puls{0%{box-shadow:0 0 0 0 rgba(59,181,74,.85)}100%{box-shadow:0 0 0 10px rgba(59,181,74,0)}}
|
||||
.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}
|
||||
@@ -576,6 +648,45 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.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}
|
||||
/* Optionen: eine Spalte, jede Einstellung eine Zeile (Text links, Bedienelement rechts) */
|
||||
#modal-settings .settings-grid{grid-template-columns:minmax(0,1fr);max-width:760px}
|
||||
#modal-settings .settings-card-wide{grid-column:auto}
|
||||
#modal-settings .settings-card{padding:14px 18px 6px}
|
||||
#modal-settings .settings-card h5{margin:0 0 4px}
|
||||
.s-zeile{display:flex;align-items:center;gap:18px;padding:11px 0;border-top:1px solid var(--border)}
|
||||
.settings-card h5+.s-zeile,.s-vorab+.s-zeile{border-top:none}
|
||||
.s-zeile.s-voll{flex-direction:column;align-items:stretch;gap:8px}
|
||||
.s-text{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
.s-text b{font-size:13px;font-weight:600}
|
||||
.s-text small{font-size:12px;color:var(--dim);line-height:1.45}
|
||||
.s-wert{flex:0 0 auto;width:230px}
|
||||
.s-wert.s-breit{width:min(320px,48%)}
|
||||
.s-wert.s-zahl{width:110px}
|
||||
select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
|
||||
.s-paar,.s-einheit,.s-regler{display:flex;align-items:center;gap:8px}
|
||||
.s-paar input,.s-einheit input{width:0;flex:1 1 0;min-width:0;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
|
||||
.s-paar span,.s-einheit span,.s-regler>span{color:var(--dim);font-size:12px;white-space:nowrap}
|
||||
.s-regler input{flex:1;min-width:0}
|
||||
.s-regler>span{min-width:42px;text-align:right}
|
||||
.s-vorab{display:block;margin:2px 0 6px;color:var(--dim)}
|
||||
.s-warnung{color:#d9822b}
|
||||
#s-backup-status,#s-restore-status{display:block;margin:-4px 0 10px}
|
||||
.s-felder{padding:0 0 6px}
|
||||
.s-zeile .field-row .field{margin-bottom:0}
|
||||
.s-zeile .path-row select{flex:1;min-width:0;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
|
||||
.s-zeile>.btn{flex:none;white-space:nowrap}
|
||||
.settings-card .backup-progress{margin:0 0 12px}
|
||||
/* Schalter (statt Häkchen) */
|
||||
.schalter{position:relative;flex:none;width:40px;height:22px;cursor:pointer}
|
||||
.schalter input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;z-index:1}
|
||||
.schalter i{position:absolute;inset:0;border-radius:11px;background:rgba(127,127,127,.38);transition:background .18s}
|
||||
.schalter i::after{content:'';position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
|
||||
.schalter input:checked+i{background:var(--accent)}
|
||||
.schalter input:checked+i::after{transform:translateX(18px)}
|
||||
.schalter input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.schalter input:disabled{cursor:default}
|
||||
.schalter input:disabled+i{opacity:.45}
|
||||
@media (max-width:640px){.s-zeile{flex-wrap:wrap}.s-wert,.s-wert.s-breit{width:100%}}
|
||||
.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)}
|
||||
@@ -651,7 +762,27 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.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-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 14px;font-size:12.5px;line-height:1.5;margin-bottom:12px;overflow-wrap:anywhere}
|
||||
/* Markdown in den Update-Hinweisen */
|
||||
.upd-notes>:first-child{margin-top:0}
|
||||
.upd-notes>:last-child{margin-bottom:0}
|
||||
.upd-notes h4{margin:14px 0 6px;font-size:15px;font-weight:700}
|
||||
.upd-notes h5{margin:12px 0 4px;font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.05em}
|
||||
.upd-notes h6{margin:10px 0 4px;font-size:12.5px;font-weight:700}
|
||||
.upd-notes p{margin:6px 0}
|
||||
.upd-notes ul,.upd-notes ol{margin:4px 0 8px;padding-left:20px}
|
||||
.upd-notes ul ul,.upd-notes ol ul,.upd-notes ul ol{margin:3px 0 2px}
|
||||
.upd-notes li{margin:3px 0}
|
||||
.upd-notes li::marker{color:var(--accent)}
|
||||
.upd-notes code{padding:1px 5px;border-radius:4px;background:rgba(127,127,127,.18);font-family:Consolas,"Cascadia Mono",monospace;font-size:11.5px}
|
||||
.upd-notes pre{margin:8px 0;padding:8px 10px;border-radius:6px;background:rgba(127,127,127,.14);overflow:auto}
|
||||
.upd-notes pre code{padding:0;background:none}
|
||||
.upd-notes blockquote{margin:8px 0;padding:2px 0 2px 10px;border-left:3px solid var(--accent);color:var(--dim)}
|
||||
.upd-notes hr{border:none;border-top:1px solid var(--border);margin:12px 0}
|
||||
.upd-notes a{color:var(--link,var(--accent));text-decoration:underline;cursor:pointer}
|
||||
/* als eigenes Fenster: Hinweise füllen den freien Platz */
|
||||
#modal-update.popout-host .modal-body{display:flex;flex-direction:column}
|
||||
#modal-update.popout-host .upd-notes{flex:1 1 auto;max-height:none;min-height:120px}
|
||||
.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}
|
||||
@@ -682,6 +813,32 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.ctx-strong{font-weight:600}
|
||||
.ctx-danger:hover{background:var(--danger);color:#fff}
|
||||
.ctx-sep{height:1px;background:var(--border);margin:4px 6px}
|
||||
/* Erfolg-Meldung wie im Spiel („Erfolg erzielt!“) */
|
||||
.erfolg-meldungen{position:fixed;top:92px;right:14px;z-index:300;display:flex;flex-direction:column;gap:8px}
|
||||
/* Grundform (Klassisch): Karte in den Farben des Designs */
|
||||
.erfolg{position:relative;display:flex;align-items:center;gap:10px;min-width:250px;max-width:320px;padding:9px 14px 9px 9px;cursor:pointer;
|
||||
background:var(--win);border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:6px;
|
||||
box-shadow:0 10px 26px rgba(0,0,0,.35);color:var(--text)}
|
||||
.erfolg-symbol{width:36px;height:36px;flex:none;display:grid;place-items:center;border-radius:5px;background:rgba(127,127,127,.15)}
|
||||
.erfolg-symbol svg,.erfolg-symbol img{width:28px;height:28px}
|
||||
.erfolg-kopf{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;line-height:1.3}
|
||||
.erfolg-name{font-size:13px;font-weight:600;line-height:1.3}
|
||||
/* Funken (alle Designs außer Minecraft) und XP-Kugeln (Minecraft) */
|
||||
.erfolg .funke{position:absolute;z-index:2;width:6px;height:6px;border-radius:50%;background:#fff;box-shadow:0 0 5px 2px var(--accent),0 0 12px 3px var(--accent);pointer-events:none}
|
||||
.erfolg .xp-kugel{position:absolute;width:8px;height:8px;margin-top:-4px;border-radius:50%;pointer-events:none;
|
||||
background:radial-gradient(circle at 35% 35%,#fbff9c 0 25%,#b8f531 45%,#4f9a12 80%);box-shadow:0 0 6px 1px rgba(182,255,58,.75)}
|
||||
.ui-animiert .erfolg{animation:erfolg-rein .45s cubic-bezier(.2,.8,.2,1) both}
|
||||
.ui-animiert .erfolg.weg{animation:erfolg-raus .35s ease-in both}
|
||||
@keyframes erfolg-rein{from{transform:translateX(120%);opacity:0}}
|
||||
@keyframes erfolg-raus{to{transform:translateX(120%);opacity:0}}
|
||||
/* Übersicht in der Statistik */
|
||||
.erfolge-liste{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:6px;margin-bottom:12px}
|
||||
.erfolg-karte{display:flex;align-items:center;gap:9px;padding:6px 8px;border:1px solid var(--border);border-radius:6px}
|
||||
.erfolg-karte .erfolg-symbol{width:32px;height:32px}
|
||||
.erfolg-karte b{display:block;font-size:12px}
|
||||
.erfolg-karte small{display:block;font-size:11px;color:var(--dim);line-height:1.3}
|
||||
.erfolg-karte.offen{opacity:.45;filter:grayscale(1)}
|
||||
|
||||
/* Konto-Menü oben rechts (wie in MultiMC) */
|
||||
.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
|
||||
.konto-menu{min-width:220px}
|
||||
|
||||
Reference in New Issue
Block a user