Upload via GUI (123 Dateien)
This commit is contained in:
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||||
|
|
||||||
|
## [1.1.6] - 2026-10-02
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Skin-Editor: Skins direkt im Launcher pixeln – erreichbar im Skin-Fenster über „Im Editor bearbeiten…“
|
||||||
|
- Zwei Schichten: Grundschicht und 2. Schicht getrennt bemalen – die 2. Schicht liegt wie eine Hülle über der Figur und lässt Haare, Kapuzen oder Ärmel plastisch wirken
|
||||||
|
- Wahlweise auf der aufgeklappten 2D-Vorlage oder direkt auf der drehbaren 3D-Figur malen – die andere Ansicht läuft als Live-Vorschau mit und ist ebenfalls bemalbar
|
||||||
|
- Werkzeuge: Stift, Radierer (nur 2. Schicht), Pipette (auch Alt+Klick), Füllen, Farbwähler mit den zuletzt benutzten Farben
|
||||||
|
- Rückgängig/Wiederholen mit Strg+Z und Strg+Y
|
||||||
|
- Spiegeln: malt gleichzeitig auf der anderen Körperseite (linker ↔ rechter Arm/Bein)
|
||||||
|
- Ebenen-Werkzeuge: 2. Schicht ein-/ausblenden, Grundschicht durchscheinen lassen, 2. Schicht leeren
|
||||||
|
- Aktuellen Skin laden, PNG öffnen oder speichern und das Ergebnis mit einem Klick als Skin übernehmen – klassische und schmale Arme, alte 64×32-Skins werden automatisch umgewandelt
|
||||||
|
- Skin-Hintergrund: Armform wählbar (Optionen → Darstellung) – „Automatisch“ übernimmt die Armform des Kontos, alternativ fest „Normal“ oder „Schmal“
|
||||||
|
- Designs zur Auswahl (Optionen → Darstellung → Design, mit Vorschau-Kacheln): „Klassisch“ hell oder dunkel wie bisher, „AeroMC“ im Look der Webseite (Nachtblau, Cyan-Verläufe, runde Knöpfe), „Nachtlila“ (wie AeroMC, nur in dunklem Lila) und „Minecraft“ mit Block-Optik, Stein-Knöpfen, grünem Starten-Knopf und eigener Pixel-Schrift
|
||||||
|
- Ein gewähltes Design gilt sofort in allen offenen Fenstern
|
||||||
|
- Eigene Titelleiste im Design statt der weißen Windows-Leiste – Minimieren, Maximieren und Schließen bleiben echte Windows-Knöpfe (Andocken, Fensterlayouts) und werden passend eingefärbt
|
||||||
|
- Import aus der Modrinth App (Importieren → „Modrinth App“, auch im Installer): übernimmt Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen – auch bei verlegtem Datenordner und während die Modrinth App läuft
|
||||||
|
- Aus der Modrinth App übernommene Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC aktualisieren
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Das Symbol für „Optionen“ sah aus wie eine Sonne – jetzt ein Zahnrad
|
||||||
|
- Der Skin-Hintergrund zeigte schmale (Alex-)Skins mit breiten Armen – dadurch erschienen falsche Streifen an den Armen
|
||||||
|
|
||||||
## [1.1.5] - 2026-10-02
|
## [1.1.5] - 2026-10-02
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
+437
@@ -0,0 +1,437 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* mrimport.js – Instanzen aus der Modrinth App übernehmen
|
||||||
|
* --------------------------------------------------------
|
||||||
|
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
|
||||||
|
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
|
||||||
|
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
|
||||||
|
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
|
||||||
|
* der App verlegt, steht der neue Ort in settings.custom_dir.
|
||||||
|
*
|
||||||
|
* Unterstützte Datenbank-Fassungen:
|
||||||
|
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
|
||||||
|
* Gruppen in "instance_group_memberships"/"instance_groups",
|
||||||
|
* Starteinstellungen als JSONB in "instance_launch_overrides"
|
||||||
|
* - 2024 bis Mitte 2026: eine Tabelle "profiles"
|
||||||
|
* - vorher (ohne app.db): je Instanz eine profile.json
|
||||||
|
*
|
||||||
|
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
|
||||||
|
* die Datenbank der Modrinth App wird nie verändert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
|
||||||
|
|
||||||
|
let sqlPromise = null;
|
||||||
|
function sqlJs() {
|
||||||
|
if (!sqlPromise) {
|
||||||
|
const initSqlJs = require('sql.js');
|
||||||
|
const wasmDir = path.dirname(require.resolve('sql.js'));
|
||||||
|
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
|
||||||
|
}
|
||||||
|
return sqlPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appDatenOrdner() {
|
||||||
|
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||||
|
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function standardOrdner() {
|
||||||
|
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
|
||||||
|
function ermittleOrte(eingabe) {
|
||||||
|
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
|
||||||
|
if (!fs.existsSync(basis)) return null;
|
||||||
|
// direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
|
||||||
|
if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
|
||||||
|
const hoeher = path.dirname(basis);
|
||||||
|
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
|
||||||
|
}
|
||||||
|
const db = path.join(basis, 'app.db');
|
||||||
|
return { basis, db: fs.existsSync(db) ? db : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Datenbank ----------
|
||||||
|
/*
|
||||||
|
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
|
||||||
|
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
|
||||||
|
* Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
|
||||||
|
* dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
|
||||||
|
* mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
|
||||||
|
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
|
||||||
|
*/
|
||||||
|
function spieleWalEin(daten, walPfad) {
|
||||||
|
let wal;
|
||||||
|
try { wal = fs.readFileSync(walPfad); } catch { return null; }
|
||||||
|
if (wal.length < 32) return null;
|
||||||
|
const magie = wal.readUInt32BE(0);
|
||||||
|
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
|
||||||
|
const bigEndian = magie === 0x377f0683;
|
||||||
|
const seite = wal.readUInt32BE(8);
|
||||||
|
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
|
||||||
|
if (!seite || seite !== dbSeite) return null;
|
||||||
|
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
|
||||||
|
|
||||||
|
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
|
||||||
|
let s0 = 0, s1 = 0;
|
||||||
|
const pruefe = (buf, start, ende) => {
|
||||||
|
for (let i = start; i < ende; i += 8) {
|
||||||
|
s0 = (s0 + wort(buf, i) + s1) >>> 0;
|
||||||
|
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pruefe(wal, 0, 24);
|
||||||
|
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
|
||||||
|
|
||||||
|
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
|
||||||
|
let offen = new Map();
|
||||||
|
let seiten = Math.floor(daten.length / seite);
|
||||||
|
let rahmen = 0;
|
||||||
|
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
|
||||||
|
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
|
||||||
|
pruefe(wal, pos, pos + 8);
|
||||||
|
pruefe(wal, pos + 24, pos + 24 + seite);
|
||||||
|
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
|
||||||
|
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
|
||||||
|
const nachCommit = wal.readUInt32BE(pos + 4);
|
||||||
|
if (nachCommit) { // Transaktion abgeschlossen
|
||||||
|
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
|
||||||
|
offen = new Map();
|
||||||
|
seiten = nachCommit;
|
||||||
|
rahmen++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rahmen) return null;
|
||||||
|
const neu = Buffer.alloc(seiten * seite);
|
||||||
|
daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
|
||||||
|
for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
|
||||||
|
return { daten: neu, rahmen };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oeffneDb(dbPfad) {
|
||||||
|
const SQL = await sqlJs();
|
||||||
|
let daten = fs.readFileSync(dbPfad);
|
||||||
|
let walEingespielt = false;
|
||||||
|
if (daten.length > 100 && daten[18] === 2) {
|
||||||
|
const mitWal = spieleWalEin(daten, dbPfad + '-wal');
|
||||||
|
if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
|
||||||
|
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
|
||||||
|
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
|
||||||
|
daten[18] = 1; daten[19] = 1;
|
||||||
|
}
|
||||||
|
const db = new SQL.Database(daten);
|
||||||
|
db.walEingespielt = walEingespielt;
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeilen(db, sql, params) {
|
||||||
|
const st = db.prepare(sql);
|
||||||
|
try {
|
||||||
|
if (params) st.bind(params);
|
||||||
|
const out = [];
|
||||||
|
while (st.step()) out.push(st.getAsObject());
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
st.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabellen(db) {
|
||||||
|
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function spalten(db, tabelle) {
|
||||||
|
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonFeld(wert) {
|
||||||
|
if (wert === null || wert === undefined || wert === '') return null;
|
||||||
|
try { return JSON.parse(wert); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
|
||||||
|
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
|
||||||
|
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eigenerOrdner(db, t) {
|
||||||
|
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
|
||||||
|
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
|
||||||
|
return r && r.custom_dir ? String(r.custom_dir) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leseNeu(db, t) {
|
||||||
|
const si = spalten(db, 'instances');
|
||||||
|
const liste = zeilen(db, `
|
||||||
|
SELECT i.id AS id, i.path AS path, i.name AS name,
|
||||||
|
${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
|
||||||
|
${sp(si, 'i.last_played', 'last_played')},
|
||||||
|
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
|
||||||
|
${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
|
||||||
|
cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
|
||||||
|
FROM instances i
|
||||||
|
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
|
||||||
|
|
||||||
|
const overrides = new Map();
|
||||||
|
if (t.has('instance_launch_overrides')) {
|
||||||
|
for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
|
||||||
|
overrides.set(r.instance_id, jsonFeld(r.o) || {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gruppen = new Map();
|
||||||
|
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
|
||||||
|
if (t.has('instance_group_memberships') && t.has('instance_groups')) {
|
||||||
|
for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
|
||||||
|
JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
|
||||||
|
} else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
|
||||||
|
for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
|
||||||
|
}
|
||||||
|
const links = new Map();
|
||||||
|
if (t.has('instance_links')) {
|
||||||
|
for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
|
||||||
|
if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return liste.map((r) => {
|
||||||
|
const o = overrides.get(r.id) || {};
|
||||||
|
const hooks = o.hooks || {};
|
||||||
|
return {
|
||||||
|
path: r.path,
|
||||||
|
name: r.name,
|
||||||
|
iconPath: r.icon_path,
|
||||||
|
installiert: !r.install_stage || r.install_stage === 'installed',
|
||||||
|
lastPlayed: r.last_played,
|
||||||
|
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
||||||
|
gameVersion: r.game_version,
|
||||||
|
loader: r.loader,
|
||||||
|
loaderVersion: r.loader_version,
|
||||||
|
gruppe: gruppen.get(r.id) || '',
|
||||||
|
javaPath: o.java_path || '',
|
||||||
|
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
|
||||||
|
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
|
||||||
|
vorStart: hooks.pre_launch || '',
|
||||||
|
nachEnde: hooks.post_exit || '',
|
||||||
|
modpack: links.get(r.id) || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function leseProfile(db) {
|
||||||
|
const s = spalten(db, 'profiles');
|
||||||
|
const liste = zeilen(db, `SELECT path, name,
|
||||||
|
${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
|
||||||
|
${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
|
||||||
|
${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
|
||||||
|
${sp(s, 'last_played', 'last_played')},
|
||||||
|
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
|
||||||
|
${sp(s, 'recent_time_played', 'recent_time_played', '0')},
|
||||||
|
${sp(s, 'override_java_path', 'override_java_path')},
|
||||||
|
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
|
||||||
|
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
|
||||||
|
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
|
||||||
|
${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
|
||||||
|
${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
|
||||||
|
FROM profiles`);
|
||||||
|
return liste.map((r) => {
|
||||||
|
const gruppen = jsonFeld(r.groups);
|
||||||
|
const args = jsonFeld(r.override_extra_launch_args);
|
||||||
|
return {
|
||||||
|
path: r.path,
|
||||||
|
name: r.name,
|
||||||
|
iconPath: r.icon_path,
|
||||||
|
installiert: !r.install_stage || r.install_stage === 'installed',
|
||||||
|
lastPlayed: r.last_played,
|
||||||
|
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
||||||
|
gameVersion: r.game_version,
|
||||||
|
loader: r.mod_loader,
|
||||||
|
loaderVersion: r.mod_loader_version,
|
||||||
|
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
|
||||||
|
javaPath: r.override_java_path || '',
|
||||||
|
maxMemMb: Number(r.override_mc_memory_max) || null,
|
||||||
|
extraArgs: Array.isArray(args) ? args.join(' ') : '',
|
||||||
|
vorStart: r.override_hook_pre_launch || '',
|
||||||
|
nachEnde: r.override_hook_post_exit || '',
|
||||||
|
modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// sehr alte Fassungen: profiles\<Ordner>\profile.json
|
||||||
|
function leseProfileJson(profilesDir) {
|
||||||
|
const liste = [];
|
||||||
|
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
|
||||||
|
if (!e.isDirectory()) continue;
|
||||||
|
const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
|
||||||
|
if (!p) continue;
|
||||||
|
const m = p.metadata || p;
|
||||||
|
const lv = m.loader_version;
|
||||||
|
liste.push({
|
||||||
|
path: e.name,
|
||||||
|
name: m.name || e.name,
|
||||||
|
iconPath: m.icon || null,
|
||||||
|
installiert: !p.install_stage || p.install_stage === 'installed',
|
||||||
|
lastPlayed: m.last_played || null,
|
||||||
|
sekunden: 0,
|
||||||
|
gameVersion: m.game_version,
|
||||||
|
loader: m.loader,
|
||||||
|
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
|
||||||
|
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
|
||||||
|
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
|
||||||
|
maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
|
||||||
|
extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
|
||||||
|
vorStart: (p.hooks && p.hooks.pre_launch) || '',
|
||||||
|
nachEnde: (p.hooks && p.hooks.post_exit) || '',
|
||||||
|
modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
|
||||||
|
? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
|
||||||
|
function loaderVersion(loader, wert, mc) {
|
||||||
|
let v = String(wert || '').trim();
|
||||||
|
if (!v || v === 'stable' || v === 'latest') return '';
|
||||||
|
if (loader === 'forge') {
|
||||||
|
v = v.replace(/^forge-/i, '');
|
||||||
|
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
|
||||||
|
} else if (loader === 'neoforge') {
|
||||||
|
v = v.replace(/^neoforge-/i, '');
|
||||||
|
} else if (loader === 'fabric' || loader === 'quilt') {
|
||||||
|
v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
|
||||||
|
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeitstempel(wert) {
|
||||||
|
if (!wert) return null;
|
||||||
|
const n = Number(wert);
|
||||||
|
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
|
||||||
|
const d = new Date(wert);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Durchsucht die Modrinth App. Leere Eingabe = Standardort.
|
||||||
|
* -> { ok, basis, count, instances, hinweis }
|
||||||
|
*/
|
||||||
|
async function scan(eingabe) {
|
||||||
|
const orte = ermittleOrte(eingabe);
|
||||||
|
if (!orte) return { ok: false, reason: 'not-found' };
|
||||||
|
|
||||||
|
let roh = [];
|
||||||
|
let profilesDir = path.join(orte.basis, 'profiles');
|
||||||
|
let hinweis = '';
|
||||||
|
if (orte.db) {
|
||||||
|
const db = await oeffneDb(orte.db);
|
||||||
|
try {
|
||||||
|
const t = tabellen(db);
|
||||||
|
const eigener = eigenerOrdner(db, t);
|
||||||
|
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
|
||||||
|
if (t.has('instances')) roh = leseNeu(db, t);
|
||||||
|
else if (t.has('profiles')) roh = leseProfile(db);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
|
||||||
|
// ist evtl. nicht ganz aktuell
|
||||||
|
try {
|
||||||
|
const wal = orte.db + '-wal';
|
||||||
|
if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
|
||||||
|
hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
|
||||||
|
}
|
||||||
|
} catch { /* egal */ }
|
||||||
|
} else if (fs.existsSync(profilesDir)) {
|
||||||
|
roh = leseProfileJson(profilesDir);
|
||||||
|
} else {
|
||||||
|
return { ok: false, reason: 'not-found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const instances = [];
|
||||||
|
for (const r of roh) {
|
||||||
|
const dir = path.join(profilesDir, String(r.path || ''));
|
||||||
|
if (!r.path || !fs.existsSync(dir)) continue;
|
||||||
|
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
|
||||||
|
const version = String(r.gameVersion || '').trim();
|
||||||
|
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
|
||||||
|
instances.push({
|
||||||
|
folder: r.path,
|
||||||
|
dir,
|
||||||
|
gameDir: dir,
|
||||||
|
name: String(r.name || r.path),
|
||||||
|
group: r.gruppe || '',
|
||||||
|
version,
|
||||||
|
loader,
|
||||||
|
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
|
||||||
|
installiert: r.installiert,
|
||||||
|
iconPath: icon,
|
||||||
|
lastPlayed: zeitstempel(r.lastPlayed),
|
||||||
|
playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
|
||||||
|
javaPath: String(r.javaPath || '').trim(),
|
||||||
|
maxMemMb: r.maxMemMb || null,
|
||||||
|
extraArgs: String(r.extraArgs || '').trim(),
|
||||||
|
preLaunchCommand: String(r.vorStart || '').trim(),
|
||||||
|
postExitCommand: String(r.nachEnde || '').trim(),
|
||||||
|
modpack: r.modpack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
|
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
|
||||||
|
}
|
||||||
|
|
||||||
|
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
|
||||||
|
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
|
||||||
|
|
||||||
|
function copyInto(quelle, ziel) {
|
||||||
|
fs.mkdirSync(ziel, { recursive: true });
|
||||||
|
for (const name of fs.readdirSync(quelle)) {
|
||||||
|
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
|
||||||
|
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
|
||||||
|
fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOne(store, entry, copyData = true) {
|
||||||
|
const created = store.createOrReplaceImportedInstance({
|
||||||
|
name: entry.name,
|
||||||
|
group: entry.group,
|
||||||
|
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = {};
|
||||||
|
if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
|
||||||
|
patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
|
||||||
|
}
|
||||||
|
if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
|
||||||
|
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
|
||||||
|
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
|
||||||
|
const vorher = store.getInstance(created.id) || {};
|
||||||
|
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
|
||||||
|
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
|
||||||
|
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||||
|
copyInto(entry.gameDir, store.gameDir(created.id));
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
if (entry.iconPath) {
|
||||||
|
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
|
||||||
|
}
|
||||||
|
return { id: created.id, name: created.name, copied };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scan, importOne, standardOrdner };
|
||||||
+15
-1
@@ -154,6 +154,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||||
|
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
||||||
|
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
||||||
|
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
||||||
|
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
||||||
|
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
|
||||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||||
@@ -192,6 +197,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||||
},
|
},
|
||||||
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||||
|
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
||||||
|
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
||||||
|
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
||||||
|
onMrProgress: (cb) => {
|
||||||
|
const l = (_e, p) => cb(p);
|
||||||
|
ipcRenderer.on('mr:progress', l);
|
||||||
|
return () => ipcRenderer.removeListener('mr:progress', l);
|
||||||
|
},
|
||||||
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||||
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||||
onCfProgress: (cb) => {
|
onCfProgress: (cb) => {
|
||||||
@@ -269,6 +282,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
||||||
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
|
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
|
||||||
popoutHide: () => ipcRenderer.send('popout:hide'),
|
popoutHide: () => ipcRenderer.send('popout:hide'),
|
||||||
|
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
||||||
onPopoutReopen: (cb) => {
|
onPopoutReopen: (cb) => {
|
||||||
const l = (_e, params) => cb(params);
|
const l = (_e, params) => cb(params);
|
||||||
ipcRenderer.on('popout:reopen', l);
|
ipcRenderer.on('popout:reopen', l);
|
||||||
@@ -285,7 +299,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
return () => ipcRenderer.removeListener('instances:changed', l);
|
return () => ipcRenderer.removeListener('instances:changed', l);
|
||||||
},
|
},
|
||||||
onSkinChanged: (cb) => {
|
onSkinChanged: (cb) => {
|
||||||
const l = () => cb();
|
const l = (_e, info) => cb(info || {});
|
||||||
ipcRenderer.on('skin:changed', l);
|
ipcRenderer.on('skin:changed', l);
|
||||||
return () => ipcRenderer.removeListener('skin:changed', l);
|
return () => ipcRenderer.removeListener('skin:changed', l);
|
||||||
},
|
},
|
||||||
|
|||||||
+146
-8
@@ -537,13 +537,23 @@ function escapeHtml(s) {
|
|||||||
}
|
}
|
||||||
function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }
|
function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }
|
||||||
|
|
||||||
|
// Designs: 'dark'/'light' = Klassisch (MultiMC-Look), 'aero' = wie die Webseite,
|
||||||
|
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
|
||||||
|
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
|
||||||
|
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
|
||||||
function applyTheme(theme) {
|
function applyTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
const t = DESIGNS.includes(theme) ? theme : 'dark';
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
|
||||||
|
root.setAttribute('data-design', ['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch');
|
||||||
|
if (!POPOUT && window.api.setTitleBarDesign) window.api.setTitleBarDesign(t);
|
||||||
|
document.querySelectorAll('.design-wahl').forEach((b) => b.classList.toggle('on', b.dataset.design === t));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Instanzen aus MultiMC übernehmen ----------
|
// ---------- Instanzen aus MultiMC übernehmen ----------
|
||||||
let mmcFound = [];
|
let mmcFound = [];
|
||||||
let cfFound = [];
|
let cfFound = [];
|
||||||
|
let mrFound = [];
|
||||||
let mcFound = [];
|
let mcFound = [];
|
||||||
let technicFound = [];
|
let technicFound = [];
|
||||||
let importKind = 'zip';
|
let importKind = 'zip';
|
||||||
@@ -568,8 +578,8 @@ async function ensureDefaultMcScan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setImportKind(kind) {
|
function setImportKind(kind) {
|
||||||
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip';
|
importKind = ['zip', 'modpack', 'mc', 'cf', 'mr', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip';
|
||||||
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].forEach((entryKind) => {
|
['zip', 'modpack', 'mc', 'cf', 'mr', 'mmc', 'technic', 'code'].forEach((entryKind) => {
|
||||||
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
|
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
|
||||||
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
|
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
|
||||||
});
|
});
|
||||||
@@ -577,12 +587,14 @@ function setImportKind(kind) {
|
|||||||
el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack');
|
el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack');
|
||||||
el('mc-do').classList.toggle('hidden', importKind !== 'mc');
|
el('mc-do').classList.toggle('hidden', importKind !== 'mc');
|
||||||
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
|
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
|
||||||
|
el('mr-do').classList.toggle('hidden', importKind !== 'mr');
|
||||||
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
|
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
|
||||||
el('technic-do').classList.toggle('hidden', importKind !== 'technic');
|
el('technic-do').classList.toggle('hidden', importKind !== 'technic');
|
||||||
el('code-do').classList.toggle('hidden', importKind !== 'code');
|
el('code-do').classList.toggle('hidden', importKind !== 'code');
|
||||||
el('code-progress').classList.toggle('hidden', importKind !== 'code' || !el('code-progress').textContent);
|
el('code-progress').classList.toggle('hidden', importKind !== 'code' || !el('code-progress').textContent);
|
||||||
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
|
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
|
||||||
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
|
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
|
||||||
|
el('mr-progress').classList.toggle('hidden', importKind !== 'mr' || !el('mr-progress').textContent);
|
||||||
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
|
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
|
||||||
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
|
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
|
||||||
}
|
}
|
||||||
@@ -601,6 +613,12 @@ function openImportDialog(kind = 'zip', vorauswahl = null) {
|
|||||||
el('cf-progress').textContent = '';
|
el('cf-progress').textContent = '';
|
||||||
el('cf-progress').classList.add('hidden');
|
el('cf-progress').classList.add('hidden');
|
||||||
el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances';
|
el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances';
|
||||||
|
mrFound = [];
|
||||||
|
el('mr-result').classList.add('hidden');
|
||||||
|
el('mr-do').disabled = true;
|
||||||
|
el('mr-progress').textContent = '';
|
||||||
|
el('mr-progress').classList.add('hidden');
|
||||||
|
el('mr-status').textContent = 'Leer lassen nutzt automatisch den Standardort der Modrinth App.';
|
||||||
el('mmc-result').classList.add('hidden');
|
el('mmc-result').classList.add('hidden');
|
||||||
el('mmc-do').disabled = true;
|
el('mmc-do').disabled = true;
|
||||||
el('mmc-progress').textContent = '';
|
el('mmc-progress').textContent = '';
|
||||||
@@ -613,6 +631,8 @@ function openImportDialog(kind = 'zip', vorauswahl = null) {
|
|||||||
ensureDefaultMcScan();
|
ensureDefaultMcScan();
|
||||||
}
|
}
|
||||||
if (importKind === 'cf') el('cf-path').focus();
|
if (importKind === 'cf') el('cf-path').focus();
|
||||||
|
// Modrinth App: Standardort gleich durchsuchen, wie beim Minecraft Launcher
|
||||||
|
if (importKind === 'mr' && !(Array.isArray(vorauswahl) && vorauswahl.length)) scanMr();
|
||||||
if (importKind === 'mmc') el('mmc-path').focus();
|
if (importKind === 'mmc') el('mmc-path').focus();
|
||||||
if (Array.isArray(vorauswahl) && vorauswahl.length) uebernehmeSetupVorauswahl(vorauswahl);
|
if (Array.isArray(vorauswahl) && vorauswahl.length) uebernehmeSetupVorauswahl(vorauswahl);
|
||||||
}
|
}
|
||||||
@@ -622,7 +642,7 @@ function openImportDialog(kind = 'zip', vorauswahl = null) {
|
|||||||
* eintragen und durchsuchen, damit man beim ersten Start nur noch auswählt.
|
* eintragen und durchsuchen, damit man beim ersten Start nur noch auswählt.
|
||||||
*/
|
*/
|
||||||
function uebernehmeSetupVorauswahl(quellen) {
|
function uebernehmeSetupVorauswahl(quellen) {
|
||||||
const scanner = { mc: scanMc, cf: scanCf, mmc: scanMmc, technic: scanTechnic };
|
const scanner = { mc: scanMc, cf: scanCf, mr: scanMr, mmc: scanMmc, technic: scanTechnic };
|
||||||
importWarteschlange = quellen.map((q) => q && q.kind).filter((k) => scanner[k]);
|
importWarteschlange = quellen.map((q) => q && q.kind).filter((k) => scanner[k]);
|
||||||
for (const q of quellen) {
|
for (const q of quellen) {
|
||||||
if (!q || !scanner[q.kind]) continue;
|
if (!q || !scanner[q.kind]) continue;
|
||||||
@@ -923,6 +943,82 @@ async function runCfImport() {
|
|||||||
if (instances.length) await selectInstance(instances[0].id);
|
if (instances.length) await selectInstance(instances[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scanMr() {
|
||||||
|
const folder = el('mr-path').value.trim();
|
||||||
|
el('mr-status').textContent = 'Suche Instanzen der Modrinth App …';
|
||||||
|
const res = await window.api.mrScan(folder);
|
||||||
|
if (!res.ok) {
|
||||||
|
el('mr-status').textContent = folder
|
||||||
|
? '✖ Dort wurden keine Daten der Modrinth App gefunden. Gemeint ist der Ordner mit „app.db“ und „profiles“.'
|
||||||
|
: '✖ Die Modrinth App wurde am Standardort nicht gefunden. Ist sie installiert? Sonst den Datenordner wählen.';
|
||||||
|
el('mr-result').classList.add('hidden');
|
||||||
|
el('mr-do').disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mrFound = res.instances;
|
||||||
|
el('mr-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden (${res.basis}).`
|
||||||
|
+ (res.hinweis ? ' ⚠ ' + res.hinweis : '');
|
||||||
|
el('mr-result').classList.remove('hidden');
|
||||||
|
el('mr-all').checked = true;
|
||||||
|
renderMrList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMrList() {
|
||||||
|
const box = el('mr-list');
|
||||||
|
box.innerHTML = '';
|
||||||
|
if (!mrFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; return; }
|
||||||
|
mrFound.forEach((inst, idx) => {
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = 'mmc-item';
|
||||||
|
const loaderTxt = inst.loader === 'vanilla'
|
||||||
|
? 'Vanilla'
|
||||||
|
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
|
||||||
|
const meta = [inst.version || 'ohne Version', loaderTxt, inst.group ? 'Gruppe: ' + inst.group : '',
|
||||||
|
inst.modpack ? 'Modrinth-Modpack' : '', inst.playSeconds ? fmtDuration(inst.playSeconds) + ' gespielt' : '',
|
||||||
|
inst.installiert === false ? 'nicht fertig installiert' : '']
|
||||||
|
.filter(Boolean).join(' · ');
|
||||||
|
row.innerHTML =
|
||||||
|
`<input type="checkbox" data-idx="${idx}" checked />
|
||||||
|
<div class="mod-info">
|
||||||
|
<div class="mmc-name">${escapeHtml(inst.name)}</div>
|
||||||
|
<div class="mmc-meta">${escapeHtml(meta)}</div>
|
||||||
|
</div>`;
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
updateMrButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedMr() {
|
||||||
|
return [...document.querySelectorAll('#mr-list input[type=checkbox]')]
|
||||||
|
.filter((c) => c.checked)
|
||||||
|
.map((c) => mrFound[Number(c.dataset.idx)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMrButton() {
|
||||||
|
const n = selectedMr().length;
|
||||||
|
el('mr-do').disabled = n === 0;
|
||||||
|
el('mr-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMrImport() {
|
||||||
|
const entries = selectedMr();
|
||||||
|
if (!entries.length) return;
|
||||||
|
const copy = el('mr-copy').checked;
|
||||||
|
el('mr-do').disabled = true;
|
||||||
|
el('mr-progress').textContent = 'Übernehme …';
|
||||||
|
el('mr-progress').classList.remove('hidden');
|
||||||
|
|
||||||
|
const res = await window.api.mrImport(entries, copy);
|
||||||
|
await reload();
|
||||||
|
importAbgeschlossen('mr');
|
||||||
|
const n = res.imported.length;
|
||||||
|
const f = res.failed.length;
|
||||||
|
const ohneLink = res.imported.filter((i) => i.modpack === false).length;
|
||||||
|
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}`
|
||||||
|
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0);
|
||||||
|
if (instances.length) await selectInstance(instances[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
async function scanMmc() {
|
async function scanMmc() {
|
||||||
const folder = el('mmc-path').value.trim();
|
const folder = el('mmc-path').value.trim();
|
||||||
if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
|
if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
|
||||||
@@ -1464,7 +1560,11 @@ async function applySkinBackground(uuidOverride) {
|
|||||||
// Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht),
|
// Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht),
|
||||||
// damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist.
|
// damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist.
|
||||||
// 1) Skin lokal in 3D rendern – unabhängig von Render-Diensten
|
// 1) Skin lokal in 3D rendern – unabhängig von Render-Diensten
|
||||||
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking');
|
// Armform: fest gewählt oder (Standard) die des Kontos – sonst sieht ein
|
||||||
|
// schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm)
|
||||||
|
let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : '';
|
||||||
|
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
|
||||||
|
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell });
|
||||||
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
|
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
|
||||||
|
|
||||||
// 2) Rückfall: externe Render-Dienste
|
// 2) Rückfall: externe Render-Dienste
|
||||||
@@ -1630,7 +1730,7 @@ const ICONS_SVG = {
|
|||||||
terminal: '<path d="M4 5h16v14H4zM7 9l3 3-3 3M13 16h4"/>',
|
terminal: '<path d="M4 5h16v14H4zM7 9l3 3-3 3M13 16h4"/>',
|
||||||
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z"/>',
|
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z"/>',
|
||||||
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>',
|
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>',
|
||||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
|
settings: '<path d="M19.1 13.1 21.5 14.5 20.5 16.9 17.8 16.3 16.3 17.8 16.9 20.5 14.5 21.5 13.1 19.1 10.9 19.1 9.5 21.5 7.1 20.5 7.7 17.8 6.2 16.3 3.5 16.9 2.5 14.5 4.9 13.1 4.9 10.9 2.5 9.5 3.5 7.1 6.2 7.7 7.7 6.2 7.1 3.5 9.5 2.5 10.9 4.9 13.1 4.9 14.5 2.5 16.9 3.5 16.3 6.2 17.8 7.7 20.5 7.1 21.5 9.5 19.1 10.9Z"/><circle cx="12" cy="12" r="3"/>', // Zahnrad (8 Zähne)
|
||||||
play: '<path d="M7 5l12 7-12 7z"/>',
|
play: '<path d="M7 5l12 7-12 7z"/>',
|
||||||
blocks: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
|
blocks: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
|
||||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>',
|
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>',
|
||||||
@@ -4790,7 +4890,8 @@ async function openSettings(pane) {
|
|||||||
window.api.updateLastCheck().then(renderUpdateStatus).catch(() => {});
|
window.api.updateLastCheck().then(renderUpdateStatus).catch(() => {});
|
||||||
|
|
||||||
const s = await window.api.getSettings();
|
const s = await window.api.getSettings();
|
||||||
el('s-theme').value = s.theme || 'dark';
|
el('s-theme').value = DESIGNS.includes(s.theme) ? s.theme : 'dark';
|
||||||
|
applyTheme(el('s-theme').value);
|
||||||
el('s-global-pre').value = s.globalPreLaunch || '';
|
el('s-global-pre').value = s.globalPreLaunch || '';
|
||||||
el('s-global-post').value = s.globalPostExit || '';
|
el('s-global-post').value = s.globalPostExit || '';
|
||||||
el('s-wrapper').value = s.wrapperCommand || '';
|
el('s-wrapper').value = s.wrapperCommand || '';
|
||||||
@@ -4806,6 +4907,7 @@ async function openSettings(pane) {
|
|||||||
}
|
}
|
||||||
sel.value = s.bgSkin || '';
|
sel.value = s.bgSkin || '';
|
||||||
el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
|
el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
|
||||||
|
el('s-bgskin-model').value = ['classic', 'slim'].includes(s.bgSkinModel) ? s.bgSkinModel : 'auto';
|
||||||
el('s-bgskin-cycle').checked = !!s.bgSkinCycle;
|
el('s-bgskin-cycle').checked = !!s.bgSkinCycle;
|
||||||
el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30;
|
el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30;
|
||||||
sel.disabled = !!s.bgSkinCycle;
|
sel.disabled = !!s.bgSkinCycle;
|
||||||
@@ -4939,6 +5041,7 @@ async function saveSettings() {
|
|||||||
globalPostExit: el('s-global-post').value.trim(),
|
globalPostExit: el('s-global-post').value.trim(),
|
||||||
bgSkin: el('s-bgskin').value,
|
bgSkin: el('s-bgskin').value,
|
||||||
bgSkinPose: el('s-bgskin-pose').value,
|
bgSkinPose: el('s-bgskin-pose').value,
|
||||||
|
bgSkinModel: el('s-bgskin-model').value,
|
||||||
bgSkinCycle: el('s-bgskin-cycle').checked,
|
bgSkinCycle: el('s-bgskin-cycle').checked,
|
||||||
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
|
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
|
||||||
sortBy: el('s-sortby').value,
|
sortBy: el('s-sortby').value,
|
||||||
@@ -5471,6 +5574,9 @@ function wire() {
|
|||||||
});
|
});
|
||||||
document.addEventListener('scroll', closeContextMenu, true);
|
document.addEventListener('scroll', closeContextMenu, true);
|
||||||
window.addEventListener('blur', closeContextMenu);
|
window.addEventListener('blur', closeContextMenu);
|
||||||
|
// Titelleiste wie bei Windows abblenden, solange das Fenster nicht aktiv ist
|
||||||
|
window.addEventListener('blur', () => document.body.classList.add('fenster-inaktiv'));
|
||||||
|
window.addEventListener('focus', () => document.body.classList.remove('fenster-inaktiv'));
|
||||||
|
|
||||||
// Warnung anklicken -> sofort erneut prüfen
|
// Warnung anklicken -> sofort erneut prüfen
|
||||||
el('status-auth').addEventListener('click', async () => {
|
el('status-auth').addEventListener('click', async () => {
|
||||||
@@ -5530,6 +5636,18 @@ function wire() {
|
|||||||
});
|
});
|
||||||
el('cf-list').addEventListener('change', updateCfButton);
|
el('cf-list').addEventListener('change', updateCfButton);
|
||||||
el('cf-do').addEventListener('click', runCfImport);
|
el('cf-do').addEventListener('click', runCfImport);
|
||||||
|
el('mr-pick').addEventListener('click', async () => {
|
||||||
|
const p = await window.api.mrPickFolder();
|
||||||
|
if (p) { el('mr-path').value = p; await scanMr(); }
|
||||||
|
});
|
||||||
|
el('mr-scan').addEventListener('click', scanMr);
|
||||||
|
el('mr-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMr(); });
|
||||||
|
el('mr-all').addEventListener('change', (e) => {
|
||||||
|
document.querySelectorAll('#mr-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
|
||||||
|
updateMrButton();
|
||||||
|
});
|
||||||
|
el('mr-list').addEventListener('change', updateMrButton);
|
||||||
|
el('mr-do').addEventListener('click', runMrImport);
|
||||||
el('mmc-pick').addEventListener('click', async () => {
|
el('mmc-pick').addEventListener('click', async () => {
|
||||||
const p = await window.api.mmcPickFolder();
|
const p = await window.api.mmcPickFolder();
|
||||||
if (p) { el('mmc-path').value = p; await scanMmc(); }
|
if (p) { el('mmc-path').value = p; await scanMmc(); }
|
||||||
@@ -5567,6 +5685,10 @@ function wire() {
|
|||||||
el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
||||||
el('cf-progress').classList.remove('hidden');
|
el('cf-progress').classList.remove('hidden');
|
||||||
});
|
});
|
||||||
|
window.api.onMrProgress((p) => {
|
||||||
|
el('mr-progress').textContent = p.detail ? `${p.detail} …` : `Übernehme … ${p.current}/${p.total}`;
|
||||||
|
el('mr-progress').classList.remove('hidden');
|
||||||
|
});
|
||||||
window.api.onMmcProgress((p) => {
|
window.api.onMmcProgress((p) => {
|
||||||
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
||||||
el('mmc-progress').classList.remove('hidden');
|
el('mmc-progress').classList.remove('hidden');
|
||||||
@@ -5804,6 +5926,10 @@ function wire() {
|
|||||||
el('skin-pick').addEventListener('click', waehleSkinDatei);
|
el('skin-pick').addEventListener('click', waehleSkinDatei);
|
||||||
el('skin-variante').addEventListener('change', skinVarianteGewechselt);
|
el('skin-variante').addEventListener('change', skinVarianteGewechselt);
|
||||||
el('skin-apply').addEventListener('click', setzeSkin);
|
el('skin-apply').addEventListener('click', setzeSkin);
|
||||||
|
// gewählte Datei (sonst der aktuelle Skin des Kontos) im Editor öffnen
|
||||||
|
el('skin-editor').addEventListener('click', () => window.openSkinEditor(gewaehlterSkin
|
||||||
|
? { pfad: gewaehlterSkin, variante: el('skin-variante').value }
|
||||||
|
: { quelle: 'aktuell' }));
|
||||||
el('skin-reset').addEventListener('click', setzeSkinZurueck);
|
el('skin-reset').addEventListener('click', setzeSkinZurueck);
|
||||||
el('logs-select').addEventListener('change', (e) => ladeLog(e.target.value));
|
el('logs-select').addEventListener('change', (e) => ladeLog(e.target.value));
|
||||||
el('logs-folder').addEventListener('click', async () => {
|
el('logs-folder').addEventListener('click', async () => {
|
||||||
@@ -5986,6 +6112,11 @@ function wire() {
|
|||||||
if (p) { el('s-java').value = p; await refreshJavaStatus(p); }
|
if (p) { el('s-java').value = p; await refreshJavaStatus(p); }
|
||||||
});
|
});
|
||||||
el('s-theme').addEventListener('change', (e) => applyTheme(e.target.value));
|
el('s-theme').addEventListener('change', (e) => applyTheme(e.target.value));
|
||||||
|
// Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an
|
||||||
|
document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => {
|
||||||
|
el('s-theme').value = b.dataset.design;
|
||||||
|
applyTheme(b.dataset.design);
|
||||||
|
}));
|
||||||
el('s-bgskin-opacity').addEventListener('input', (e) => {
|
el('s-bgskin-opacity').addEventListener('input', (e) => {
|
||||||
el('s-bgskin-val').textContent = e.target.value;
|
el('s-bgskin-val').textContent = e.target.value;
|
||||||
document.documentElement.style.setProperty('--bg-skin-opacity', String(e.target.value / 100));
|
document.documentElement.style.setProperty('--bg-skin-opacity', String(e.target.value / 100));
|
||||||
@@ -5995,11 +6126,13 @@ function wire() {
|
|||||||
await window.api.saveSettings({
|
await window.api.saveSettings({
|
||||||
bgSkin: el('s-bgskin').value,
|
bgSkin: el('s-bgskin').value,
|
||||||
bgSkinPose: el('s-bgskin-pose').value,
|
bgSkinPose: el('s-bgskin-pose').value,
|
||||||
|
bgSkinModel: el('s-bgskin-model').value,
|
||||||
});
|
});
|
||||||
await refreshSkinBackground();
|
await refreshSkinBackground();
|
||||||
};
|
};
|
||||||
el('s-bgskin').addEventListener('change', previewSkin);
|
el('s-bgskin').addEventListener('change', previewSkin);
|
||||||
el('s-bgskin-pose').addEventListener('change', previewSkin);
|
el('s-bgskin-pose').addEventListener('change', previewSkin);
|
||||||
|
el('s-bgskin-model').addEventListener('change', previewSkin);
|
||||||
const previewSkinCycle = async () => {
|
const previewSkinCycle = async () => {
|
||||||
el('s-bgskin').disabled = el('s-bgskin-cycle').checked;
|
el('s-bgskin').disabled = el('s-bgskin-cycle').checked;
|
||||||
await window.api.saveSettings({
|
await window.api.saveSettings({
|
||||||
@@ -6240,6 +6373,7 @@ const POPOUT_OPENERS = {
|
|||||||
screens: () => openScreens(),
|
screens: () => openScreens(),
|
||||||
servers: () => openServers(),
|
servers: () => openServers(),
|
||||||
skin: () => openSkin(),
|
skin: () => openSkin(),
|
||||||
|
skineditor: (p) => window.openSkinEditor(p),
|
||||||
bedrock: () => openBedrock(),
|
bedrock: () => openBedrock(),
|
||||||
login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }),
|
login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }),
|
||||||
import: (p) => {
|
import: (p) => {
|
||||||
@@ -6398,7 +6532,11 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Ein Skin-Wechsel oder eine neue/aktualisierte Instanz aus einem anderen
|
// Ein Skin-Wechsel oder eine neue/aktualisierte Instanz aus einem anderen
|
||||||
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
|
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
|
||||||
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
|
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
|
||||||
window.api.onSkinChanged(() => { if (!POPOUT) refreshSkinBackground(); });
|
window.api.onSkinChanged((info) => {
|
||||||
|
if (!POPOUT) refreshSkinBackground();
|
||||||
|
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
|
||||||
|
if (POPOUT === 'skin' && info.quelle === 'editor' && !el('modal-skin').classList.contains('hidden')) openSkin();
|
||||||
|
});
|
||||||
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
|
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
|
||||||
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
|
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
|
||||||
await loadMcIcons(); // echte Minecraft-Icons bereitstellen
|
await loadMcIcons(); // echte Minecraft-Icons bereitstellen
|
||||||
|
|||||||
@@ -0,0 +1,983 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* skineditor.js – Pixel-Editor für Minecraft-Skins
|
||||||
|
* -------------------------------------------------
|
||||||
|
* Ein Skin ist eine einzige 64×64-Textur. Jedes Körperteil hat darin zwei
|
||||||
|
* Bereiche: die Grundschicht (liegt direkt auf der Figur) und die 2. Schicht
|
||||||
|
* (eine etwas größere Hülle darüber – Haare, Jacke, Ärmel …). Die 2. Schicht
|
||||||
|
* darf durchsichtig sein und lässt die Figur dadurch plastischer wirken.
|
||||||
|
*
|
||||||
|
* Gemalt wird wahlweise auf der aufgeklappten Vorlage (2D) oder direkt auf der
|
||||||
|
* drehbaren Figur (3D). Beide Ansichten sind immer da – die gerade nicht
|
||||||
|
* gewählte steht klein rechts und ist ebenfalls bemalbar.
|
||||||
|
*
|
||||||
|
* Läuft im Popup-Fenster "skineditor" und nutzt die Helfer aus renderer.js
|
||||||
|
* (el, show, toast, askConfirm, POPOUT, SKIN_POSES).
|
||||||
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const N = 64;
|
||||||
|
|
||||||
|
// Lage der Körperteile in der Textur. w/h/d = Breite/Höhe/Tiefe in Pixeln,
|
||||||
|
// base/over = linke obere Ecke des Bereichs der Grund- bzw. 2. Schicht.
|
||||||
|
const TEILE = [
|
||||||
|
{ key: 'head', name: 'Kopf', w: 8, h: 8, d: 8, base: [0, 0], over: [32, 0], spiegel: 'head' },
|
||||||
|
{ key: 'body', name: 'Körper', w: 8, h: 12, d: 4, base: [16, 16], over: [16, 32], spiegel: 'body' },
|
||||||
|
{ key: 'rightArm', name: 'Rechter Arm', w: 4, h: 12, d: 4, base: [40, 16], over: [40, 32], spiegel: 'leftArm', arm: true },
|
||||||
|
{ key: 'leftArm', name: 'Linker Arm', w: 4, h: 12, d: 4, base: [32, 48], over: [48, 48], spiegel: 'rightArm', arm: true },
|
||||||
|
{ key: 'rightLeg', name: 'Rechtes Bein', w: 4, h: 12, d: 4, base: [0, 16], over: [0, 32], spiegel: 'leftLeg' },
|
||||||
|
{ key: 'leftLeg', name: 'Linkes Bein', w: 4, h: 12, d: 4, base: [16, 48], over: [0, 48], spiegel: 'rightLeg' },
|
||||||
|
];
|
||||||
|
const TEIL = Object.fromEntries(TEILE.map((t) => [t.key, t]));
|
||||||
|
const SEITE_NAME = { oben: 'oben', unten: 'unten', rechts: 'rechte Seite', vorne: 'vorne', links: 'linke Seite', hinten: 'hinten' };
|
||||||
|
const SEITE_GEGENSTUECK = { rechts: 'links', links: 'rechts' };
|
||||||
|
const SCHICHT_NAME = ['Grundschicht', '2. Schicht'];
|
||||||
|
|
||||||
|
// Rechtecke [x, y, breite, höhe] der sechs Seiten eines Teils (Minecraft-Aufteilung:
|
||||||
|
// oben/unten in der ersten Reihe, darunter rechts | vorne | links | hinten)
|
||||||
|
function seiten(teil, schicht, slim) {
|
||||||
|
const w = teil.arm && slim ? 3 : teil.w;
|
||||||
|
const { h, d } = teil;
|
||||||
|
const [u, v] = schicht ? teil.over : teil.base;
|
||||||
|
return {
|
||||||
|
oben: [u + d, v, w, d],
|
||||||
|
unten: [u + d + w, v, w, d],
|
||||||
|
rechts: [u, v + d, d, h],
|
||||||
|
vorne: [u + d, v + d, w, h],
|
||||||
|
links: [u + d + w, v + d, d, h],
|
||||||
|
hinten: [u + 2 * d + w, v + d, w, h],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für jede Armform: Liste aller Flächen und eine Pixel→Fläche-Karte
|
||||||
|
const AUFTEILUNG = [false, true].map((slim) => {
|
||||||
|
const flaechen = [];
|
||||||
|
const karte = new Array(N * N).fill(null);
|
||||||
|
for (const teil of TEILE) {
|
||||||
|
for (const schicht of [0, 1]) {
|
||||||
|
for (const [seite, r] of Object.entries(seiten(teil, schicht, slim))) {
|
||||||
|
const f = { teil, schicht, seite, x: r[0], y: r[1], w: r[2], h: r[3] };
|
||||||
|
flaechen.push(f);
|
||||||
|
for (let yy = 0; yy < f.h; yy++) for (let xx = 0; xx < f.w; xx++) karte[(f.y + yy) * N + f.x + xx] = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { flaechen, karte };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Zustand ----------
|
||||||
|
const ed = {
|
||||||
|
daten: new Uint8ClampedArray(N * N * 4),
|
||||||
|
slim: false,
|
||||||
|
werkzeug: 'stift',
|
||||||
|
vorPipette: 'stift',
|
||||||
|
farbe: '#3a6ea5',
|
||||||
|
schicht: 0,
|
||||||
|
zeigeAussen: true,
|
||||||
|
durchscheinen: true,
|
||||||
|
spiegeln: false,
|
||||||
|
modus: '2d',
|
||||||
|
undo: [],
|
||||||
|
redo: [],
|
||||||
|
geaendert: false,
|
||||||
|
hover: null,
|
||||||
|
strich: null, // { snap, letzter: [x,y] | null, letzterBild: [cx,cy] | null, geaendert }
|
||||||
|
viewer: null,
|
||||||
|
bereit: false,
|
||||||
|
zeichnenGeplant: false,
|
||||||
|
};
|
||||||
|
const textur = document.createElement('canvas');
|
||||||
|
textur.width = textur.height = N;
|
||||||
|
const texturCtx = textur.getContext('2d', { willReadFrequently: true });
|
||||||
|
// Grundschicht als Schatten unter die 2. Schicht legen (Option "durchscheinen")
|
||||||
|
const schatten = document.createElement('canvas');
|
||||||
|
schatten.width = schatten.height = N;
|
||||||
|
const schattenCtx = schatten.getContext('2d');
|
||||||
|
// Hintergrund (Karomuster in den Flächen) je Armform einmal vorberechnet
|
||||||
|
const hintergrund = [null, null];
|
||||||
|
|
||||||
|
const aufteilung = () => AUFTEILUNG[ed.slim ? 1 : 0];
|
||||||
|
const flaecheBei = (x, y) => (x < 0 || y < 0 || x >= N || y >= N ? null : aufteilung().karte[y * N + x]);
|
||||||
|
const dunkel = () => document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
|
||||||
|
function hexZuRgb(hex) {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
|
||||||
|
if (!m) return [0, 0, 0];
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
const rgbZuHex = (r, g, b) => '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
// Pixel auf der gespiegelten Körperseite (rechter ↔ linker Arm/Bein,
|
||||||
|
// rechte ↔ linke Seite, waagerecht umgedreht)
|
||||||
|
function spiegelPixel(x, y) {
|
||||||
|
const f = flaecheBei(x, y);
|
||||||
|
if (!f) return null;
|
||||||
|
const partner = TEIL[f.teil.spiegel];
|
||||||
|
const seite = SEITE_GEGENSTUECK[f.seite] || f.seite;
|
||||||
|
const r = seiten(partner, f.schicht, ed.slim)[seite];
|
||||||
|
const fx = x - f.x;
|
||||||
|
const fy = y - f.y;
|
||||||
|
return [r[0] + (r[2] - 1 - fx), r[1] + fy];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Daten ändern ----------
|
||||||
|
function setzePixel(x, y, rgba) {
|
||||||
|
const i = (y * N + x) * 4;
|
||||||
|
const d = ed.daten;
|
||||||
|
if (d[i] === rgba[0] && d[i + 1] === rgba[1] && d[i + 2] === rgba[2] && d[i + 3] === rgba[3]) return false;
|
||||||
|
d[i] = rgba[0]; d[i + 1] = rgba[1]; d[i + 2] = rgba[2]; d[i + 3] = rgba[3];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zielPixel(x, y) {
|
||||||
|
const liste = [[x, y]];
|
||||||
|
if (ed.spiegeln) {
|
||||||
|
const s = spiegelPixel(x, y);
|
||||||
|
if (s && (s[0] !== x || s[1] !== y)) liste.push(s);
|
||||||
|
}
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
function malFarbe() {
|
||||||
|
if (ed.werkzeug === 'radierer') return [0, 0, 0, 0];
|
||||||
|
return [...hexZuRgb(ed.farbe), 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einen Pixel mit dem aktuellen Werkzeug bearbeiten (Stift/Radierer)
|
||||||
|
function malePixel(x, y) {
|
||||||
|
const f = flaecheBei(x, y);
|
||||||
|
if (!f || f.schicht !== ed.schicht) return false;
|
||||||
|
let geaendert = false;
|
||||||
|
const rgba = malFarbe();
|
||||||
|
for (const [px, py] of zielPixel(x, y)) if (setzePixel(px, py, rgba)) geaendert = true;
|
||||||
|
return geaendert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Füllen: zusammenhängende gleichfarbige Pixel innerhalb derselben Fläche
|
||||||
|
function fuelle(x, y) {
|
||||||
|
const f = flaecheBei(x, y);
|
||||||
|
if (!f || f.schicht !== ed.schicht) return false;
|
||||||
|
const d = ed.daten;
|
||||||
|
const i0 = (y * N + x) * 4;
|
||||||
|
const alt = [d[i0], d[i0 + 1], d[i0 + 2], d[i0 + 3]];
|
||||||
|
const neu = malFarbe();
|
||||||
|
if (alt.every((v, k) => v === neu[k])) return false;
|
||||||
|
const gleich = (px, py) => {
|
||||||
|
const i = (py * N + px) * 4;
|
||||||
|
return d[i] === alt[0] && d[i + 1] === alt[1] && d[i + 2] === alt[2] && d[i + 3] === alt[3];
|
||||||
|
};
|
||||||
|
const gesehen = new Set();
|
||||||
|
const stapel = [[x, y]];
|
||||||
|
const treffer = [];
|
||||||
|
while (stapel.length) {
|
||||||
|
const [px, py] = stapel.pop();
|
||||||
|
const k = py * N + px;
|
||||||
|
if (gesehen.has(k)) continue;
|
||||||
|
if (px < f.x || py < f.y || px >= f.x + f.w || py >= f.y + f.h) continue;
|
||||||
|
gesehen.add(k);
|
||||||
|
if (!gleich(px, py)) continue;
|
||||||
|
treffer.push([px, py]);
|
||||||
|
stapel.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]);
|
||||||
|
}
|
||||||
|
let geaendert = false;
|
||||||
|
for (const [px, py] of treffer) for (const [zx, zy] of zielPixel(px, py)) if (setzePixel(zx, zy, neu)) geaendert = true;
|
||||||
|
return geaendert;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nimmFarbe(x, y) {
|
||||||
|
const i = (y * N + x) * 4;
|
||||||
|
const d = ed.daten;
|
||||||
|
if (d[i + 3] === 0) return false;
|
||||||
|
setzeFarbe(rgbZuHex(d[i], d[i + 1], d[i + 2]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Striche, Rückgängig ----------
|
||||||
|
function beginneStrich() {
|
||||||
|
ed.strich = { snap: ed.daten.slice(), letzter: null, letzterBild: null, geaendert: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function beendeStrich() {
|
||||||
|
const s = ed.strich;
|
||||||
|
ed.strich = null;
|
||||||
|
if (!s || !s.geaendert) return;
|
||||||
|
ed.undo.push(s.snap);
|
||||||
|
if (ed.undo.length > 100) ed.undo.shift();
|
||||||
|
ed.redo = [];
|
||||||
|
ed.geaendert = true;
|
||||||
|
if (ed.werkzeug !== 'radierer') merkeFarbe(ed.farbe);
|
||||||
|
aktualisiereKnoepfe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Werkzeug auf einen Texturpixel anwenden (während eines Strichs)
|
||||||
|
function wendeAn(x, y) {
|
||||||
|
const s = ed.strich;
|
||||||
|
if (!s) return;
|
||||||
|
if (ed.werkzeug === 'pipette') {
|
||||||
|
nimmFarbe(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ed.werkzeug === 'fuellen') {
|
||||||
|
if (s.letzter) return; // ein Klick = eine Füllung
|
||||||
|
s.letzter = [x, y];
|
||||||
|
if (fuelle(x, y)) { s.geaendert = true; texturGeaendert(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let geaendert = false;
|
||||||
|
// Lücken zwischen zwei Mausereignissen schließen (nur innerhalb der Vorlage
|
||||||
|
// sinnvoll, auf der Figur liegen benachbarte Pixel nicht nebeneinander)
|
||||||
|
const punkte = s.letzter && s.zweiD ? linie(s.letzter[0], s.letzter[1], x, y) : [[x, y]];
|
||||||
|
for (const [px, py] of punkte) if (malePixel(px, py)) geaendert = true;
|
||||||
|
s.letzter = [x, y];
|
||||||
|
if (geaendert) { s.geaendert = true; texturGeaendert(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function linie(x0, y0, x1, y1) {
|
||||||
|
const punkte = [];
|
||||||
|
const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
|
||||||
|
const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
|
||||||
|
let err = dx + dy;
|
||||||
|
for (;;) {
|
||||||
|
punkte.push([x0, y0]);
|
||||||
|
if (x0 === x1 && y0 === y1) break;
|
||||||
|
const e2 = 2 * err;
|
||||||
|
if (e2 >= dy) { err += dy; x0 += sx; }
|
||||||
|
if (e2 <= dx) { err += dx; y0 += sy; }
|
||||||
|
}
|
||||||
|
return punkte;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rueckgaengig() {
|
||||||
|
if (!ed.undo.length) return;
|
||||||
|
ed.redo.push(ed.daten.slice());
|
||||||
|
ed.daten = ed.undo.pop();
|
||||||
|
ed.geaendert = true;
|
||||||
|
texturGeaendert();
|
||||||
|
aktualisiereKnoepfe();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wiederholen() {
|
||||||
|
if (!ed.redo.length) return;
|
||||||
|
ed.undo.push(ed.daten.slice());
|
||||||
|
ed.daten = ed.redo.pop();
|
||||||
|
ed.geaendert = true;
|
||||||
|
texturGeaendert();
|
||||||
|
aktualisiereKnoepfe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ganze Änderung (Laden, Leeren …) als ein Rückgängig-Schritt
|
||||||
|
function aenderung(fn) {
|
||||||
|
const snap = ed.daten.slice();
|
||||||
|
fn();
|
||||||
|
if (snap.every((v, i) => v === ed.daten[i])) return false;
|
||||||
|
ed.undo.push(snap);
|
||||||
|
if (ed.undo.length > 100) ed.undo.shift();
|
||||||
|
ed.redo = [];
|
||||||
|
ed.geaendert = true;
|
||||||
|
texturGeaendert();
|
||||||
|
aktualisiereKnoepfe();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Farben ----------
|
||||||
|
const FARB_SCHLUESSEL = 'aeromc-skin-farben';
|
||||||
|
const STANDARD_FARBEN = ['#000000', '#ffffff', '#c69c6d', '#a87b4f', '#6b4a2f', '#3b2416', '#d32f2f', '#f9a825',
|
||||||
|
'#43a047', '#1e88e5', '#3949ab', '#8e24aa', '#00897b', '#757575', '#424242', '#ff8a65'];
|
||||||
|
let zuletzt = [];
|
||||||
|
try { zuletzt = JSON.parse(localStorage.getItem(FARB_SCHLUESSEL) || '[]'); } catch { zuletzt = []; }
|
||||||
|
if (!Array.isArray(zuletzt) || !zuletzt.length) zuletzt = STANDARD_FARBEN.slice();
|
||||||
|
|
||||||
|
function merkeFarbe(hex) {
|
||||||
|
zuletzt = [hex, ...zuletzt.filter((f) => f !== hex)].slice(0, 16);
|
||||||
|
try { localStorage.setItem(FARB_SCHLUESSEL, JSON.stringify(zuletzt)); } catch { /* egal */ }
|
||||||
|
zeigeZuletzt();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeZuletzt() {
|
||||||
|
const box = el('se-zuletzt');
|
||||||
|
box.innerHTML = '';
|
||||||
|
for (const f of zuletzt) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'se-farbfeld' + (f === ed.farbe ? ' on' : '');
|
||||||
|
b.style.background = f;
|
||||||
|
b.title = f;
|
||||||
|
b.addEventListener('click', () => setzeFarbe(f));
|
||||||
|
box.appendChild(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setzeFarbe(hex) {
|
||||||
|
ed.farbe = hex.toLowerCase();
|
||||||
|
el('se-farbe').value = ed.farbe;
|
||||||
|
el('se-hex').value = ed.farbe;
|
||||||
|
// nach dem Aufnehmen zurück zum vorherigen Werkzeug; mit dem Radierer
|
||||||
|
// ergibt eine neue Farbe keinen Sinn -> Stift
|
||||||
|
if (ed.werkzeug === 'pipette') waehleWerkzeug(ed.vorPipette === 'radierer' ? 'stift' : ed.vorPipette);
|
||||||
|
else if (ed.werkzeug === 'radierer') waehleWerkzeug('stift');
|
||||||
|
zeigeZuletzt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bedienelemente ----------
|
||||||
|
function waehleWerkzeug(w) {
|
||||||
|
if (w === 'radierer' && ed.schicht === 0) w = 'stift';
|
||||||
|
if (w === 'pipette' && ed.werkzeug !== 'pipette') ed.vorPipette = ed.werkzeug;
|
||||||
|
ed.werkzeug = w;
|
||||||
|
document.querySelectorAll('[data-se-tool]').forEach((b) => b.classList.toggle('on', b.dataset.seTool === w));
|
||||||
|
for (const h of [el('se-2d'), el('se-3d')]) h.dataset.werkzeug = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waehleSchicht(s) {
|
||||||
|
ed.schicht = s;
|
||||||
|
document.querySelectorAll('[data-se-schicht]').forEach((b) => b.classList.toggle('on', Number(b.dataset.seSchicht) === s));
|
||||||
|
// die 2. Schicht muss sichtbar sein, wenn man darauf malen will
|
||||||
|
if (s === 1 && !ed.zeigeAussen) { ed.zeigeAussen = true; el('se-aussen').checked = true; }
|
||||||
|
const radierer = document.querySelector('[data-se-tool="radierer"]');
|
||||||
|
radierer.disabled = s === 0;
|
||||||
|
radierer.title = s === 0
|
||||||
|
? 'Radierer – nur in der 2. Schicht (die Grundschicht darf keine Löcher haben)'
|
||||||
|
: 'Radierer (E) – macht Pixel durchsichtig';
|
||||||
|
if (s === 0 && ed.werkzeug === 'radierer') waehleWerkzeug('stift');
|
||||||
|
el('se-durch').disabled = s === 0;
|
||||||
|
ed.hover = null;
|
||||||
|
texturGeaendert();
|
||||||
|
}
|
||||||
|
|
||||||
|
function waehleModus(m) {
|
||||||
|
ed.modus = m;
|
||||||
|
document.querySelectorAll('[data-se-modus]').forEach((b) => b.classList.toggle('on', b.dataset.seModus === m));
|
||||||
|
const gross = el('se-buehne');
|
||||||
|
const klein = el('se-mini');
|
||||||
|
gross.appendChild(m === '2d' ? el('se-2d') : el('se-3d'));
|
||||||
|
klein.appendChild(m === '2d' ? el('se-3d') : el('se-2d'));
|
||||||
|
el('se-mini-titel').textContent = m === '2d' ? '3D-Vorschau' : 'Vorlage';
|
||||||
|
zeigeInfo();
|
||||||
|
passeGroessenAn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktualisiereKnoepfe() {
|
||||||
|
el('se-undo').disabled = !ed.undo.length;
|
||||||
|
el('se-redo').disabled = !ed.redo.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeInfo() {
|
||||||
|
const info = el('se-info');
|
||||||
|
const h = ed.hover;
|
||||||
|
const f = h && flaecheBei(h[0], h[1]);
|
||||||
|
if (f) {
|
||||||
|
info.textContent = `${f.teil.name} · ${SEITE_NAME[f.seite]} · ${SCHICHT_NAME[f.schicht]} · Pixel ${h[0]}, ${h[1]}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info.textContent = ed.modus === '3d'
|
||||||
|
? 'Linke Maustaste malt · rechte Maustaste dreht · Mausrad zoomt · Alt+Klick nimmt Farbe auf'
|
||||||
|
: 'Linke Maustaste malt · Alt+Klick nimmt eine Farbe auf · abgedunkelte Bereiche gehören zur anderen Schicht';
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(text) { el('se-status').textContent = text || ''; el('se-status').title = text || ''; }
|
||||||
|
|
||||||
|
// ---------- Zeichnen ----------
|
||||||
|
function texturGeaendert() {
|
||||||
|
texturCtx.putImageData(new ImageData(ed.daten, N, N), 0, 0);
|
||||||
|
// Schatten: Grundschicht-Pixel an die Stelle der zugehörigen 2. Schicht legen
|
||||||
|
const s = schattenCtx.createImageData(N, N);
|
||||||
|
for (const f of aufteilung().flaechen) {
|
||||||
|
if (f.schicht !== 1) continue;
|
||||||
|
const r = seiten(f.teil, 0, ed.slim)[f.seite];
|
||||||
|
for (let yy = 0; yy < f.h; yy++) {
|
||||||
|
for (let xx = 0; xx < f.w; xx++) {
|
||||||
|
const q = ((r[1] + yy) * N + r[0] + xx) * 4;
|
||||||
|
const z = ((f.y + yy) * N + f.x + xx) * 4;
|
||||||
|
s.data[z] = ed.daten[q]; s.data[z + 1] = ed.daten[q + 1]; s.data[z + 2] = ed.daten[q + 2]; s.data[z + 3] = ed.daten[q + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schattenCtx.putImageData(s, 0, 0);
|
||||||
|
planeZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function planeZeichnen() {
|
||||||
|
if (ed.zeichnenGeplant) return;
|
||||||
|
ed.zeichnenGeplant = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ed.zeichnenGeplant = false;
|
||||||
|
zeichne2D();
|
||||||
|
zeichne3D();
|
||||||
|
zeigeInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hintergrundFuer(slim) {
|
||||||
|
const k = slim ? 1 : 0;
|
||||||
|
if (hintergrund[k] && hintergrund[k].dunkel === dunkel()) return hintergrund[k].canvas;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = N;
|
||||||
|
const g = c.getContext('2d');
|
||||||
|
const img = g.createImageData(N, N);
|
||||||
|
const [a, b] = dunkel() ? [[58, 58, 64], [48, 48, 54]] : [[236, 236, 238], [218, 219, 223]];
|
||||||
|
for (const f of AUFTEILUNG[k].flaechen) {
|
||||||
|
for (let yy = f.y; yy < f.y + f.h; yy++) {
|
||||||
|
for (let xx = f.x; xx < f.x + f.w; xx++) {
|
||||||
|
const farbe = (xx + yy) % 2 ? a : b;
|
||||||
|
const i = (yy * N + xx) * 4;
|
||||||
|
img.data[i] = farbe[0]; img.data[i + 1] = farbe[1]; img.data[i + 2] = farbe[2]; img.data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(img, 0, 0);
|
||||||
|
hintergrund[k] = { canvas: c, dunkel: dunkel() };
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sichtbare Textur für die 2D-Ansicht: ohne ungenutzte Bereiche und, falls
|
||||||
|
// ausgeblendet, ohne 2. Schicht
|
||||||
|
const sicht = document.createElement('canvas');
|
||||||
|
sicht.width = sicht.height = N;
|
||||||
|
const sichtCtx = sicht.getContext('2d');
|
||||||
|
|
||||||
|
function zeichne2D() {
|
||||||
|
const canvas = el('se-2d-canvas');
|
||||||
|
const k = Number(canvas.dataset.k) || 0;
|
||||||
|
if (!k) return;
|
||||||
|
const g = canvas.getContext('2d');
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
g.imageSmoothingEnabled = false;
|
||||||
|
g.setTransform(k, 0, 0, k, 0, 0);
|
||||||
|
g.fillStyle = dunkel() ? '#1c1c1f' : '#c9ccd1';
|
||||||
|
g.fillRect(0, 0, N, N);
|
||||||
|
g.drawImage(hintergrundFuer(ed.slim), 0, 0);
|
||||||
|
|
||||||
|
if (ed.schicht === 1 && ed.durchscheinen) {
|
||||||
|
g.globalAlpha = 0.35;
|
||||||
|
g.drawImage(schatten, 0, 0);
|
||||||
|
g.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
sichtCtx.clearRect(0, 0, N, N);
|
||||||
|
const img = sichtCtx.createImageData(N, N);
|
||||||
|
const karte = aufteilung().karte;
|
||||||
|
for (let i = 0; i < N * N; i++) {
|
||||||
|
const f = karte[i];
|
||||||
|
if (!f || (f.schicht === 1 && !ed.zeigeAussen)) continue;
|
||||||
|
const q = i * 4;
|
||||||
|
img.data[q] = ed.daten[q]; img.data[q + 1] = ed.daten[q + 1]; img.data[q + 2] = ed.daten[q + 2]; img.data[q + 3] = ed.daten[q + 3];
|
||||||
|
}
|
||||||
|
sichtCtx.putImageData(img, 0, 0);
|
||||||
|
g.drawImage(sicht, 0, 0);
|
||||||
|
|
||||||
|
// andere Schicht abdunkeln
|
||||||
|
g.fillStyle = dunkel() ? 'rgba(20,20,22,.62)' : 'rgba(40,44,52,.45)';
|
||||||
|
for (const f of aufteilung().flaechen) if (f.schicht !== ed.schicht) g.fillRect(f.x, f.y, f.w, f.h);
|
||||||
|
|
||||||
|
const px = 1 / k;
|
||||||
|
// Pixelraster
|
||||||
|
if (k >= 6) {
|
||||||
|
g.strokeStyle = dunkel() ? 'rgba(255,255,255,.07)' : 'rgba(0,0,0,.08)';
|
||||||
|
g.lineWidth = px;
|
||||||
|
g.beginPath();
|
||||||
|
for (let i = 1; i < N; i++) { g.moveTo(i, 0); g.lineTo(i, N); g.moveTo(0, i); g.lineTo(N, i); }
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
// Flächenränder
|
||||||
|
g.lineWidth = 1.5 * px;
|
||||||
|
for (const f of aufteilung().flaechen) {
|
||||||
|
g.strokeStyle = f.schicht === ed.schicht
|
||||||
|
? (dunkel() ? 'rgba(255,255,255,.45)' : 'rgba(0,0,0,.4)')
|
||||||
|
: (dunkel() ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.12)');
|
||||||
|
g.strokeRect(f.x, f.y, f.w, f.h);
|
||||||
|
}
|
||||||
|
// Mauszeiger und gespiegelter Partner
|
||||||
|
if (ed.hover) {
|
||||||
|
const [hx, hy] = ed.hover;
|
||||||
|
g.lineWidth = 2 * px;
|
||||||
|
g.strokeStyle = dunkel() ? '#ffffff' : '#000000';
|
||||||
|
g.strokeRect(hx + px, hy + px, 1 - 2 * px, 1 - 2 * px);
|
||||||
|
if (ed.spiegeln) {
|
||||||
|
const s = spiegelPixel(hx, hy);
|
||||||
|
if (s && (s[0] !== hx || s[1] !== hy)) {
|
||||||
|
g.setLineDash([2 * px, 2 * px]);
|
||||||
|
g.strokeRect(s[0] + px, s[1] + px, 1 - 2 * px, 1 - 2 * px);
|
||||||
|
g.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichne3D() {
|
||||||
|
const v = ed.viewer;
|
||||||
|
if (!v || !v.skinTexture) return;
|
||||||
|
const g = v.skinCanvas.getContext('2d');
|
||||||
|
if (v.skinCanvas.width !== N || v.skinCanvas.height !== N) { v.skinCanvas.width = N; v.skinCanvas.height = N; }
|
||||||
|
g.clearRect(0, 0, N, N);
|
||||||
|
g.drawImage(textur, 0, 0);
|
||||||
|
// Pixel unter der Maus hervorheben
|
||||||
|
if (ed.hover && ed.werkzeug !== 'pipette') {
|
||||||
|
const ziele = ed.spiegeln ? zielPixel(ed.hover[0], ed.hover[1]) : [ed.hover];
|
||||||
|
for (const [hx, hy] of ziele) {
|
||||||
|
const i = (hy * N + hx) * 4;
|
||||||
|
const hell = ed.daten[i + 3] === 0 || (ed.daten[i] + ed.daten[i + 1] + ed.daten[i + 2]) < 380;
|
||||||
|
g.fillStyle = hell ? 'rgba(255,255,255,.75)' : 'rgba(0,0,0,.55)';
|
||||||
|
g.fillRect(hx, hy, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.skinTexture.needsUpdate = true;
|
||||||
|
v.playerObject.skin.setOuterLayerVisible(ed.zeigeAussen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Größen ----------
|
||||||
|
function passeGroessenAn() {
|
||||||
|
const h2 = el('se-2d');
|
||||||
|
const c = el('se-2d-canvas');
|
||||||
|
const platz = Math.min(h2.clientWidth, h2.clientHeight) - 8;
|
||||||
|
const s = Math.max(1, Math.floor(platz / N));
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
c.style.width = c.style.height = (s * N) + 'px';
|
||||||
|
c.width = c.height = Math.round(s * N * dpr);
|
||||||
|
c.dataset.k = String((s * N * dpr) / N);
|
||||||
|
const h3 = el('se-3d');
|
||||||
|
if (ed.viewer && h3.clientWidth && h3.clientHeight) ed.viewer.setSize(h3.clientWidth, h3.clientHeight);
|
||||||
|
planeZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2D-Eingabe ----------
|
||||||
|
function texelAus2D(e) {
|
||||||
|
const c = el('se-2d-canvas');
|
||||||
|
const r = c.getBoundingClientRect();
|
||||||
|
const x = Math.floor(((e.clientX - r.left) / r.width) * N);
|
||||||
|
const y = Math.floor(((e.clientY - r.top) / r.height) * N);
|
||||||
|
return x >= 0 && y >= 0 && x < N && y < N ? [x, y] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verbinde2D() {
|
||||||
|
const c = el('se-2d-canvas');
|
||||||
|
c.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const t = texelAus2D(e);
|
||||||
|
if (!t) return;
|
||||||
|
e.preventDefault();
|
||||||
|
try { c.setPointerCapture(e.pointerId); } catch { /* egal */ }
|
||||||
|
if (e.altKey) { nimmFarbe(t[0], t[1]); return; }
|
||||||
|
beginneStrich();
|
||||||
|
ed.strich.zweiD = true;
|
||||||
|
wendeAn(t[0], t[1]);
|
||||||
|
});
|
||||||
|
c.addEventListener('pointermove', (e) => {
|
||||||
|
const t = texelAus2D(e);
|
||||||
|
if (ed.strich && t) wendeAn(t[0], t[1]);
|
||||||
|
const neu = t && flaecheBei(t[0], t[1]) ? t : null;
|
||||||
|
if (String(neu) !== String(ed.hover)) { ed.hover = neu; planeZeichnen(); }
|
||||||
|
});
|
||||||
|
const ende = () => beendeStrich();
|
||||||
|
c.addEventListener('pointerup', ende);
|
||||||
|
c.addEventListener('pointercancel', ende);
|
||||||
|
c.addEventListener('pointerleave', () => { if (!ed.strich && ed.hover) { ed.hover = null; planeZeichnen(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 3D: Strahl gegen die Figur ----------
|
||||||
|
// three.js' Raycaster wird von skinview3d nicht mitgeliefert – daher eigene
|
||||||
|
// Schnittprüfung Strahl/Dreieck (Möller–Trumbore) gegen die Quader der Figur.
|
||||||
|
function schnitt(o, d, a, b, c) {
|
||||||
|
const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
|
||||||
|
const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
|
||||||
|
const px = d.y * e2z - d.z * e2y, py = d.z * e2x - d.x * e2z, pz = d.x * e2y - d.y * e2x;
|
||||||
|
const det = e1x * px + e1y * py + e1z * pz;
|
||||||
|
if (Math.abs(det) < 1e-9) return null;
|
||||||
|
const inv = 1 / det;
|
||||||
|
const tx = o.x - a.x, ty = o.y - a.y, tz = o.z - a.z;
|
||||||
|
const u = (tx * px + ty * py + tz * pz) * inv;
|
||||||
|
if (u < 0 || u > 1) return null;
|
||||||
|
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
|
||||||
|
const v = (d.x * qx + d.y * qy + d.z * qz) * inv;
|
||||||
|
if (v < 0 || u + v > 1) return null;
|
||||||
|
const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
|
||||||
|
return t > 1e-6 ? { t, u, v } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// liefert den Texturpixel der getroffenen Schicht (0/1) oder null
|
||||||
|
function strahl(clientX, clientY, schicht) {
|
||||||
|
const v = ed.viewer;
|
||||||
|
if (!v) return null;
|
||||||
|
const r = v.canvas.getBoundingClientRect();
|
||||||
|
if (!r.width || !r.height) return null;
|
||||||
|
const cam = v.camera;
|
||||||
|
const V3 = cam.position.constructor;
|
||||||
|
cam.updateMatrixWorld();
|
||||||
|
const o = new V3().setFromMatrixPosition(cam.matrixWorld);
|
||||||
|
const nx = ((clientX - r.left) / r.width) * 2 - 1;
|
||||||
|
const ny = -((clientY - r.top) / r.height) * 2 + 1;
|
||||||
|
const d = new V3(nx, ny, 0.5).unproject(cam).sub(o).normalize();
|
||||||
|
v.playerObject.updateMatrixWorld(true);
|
||||||
|
const ecken = [new V3(), new V3(), new V3()];
|
||||||
|
let best = null;
|
||||||
|
for (const teil of v.playerObject.skin.getBodyParts()) {
|
||||||
|
if (!teil.visible) continue;
|
||||||
|
const mesh = schicht ? teil.outerLayer : teil.innerLayer;
|
||||||
|
if (!mesh || !mesh.visible) continue;
|
||||||
|
const geo = mesh.geometry;
|
||||||
|
const pos = geo.attributes.position;
|
||||||
|
const idx = geo.index;
|
||||||
|
const anzahl = idx ? idx.count : pos.count;
|
||||||
|
for (let i = 0; i < anzahl; i += 3) {
|
||||||
|
const ia = idx ? [idx.getX(i), idx.getX(i + 1), idx.getX(i + 2)] : [i, i + 1, i + 2];
|
||||||
|
for (let k = 0; k < 3; k++) ecken[k].fromBufferAttribute(pos, ia[k]).applyMatrix4(mesh.matrixWorld);
|
||||||
|
const h = schnitt(o, d, ecken[0], ecken[1], ecken[2]);
|
||||||
|
if (h && (!best || h.t < best.t)) best = { t: h.t, b: [1 - h.u - h.v, h.u, h.v], ia, uv: geo.attributes.uv };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return null;
|
||||||
|
let U = 0, W = 0;
|
||||||
|
let uMin = 1, uMax = 0, vMin = 1, vMax = 0;
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const u = best.uv.getX(best.ia[k]);
|
||||||
|
const w = best.uv.getY(best.ia[k]);
|
||||||
|
U += best.b[k] * u; W += best.b[k] * w;
|
||||||
|
uMin = Math.min(uMin, u); uMax = Math.max(uMax, u); vMin = Math.min(vMin, w); vMax = Math.max(vMax, w);
|
||||||
|
}
|
||||||
|
// Dreieck gehört zu genau einer Seitenfläche – Rundung an der Kante darf
|
||||||
|
// nicht in die Nachbarfläche rutschen
|
||||||
|
const x0 = Math.round(uMin * N), x1 = Math.round(uMax * N) - 1;
|
||||||
|
const y0 = Math.round((1 - vMax) * N), y1 = Math.round((1 - vMin) * N) - 1;
|
||||||
|
const x = Math.min(x1, Math.max(x0, Math.floor(U * N)));
|
||||||
|
const y = Math.min(y1, Math.max(y0, Math.floor((1 - W) * N)));
|
||||||
|
return { t: best.t, x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// sichtbarer Pixel für die Pipette: erst die 2. Schicht (falls dort Farbe ist)
|
||||||
|
function sichtbarerPixel(cx, cy) {
|
||||||
|
if (ed.zeigeAussen) {
|
||||||
|
const a = strahl(cx, cy, 1);
|
||||||
|
if (a && ed.daten[(a.y * N + a.x) * 4 + 3] > 0) return [a.x, a.y];
|
||||||
|
}
|
||||||
|
const b = strahl(cx, cy, 0);
|
||||||
|
return b ? [b.x, b.y] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verbinde3D() {
|
||||||
|
const huelle = el('se-3d');
|
||||||
|
const canvas = el('se-3d-canvas');
|
||||||
|
// in der Einfangphase: trifft die Maus die Figur, malen wir und die
|
||||||
|
// Drehsteuerung von skinview3d bekommt das Ereignis gar nicht erst
|
||||||
|
huelle.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.button !== 0 || !ed.viewer) return;
|
||||||
|
if (e.altKey || ed.werkzeug === 'pipette') {
|
||||||
|
const t = sichtbarerPixel(e.clientX, e.clientY);
|
||||||
|
if (!t) return;
|
||||||
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
nimmFarbe(t[0], t[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = strahl(e.clientX, e.clientY, ed.schicht);
|
||||||
|
if (!t) return;
|
||||||
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
try { canvas.setPointerCapture(e.pointerId); } catch { /* egal */ }
|
||||||
|
beginneStrich();
|
||||||
|
ed.strich.letzterBild = [e.clientX, e.clientY];
|
||||||
|
wendeAn(t.x, t.y);
|
||||||
|
}, true);
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (ed.strich) {
|
||||||
|
// Strecke seit dem letzten Ereignis in kleinen Schritten abtasten
|
||||||
|
const [ax, ay] = ed.strich.letzterBild || [e.clientX, e.clientY];
|
||||||
|
const schritte = Math.max(1, Math.ceil(Math.hypot(e.clientX - ax, e.clientY - ay) / 2));
|
||||||
|
for (let i = 1; i <= schritte; i++) {
|
||||||
|
const t = strahl(ax + ((e.clientX - ax) * i) / schritte, ay + ((e.clientY - ay) * i) / schritte, ed.schicht);
|
||||||
|
if (t) wendeAn(t.x, t.y);
|
||||||
|
}
|
||||||
|
ed.strich.letzterBild = [e.clientX, e.clientY];
|
||||||
|
}
|
||||||
|
if (e.buttons && !ed.strich) return; // dreht gerade
|
||||||
|
const t = strahl(e.clientX, e.clientY, ed.schicht);
|
||||||
|
const neu = t ? [t.x, t.y] : null;
|
||||||
|
if (String(neu) !== String(ed.hover)) { ed.hover = neu; planeZeichnen(); }
|
||||||
|
});
|
||||||
|
const ende = () => beendeStrich();
|
||||||
|
canvas.addEventListener('pointerup', ende);
|
||||||
|
canvas.addEventListener('pointercancel', ende);
|
||||||
|
canvas.addEventListener('pointerleave', () => { if (!ed.strich && ed.hover) { ed.hover = null; planeZeichnen(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function erzeugeViewer() {
|
||||||
|
if (ed.viewer || typeof skinview3d === 'undefined') {
|
||||||
|
if (typeof skinview3d === 'undefined') el('se-3d-leer').textContent = '3D-Ansicht nicht verfügbar';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = el('se-3d');
|
||||||
|
const viewer = new skinview3d.SkinViewer({
|
||||||
|
canvas: el('se-3d-canvas'),
|
||||||
|
width: h.clientWidth || 300,
|
||||||
|
height: h.clientHeight || 300,
|
||||||
|
skin: textur,
|
||||||
|
model: ed.slim ? 'slim' : 'default',
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const check = (n) => {
|
||||||
|
if ((viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) || n > 150) resolve();
|
||||||
|
else setTimeout(() => check(n + 1), 40);
|
||||||
|
};
|
||||||
|
check(0);
|
||||||
|
});
|
||||||
|
viewer.fov = 36;
|
||||||
|
viewer.zoom = 0.78;
|
||||||
|
viewer.autoRotate = false;
|
||||||
|
viewer.animation = null;
|
||||||
|
// leicht abgespreizte Arme/Beine, damit man auch an die Innenseiten kommt
|
||||||
|
const p = viewer.playerObject;
|
||||||
|
p.rotation.set(0, -0.35, 0);
|
||||||
|
p.skin.leftArm.rotation.set(0, 0, 0.2);
|
||||||
|
p.skin.rightArm.rotation.set(0, 0, -0.2);
|
||||||
|
p.skin.leftLeg.rotation.set(0, 0, 0.06);
|
||||||
|
p.skin.rightLeg.rotation.set(0, 0, -0.06);
|
||||||
|
viewer.controls.enableRotate = true;
|
||||||
|
viewer.controls.enableZoom = true;
|
||||||
|
viewer.controls.enablePan = false;
|
||||||
|
// rechte Maustaste dreht immer – auch wenn man gerade auf die Figur zeigt
|
||||||
|
viewer.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 0 };
|
||||||
|
ed.viewer = viewer;
|
||||||
|
el('se-3d-leer').classList.add('hidden');
|
||||||
|
passeGroessenAn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setzeArmform(slim) {
|
||||||
|
ed.slim = !!slim;
|
||||||
|
el('se-variante').value = ed.slim ? 'slim' : 'classic';
|
||||||
|
ed.hover = null;
|
||||||
|
if (ed.viewer) {
|
||||||
|
await ed.viewer.loadSkin(textur, { model: ed.slim ? 'slim' : 'default' });
|
||||||
|
}
|
||||||
|
texturGeaendert();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Laden / Vorlage ----------
|
||||||
|
function vorlage() {
|
||||||
|
const d = new Uint8ClampedArray(N * N * 4);
|
||||||
|
const farben = { head: '#c69c6d', body: '#2f9e9e', rightArm: '#c69c6d', leftArm: '#c69c6d', rightLeg: '#3b3f9e', leftLeg: '#3b3f9e' };
|
||||||
|
for (const f of AUFTEILUNG[ed.slim ? 1 : 0].flaechen) {
|
||||||
|
if (f.schicht !== 0) continue;
|
||||||
|
const [r, g, b] = hexZuRgb(farben[f.teil.key]);
|
||||||
|
for (let yy = f.y; yy < f.y + f.h; yy++) {
|
||||||
|
for (let xx = f.x; xx < f.x + f.w; xx++) {
|
||||||
|
const i = (yy * N + xx) * 4;
|
||||||
|
d[i] = r; d[i + 1] = g; d[i + 2] = b; d[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ladeBild(dataUri) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (img.width !== N || (img.height !== N && img.height !== N / 2)) {
|
||||||
|
reject(new Error(`Ein Skin muss 64×64 oder 64×32 Pixel groß sein (diese Datei: ${img.width}×${img.height}).`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = N;
|
||||||
|
const g = c.getContext('2d', { willReadFrequently: true });
|
||||||
|
g.drawImage(img, 0, 0);
|
||||||
|
const daten = g.getImageData(0, 0, N, N).data;
|
||||||
|
if (img.height === N / 2) {
|
||||||
|
// alte 64×32-Skins: linker Arm und linkes Bein sind dort gespiegelte
|
||||||
|
// Kopien der rechten – wie Minecraft es selbst macht
|
||||||
|
const karte = AUFTEILUNG[0].karte;
|
||||||
|
for (let i = 0; i < N * N / 2; i++) {
|
||||||
|
const f = karte[i];
|
||||||
|
if (!f || f.schicht !== 0 || (f.teil.key !== 'rightArm' && f.teil.key !== 'rightLeg')) continue;
|
||||||
|
const partner = TEIL[f.teil.spiegel];
|
||||||
|
const seite = SEITE_GEGENSTUECK[f.seite] || f.seite;
|
||||||
|
const r = seiten(partner, 0, false)[seite];
|
||||||
|
const x = i % N, y = Math.floor(i / N);
|
||||||
|
const z = ((r[1] + (y - f.y)) * N + r[0] + (r[2] - 1 - (x - f.x))) * 4;
|
||||||
|
for (let k = 0; k < 4; k++) daten[z + k] = daten[i * 4 + k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(daten);
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden.'));
|
||||||
|
img.src = dataUri;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uebernimmDaten(daten, variante, text) {
|
||||||
|
if (variante) await setzeArmform(variante === 'slim');
|
||||||
|
aenderung(() => { ed.daten = new Uint8ClampedArray(daten); });
|
||||||
|
ed.geaendert = false;
|
||||||
|
texturGeaendert();
|
||||||
|
status(text || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wennVerwerfenOk(aktion) {
|
||||||
|
if (!ed.geaendert) { aktion(); return; }
|
||||||
|
askConfirm('Änderungen verwerfen?', 'Der Skin im Editor hat ungespeicherte Änderungen. Trotzdem fortfahren?', 'Verwerfen', aktion);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeAktuellen() {
|
||||||
|
status('Aktueller Skin wird geladen …');
|
||||||
|
const res = await window.api.skinCurrentData();
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.standard) {
|
||||||
|
await uebernimmDaten(vorlage(), res.variante, 'Dein Konto nutzt den Standard-Skin – hier ist eine leere Vorlage zum Loslegen.');
|
||||||
|
} else {
|
||||||
|
status('✖ ' + (res.message || 'Skin konnte nicht geladen werden.'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await uebernimmDaten(await ladeBild(res.dataUri), res.variante, `Skin von ${res.name || 'deinem Konto'} geladen.`);
|
||||||
|
} catch (err) { status('✖ ' + err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeDatei(pfad, variante) {
|
||||||
|
const res = pfad ? await window.api.skinReadData(pfad) : await window.api.skinChoose();
|
||||||
|
if (res.canceled) return;
|
||||||
|
if (!res.ok) { status('✖ ' + (res.message || 'Datei konnte nicht geladen werden.')); return; }
|
||||||
|
try {
|
||||||
|
await uebernimmDaten(await ladeBild(res.dataUri), variante, `${(res.pfad || pfad || '').split(/[\\/]/).pop()} geladen.`);
|
||||||
|
} catch (err) { status('✖ ' + err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Speichern / Übernehmen ----------
|
||||||
|
function alsBase64() {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = N;
|
||||||
|
c.getContext('2d').putImageData(new ImageData(ed.daten, N, N), 0, 0);
|
||||||
|
return c.toDataURL('image/png').split(',')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minecraft zeigt durchsichtige Pixel der Grundschicht schwarz an
|
||||||
|
function loecherInGrundschicht() {
|
||||||
|
let n = 0;
|
||||||
|
for (const f of aufteilung().flaechen) {
|
||||||
|
if (f.schicht !== 0) continue;
|
||||||
|
for (let yy = f.y; yy < f.y + f.h; yy++) for (let xx = f.x; xx < f.x + f.w; xx++) if (ed.daten[(yy * N + xx) * 4 + 3] < 255) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loecherHinweis() {
|
||||||
|
const n = loecherInGrundschicht();
|
||||||
|
return n ? ` Hinweis: ${n} durchsichtige Pixel in der Grundschicht erscheinen im Spiel schwarz.` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichere() {
|
||||||
|
const res = await window.api.skinSaveData(alsBase64());
|
||||||
|
if (res.canceled) return;
|
||||||
|
if (!res.ok) { status('✖ ' + (res.message || 'Speichern fehlgeschlagen.')); return; }
|
||||||
|
ed.geaendert = false;
|
||||||
|
status('✔ Gespeichert: ' + res.pfad.split(/[\\/]/).pop() + '.' + loecherHinweis());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uebernimm() {
|
||||||
|
const btn = el('se-uebernehmen');
|
||||||
|
btn.disabled = true;
|
||||||
|
status('Wird hochgeladen …');
|
||||||
|
try {
|
||||||
|
const res = await window.api.skinApplyData(alsBase64(), ed.slim ? 'slim' : 'classic');
|
||||||
|
if (res.ok) {
|
||||||
|
ed.geaendert = false;
|
||||||
|
status('✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.' + loecherHinweis());
|
||||||
|
toast('Skin geändert.');
|
||||||
|
} else {
|
||||||
|
status('✖ ' + (res.message || 'Fehlgeschlagen.'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Öffnen ----------
|
||||||
|
let verdrahtet = false;
|
||||||
|
|
||||||
|
function verdrahte() {
|
||||||
|
if (verdrahtet) return;
|
||||||
|
verdrahtet = true;
|
||||||
|
document.querySelectorAll('[data-se-tool]').forEach((b) => b.addEventListener('click', () => waehleWerkzeug(b.dataset.seTool)));
|
||||||
|
document.querySelectorAll('[data-se-schicht]').forEach((b) => b.addEventListener('click', () => waehleSchicht(Number(b.dataset.seSchicht))));
|
||||||
|
document.querySelectorAll('[data-se-modus]').forEach((b) => b.addEventListener('click', () => waehleModus(b.dataset.seModus)));
|
||||||
|
el('se-farbe').addEventListener('input', () => setzeFarbe(el('se-farbe').value));
|
||||||
|
el('se-hex').addEventListener('change', () => {
|
||||||
|
let v = el('se-hex').value.trim();
|
||||||
|
if (!v.startsWith('#')) v = '#' + v;
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(v)) setzeFarbe(v);
|
||||||
|
else el('se-hex').value = ed.farbe;
|
||||||
|
});
|
||||||
|
el('se-aussen').addEventListener('change', () => {
|
||||||
|
ed.zeigeAussen = el('se-aussen').checked;
|
||||||
|
if (!ed.zeigeAussen && ed.schicht === 1) waehleSchicht(0);
|
||||||
|
planeZeichnen();
|
||||||
|
});
|
||||||
|
el('se-durch').addEventListener('change', () => { ed.durchscheinen = el('se-durch').checked; planeZeichnen(); });
|
||||||
|
el('se-spiegeln').addEventListener('change', () => { ed.spiegeln = el('se-spiegeln').checked; planeZeichnen(); });
|
||||||
|
el('se-leeren').addEventListener('click', () => {
|
||||||
|
askConfirm('2. Schicht leeren?', 'Alle Pixel der 2. Schicht werden durchsichtig. Mit Strg+Z lässt sich das rückgängig machen.', 'Leeren', () => {
|
||||||
|
const geaendert = aenderung(() => {
|
||||||
|
for (const f of aufteilung().flaechen) {
|
||||||
|
if (f.schicht !== 1) continue;
|
||||||
|
for (let yy = f.y; yy < f.y + f.h; yy++) for (let xx = f.x; xx < f.x + f.w; xx++) setzePixel(xx, yy, [0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
status(geaendert ? '2. Schicht geleert.' : 'Die 2. Schicht war schon leer.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
el('se-variante').addEventListener('change', () => setzeArmform(el('se-variante').value === 'slim'));
|
||||||
|
el('se-undo').addEventListener('click', rueckgaengig);
|
||||||
|
el('se-redo').addEventListener('click', wiederholen);
|
||||||
|
el('se-neu').addEventListener('click', () => wennVerwerfenOk(() => uebernimmDaten(vorlage(), null, 'Neue Vorlage – leg los!')));
|
||||||
|
el('se-laden').addEventListener('click', () => wennVerwerfenOk(ladeAktuellen));
|
||||||
|
el('se-oeffnen').addEventListener('click', () => wennVerwerfenOk(() => ladeDatei(null, null)));
|
||||||
|
el('se-speichern').addEventListener('click', speichere);
|
||||||
|
el('se-uebernehmen').addEventListener('click', uebernimm);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (el('modal-skineditor').classList.contains('hidden')) return;
|
||||||
|
if (!el('modal-confirm').classList.contains('hidden')) return;
|
||||||
|
const a = document.activeElement;
|
||||||
|
if (a && (['INPUT', 'TEXTAREA', 'SELECT'].includes(a.tagName) && a.type !== 'checkbox')) return;
|
||||||
|
const strg = e.ctrlKey || e.metaKey;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (strg && k === 'z' && !e.shiftKey) { e.preventDefault(); rueckgaengig(); return; }
|
||||||
|
if (strg && (k === 'y' || (k === 'z' && e.shiftKey))) { e.preventDefault(); wiederholen(); return; }
|
||||||
|
if (strg || e.altKey) return;
|
||||||
|
const werkzeug = { b: 'stift', e: 'radierer', i: 'pipette', g: 'fuellen' }[k];
|
||||||
|
if (werkzeug) { e.preventDefault(); waehleWerkzeug(werkzeug); return; }
|
||||||
|
if (k === 'm') { e.preventDefault(); el('se-spiegeln').click(); return; }
|
||||||
|
if (k === 'l') { e.preventDefault(); waehleSchicht(ed.schicht ? 0 : 1); }
|
||||||
|
});
|
||||||
|
|
||||||
|
verbinde2D();
|
||||||
|
verbinde3D();
|
||||||
|
new ResizeObserver(() => passeGroessenAn()).observe(el('se-buehne'));
|
||||||
|
new ResizeObserver(() => passeGroessenAn()).observe(el('se-mini'));
|
||||||
|
// Hell/Dunkel gewechselt -> Hintergrund neu
|
||||||
|
new MutationObserver(() => planeZeichnen()).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSkinEditor(p = {}) {
|
||||||
|
if (POPOUT !== 'skineditor') { window.api.popoutOpen('skineditor', p); return; }
|
||||||
|
verdrahte();
|
||||||
|
show('modal-skineditor');
|
||||||
|
if (!ed.bereit) {
|
||||||
|
ed.bereit = true;
|
||||||
|
el('se-farbe').value = ed.farbe;
|
||||||
|
el('se-hex').value = ed.farbe;
|
||||||
|
zeigeZuletzt();
|
||||||
|
waehleWerkzeug('stift');
|
||||||
|
waehleSchicht(0);
|
||||||
|
aktualisiereKnoepfe();
|
||||||
|
ed.daten = vorlage();
|
||||||
|
texturGeaendert();
|
||||||
|
waehleModus('2d');
|
||||||
|
await erzeugeViewer();
|
||||||
|
texturGeaendert();
|
||||||
|
}
|
||||||
|
const variante = p.variante === 'slim' || p.variante === 'classic' ? p.variante : null;
|
||||||
|
if (p.pfad) wennVerwerfenOk(() => ladeDatei(p.pfad, variante));
|
||||||
|
else if (p.quelle === 'aktuell') wennVerwerfenOk(ladeAktuellen);
|
||||||
|
else if (variante && !ed.geaendert) await setzeArmform(variante === 'slim');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openSkinEditor = openSkinEditor;
|
||||||
|
// für Tests
|
||||||
|
window.__skinEditor = { ed, strahl, spiegelPixel, seiten, AUFTEILUNG };
|
||||||
|
})();
|
||||||
@@ -87,6 +87,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
|
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
|
||||||
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
|
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
|
||||||
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
|
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
|
||||||
|
bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex)
|
||||||
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
|
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
|
||||||
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
|
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
|
||||||
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
|
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
|
||||||
|
|||||||
+69
-5
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html,body{margin:0;height:100%;font-family:var(--font);font-size:12px;color:var(--text);background:var(--win)}
|
html,body{margin:0;height:100%;font-family:var(--font);font-size:12px;color:var(--text);background:var(--win)}
|
||||||
body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:minmax(0,1fr);height:100vh;overflow:hidden}
|
||||||
.hidden{display:none!important}
|
.hidden{display:none!important}
|
||||||
.mono{font-family:var(--mono);font-size:12px}
|
.mono{font-family:var(--mono);font-size:12px}
|
||||||
.spacer{flex:1}
|
.spacer{flex:1}
|
||||||
@@ -75,9 +75,19 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
:root[data-theme="dark"] .modal-close:hover{color:#fff}
|
:root[data-theme="dark"] .modal-close:hover{color:#fff}
|
||||||
:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)}
|
:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)}
|
||||||
|
|
||||||
|
/* ---------- Titelleiste (ersetzt die weiße Windows-Leiste) ---------- */
|
||||||
|
/* Farben passend zu TITELLEISTE in main.js (dort für die Fensterknöpfe) */
|
||||||
|
:root{--titel-bg:#f0f0f0;--titel-fg:#1c1c1c}
|
||||||
|
:root[data-theme="dark"]{--titel-bg:#2d2d30;--titel-fg:#e4e4e6}
|
||||||
|
.titelleiste{display:flex;align-items:center;gap:8px;height:32px;padding:0 150px 0 10px;background:var(--titel-bg);color:var(--titel-fg);-webkit-app-region:drag;user-select:none;font-size:12px}
|
||||||
|
.titel-logo{width:16px;height:16px;flex:none}
|
||||||
|
.titel-text{white-space:nowrap}
|
||||||
|
body.fenster-inaktiv .titelleiste>*{opacity:.55}
|
||||||
|
body.is-popout>.titelleiste{display:none}
|
||||||
|
|
||||||
/* ---------- Toolbar ---------- */
|
/* ---------- Toolbar ---------- */
|
||||||
.toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;background:var(--win);border-bottom:1px solid var(--border)}
|
.toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;min-width:0;overflow:hidden;background:var(--win);border-bottom:1px solid var(--border)}
|
||||||
.tbtn{display:inline-flex;align-items:center;gap:7px;padding:6px 10px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
|
.tbtn{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;padding:6px 10px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
|
||||||
.tbtn:hover{background:#e2e6ec;border-color:var(--border2)}
|
.tbtn:hover{background:#e2e6ec;border-color:var(--border2)}
|
||||||
.tbtn:active{background:#d7dde5}
|
.tbtn:active{background:#d7dde5}
|
||||||
.tbtn svg{width:18px;height:18px;color:#4b5563}
|
.tbtn svg{width:18px;height:18px;color:#4b5563}
|
||||||
@@ -95,7 +105,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
.search-wrap input:focus{outline:none;border-color:var(--accent)}
|
.search-wrap input:focus{outline:none;border-color:var(--accent)}
|
||||||
|
|
||||||
/* ---------- Arbeitsbereich ---------- */
|
/* ---------- Arbeitsbereich ---------- */
|
||||||
.workspace{display:grid;grid-template-columns:1fr 172px;overflow:hidden;background:var(--area)}
|
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)}
|
||||||
.list-area{position:relative;overflow:hidden;min-height:0}
|
.list-area{position:relative;overflow:hidden;min-height:0}
|
||||||
/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */
|
/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */
|
||||||
.list-area::after{
|
.list-area::after{
|
||||||
@@ -160,7 +170,7 @@ body.file-drop-active::after{
|
|||||||
.empty p{max-width:340px;margin:0 0 8px}
|
.empty p{max-width:340px;margin:0 0 8px}
|
||||||
|
|
||||||
/* ---------- Statusleiste (zwei Zeilen) ---------- */
|
/* ---------- Statusleiste (zwei Zeilen) ---------- */
|
||||||
.statusbar{display:flex;flex-direction:column;background:var(--win);border-top:1px solid var(--border)}
|
.statusbar{display:flex;flex-direction:column;min-width:0;background:var(--win);border-top:1px solid var(--border)}
|
||||||
.st-row{display:flex;justify-content:space-between;padding:3px 10px;font-size:11px;color:#333}
|
.st-row{display:flex;justify-content:space-between;padding:3px 10px;font-size:11px;color:#333}
|
||||||
.st-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444}
|
.st-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444}
|
||||||
.auth-warn{
|
.auth-warn{
|
||||||
@@ -640,3 +650,57 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
|||||||
::-webkit-scrollbar{width:14px;height:14px}
|
::-webkit-scrollbar{width:14px;height:14px}
|
||||||
::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px}
|
::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px}
|
||||||
::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
|
::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
|
||||||
|
|
||||||
|
/* ===================== Skin-Editor ===================== */
|
||||||
|
#modal-skineditor .se-karte{width:1100px;max-width:calc(100vw - 32px);height:760px;max-height:calc(100vh - 40px)}
|
||||||
|
.se-modus{display:inline-flex;border:1px solid var(--border);border-radius:4px;overflow:hidden;background:var(--bar)}
|
||||||
|
.se-modus-btn{padding:5px 14px;border:none;background:none;color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
|
||||||
|
.se-modus-btn+.se-modus-btn{border-left:1px solid var(--border)}
|
||||||
|
.se-modus-btn:hover{color:var(--text)}
|
||||||
|
.se-modus-btn.on{background:var(--accent);color:#fff;font-weight:600}
|
||||||
|
.se-body{display:grid;grid-template-columns:200px minmax(0,1fr) 270px;gap:14px;flex:1 1 auto;min-height:0;overflow:hidden;padding:12px 14px}
|
||||||
|
.se-werkzeuge{display:flex;flex-direction:column;gap:14px;overflow:auto;min-height:0;padding-right:2px}
|
||||||
|
.se-gruppe{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.se-titel{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dim)}
|
||||||
|
.se-tools{display:grid;grid-template-columns:1fr 1fr;gap:4px}
|
||||||
|
.se-tool{display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px 4px;border:1px solid var(--border);border-radius:4px;background:var(--bar);color:var(--text);font:inherit;font-size:11px;cursor:pointer}
|
||||||
|
.se-tool svg{width:18px;height:18px}
|
||||||
|
.se-tool:hover:not(:disabled){border-color:var(--sel-br)}
|
||||||
|
.se-tool.on{background:var(--sel);border-color:var(--sel-br);font-weight:600}
|
||||||
|
.se-tool:disabled{opacity:.4;cursor:default}
|
||||||
|
.se-farbzeile{display:flex;gap:6px;align-items:center}
|
||||||
|
.se-farbzeile input[type=color]{width:42px;height:30px;padding:0 2px;border:1px solid var(--border);border-radius:4px;background:var(--bar);cursor:pointer}
|
||||||
|
.se-farbzeile input[type=text]{flex:1;min-width:0;height:30px;padding:0 8px;border:1px solid var(--border);border-radius:4px;font-family:var(--mono);font-size:12px;background:#fff;color:var(--text)}
|
||||||
|
:root[data-theme="dark"] .se-farbzeile input[type=text]{background:#1e1e1e}
|
||||||
|
.se-zuletzt{display:grid;grid-template-columns:repeat(8,1fr);gap:3px}
|
||||||
|
.se-farbfeld{aspect-ratio:1;border:1px solid rgba(0,0,0,.25);border-radius:3px;cursor:pointer;padding:0}
|
||||||
|
.se-farbfeld:hover{transform:scale(1.12)}
|
||||||
|
.se-farbfeld.on{outline:2px solid var(--sel-br);outline-offset:1px}
|
||||||
|
.se-ebenen{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--border);border-radius:4px;overflow:hidden}
|
||||||
|
.se-ebene{padding:6px 4px;border:none;background:var(--bar);color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
|
||||||
|
.se-ebene+.se-ebene{border-left:1px solid var(--border)}
|
||||||
|
.se-ebene.on{background:var(--sel);color:var(--text);font-weight:600}
|
||||||
|
.se-check{display:flex;align-items:center;gap:6px;cursor:pointer}
|
||||||
|
.se-check input{margin:0}
|
||||||
|
.se-check:has(input:disabled){opacity:.5;cursor:default}
|
||||||
|
.se-feld{display:flex;flex-direction:column;gap:3px}
|
||||||
|
.se-feld select{height:28px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font:inherit}
|
||||||
|
:root[data-theme="dark"] .se-feld select{background:#1e1e1e}
|
||||||
|
.se-undo{display:grid;grid-template-columns:1fr 1fr;gap:4px}
|
||||||
|
.se-undo .btn{justify-content:center}
|
||||||
|
.se-gruppe>.btn-mini{align-self:flex-start}
|
||||||
|
.se-mitte{display:flex;flex-direction:column;gap:6px;min-width:0;min-height:0}
|
||||||
|
.se-buehne{flex:1 1 auto;min-height:0;position:relative;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
|
||||||
|
.se-info{min-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.se-seite{display:flex;flex-direction:column;gap:6px;min-height:0}
|
||||||
|
.se-mini{position:relative;height:320px;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
|
||||||
|
.se-tipp{margin:4px 0 0;line-height:1.45}
|
||||||
|
.se-ansicht{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
|
||||||
|
.se-ansicht canvas{display:block}
|
||||||
|
.se-2d canvas{cursor:crosshair;border-radius:2px}
|
||||||
|
.se-3d canvas{cursor:grab}
|
||||||
|
.se-3d canvas:active{cursor:grabbing}
|
||||||
|
.se-2d[data-werkzeug="pipette"] canvas{cursor:copy}
|
||||||
|
.se-3d-leer{position:absolute}
|
||||||
|
.se-fuss{align-items:center}
|
||||||
|
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|||||||
Reference in New Issue
Block a user