Upload via GUI (146 Dateien)

This commit is contained in:
2026-10-05 17:20:31 +00:00
parent 93c74f94a1
commit 6ef51edede
13 changed files with 1734 additions and 416 deletions
+686 -11
View File
@@ -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}&gt;\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 */ }