'use strict'; /* * skineditor.js – Pixel-Editor für Minecraft-Skins und Umhänge * ------------------------------------------------------------- * 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. * * Ein Umhang ist eine Textur im Verhältnis 2:1 – normal 64×32, als HD bis * 1024×512. Dieselbe Datei trägt auch die Elytra. Eigene Umhänge landen auf * dem AeroMC-Umhang-Server (Mojang nimmt keine eigenen Umhänge an). * * Gemalt wird auf der aufgeklappten Vorlage (2D) oder direkt auf der drehbaren * Figur (3D); die jeweils andere Ansicht steht klein rechts und ist ebenfalls * bemalbar. Skin und Umhang sind getrennte "Dokumente" mit eigener Größe und * eigenem Rückgängig-Verlauf – Werkzeuge, Farben und Spiegeln teilen sie sich. * * Läuft im Popup-Fenster "skineditor" und nutzt die Helfer aus renderer.js * (el, show, toast, askConfirm, POPOUT). */ (() => { 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 }; }); // ---------- Umhang: Flächen in Einheiten der 64×32-Vorlage (×k für HD) ---------- const UMHANG_STUFEN = [64, 128, 256, 512, 1024]; const UMHANG_FLAECHEN = [ { name: 'Außenseite', x: 1, y: 1, w: 10, h: 16 }, { name: 'Innenseite', x: 12, y: 1, w: 10, h: 16 }, { name: 'Oberkante', x: 1, y: 0, w: 10, h: 1 }, { name: 'Unterkante', x: 11, y: 0, w: 10, h: 1 }, { name: 'Rechte Kante', x: 0, y: 1, w: 1, h: 16, spiegel: 'Linke Kante' }, { name: 'Linke Kante', x: 11, y: 1, w: 1, h: 16, spiegel: 'Rechte Kante' }, // die Elytra nutzt dieselbe Datei: ein Flügel als Quader 10×20×2 ab (22, 0) { name: 'Elytra oben', x: 24, y: 0, w: 10, h: 2, elytra: true }, { name: 'Elytra unten', x: 34, y: 0, w: 10, h: 2, elytra: true }, { name: 'Elytra rechte Kante', x: 22, y: 2, w: 2, h: 20, elytra: true, spiegel: 'Elytra linke Kante' }, { name: 'Elytra außen', x: 24, y: 2, w: 10, h: 20, elytra: true }, { name: 'Elytra linke Kante', x: 34, y: 2, w: 2, h: 20, elytra: true, spiegel: 'Elytra rechte Kante' }, { name: 'Elytra innen', x: 36, y: 2, w: 10, h: 20, elytra: true }, ]; const umhangAufteilungen = new Map(); // Breite -> { flaechen, karte } function umhangLayout(w) { if (umhangAufteilungen.has(w)) return umhangAufteilungen.get(w); const k = w / 64; const h = w / 2; const flaechen = UMHANG_FLAECHEN.map((f) => ({ ...f, umhang: true, x: f.x * k, y: f.y * k, w: f.w * k, h: f.h * k })); const karte = new Int16Array(w * h).fill(-1); flaechen.forEach((f, i) => { for (let yy = 0; yy < f.h; yy++) karte.fill(i, (f.y + yy) * w + f.x, (f.y + yy) * w + f.x + f.w); }); const ergebnis = { flaechen, karte }; umhangAufteilungen.set(w, ergebnis); return ergebnis; } // ---------- Dokumente ---------- function neuesDok(art, w, h) { return { art, w, h, daten: new Uint8ClampedArray(w * h * 4), undo: [], redo: [], geaendert: false }; } const skinDok = neuesDok('skin', N, N); const umhangDok = neuesDok('umhang', 64, 32); // Rückgängig-Verlauf je Dokument auf ~64 MB begrenzen (HD-Umhänge sind groß) const maxUndo = (d) => Math.max(10, Math.min(100, Math.floor(64e6 / (d.w * d.h * 4)))); const schnappschuss = (d) => ({ w: d.w, h: d.h, daten: d.daten.slice() }); // ---------- Zustand ---------- const ed = { slim: false, werkzeug: 'stift', vorPipette: 'stift', farbe: '#3a6ea5', schicht: 0, zeigeAussen: true, durchscheinen: true, spiegeln: false, pinsel: 1, elytra: false, modus: '2d', hover: null, strich: null, // { dok, snap, letzter: [x,y] | null, letzterBild: [cx,cy] | null, geaendert } viewer: null, bereit: false, zeichnenGeplant: false, }; const dok = () => (ed.modus === 'umhang' ? umhangDok : skinDok); const textur = document.createElement('canvas'); textur.width = textur.height = N; const texturCtx = textur.getContext('2d', { willReadFrequently: true }); const umhangTextur = document.createElement('canvas'); umhangTextur.width = 64; umhangTextur.height = 32; // 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 dunkel = () => document.documentElement.getAttribute('data-theme') === 'dark'; function flaecheBei(x, y) { const d = dok(); if (x < 0 || y < 0 || x >= d.w || y >= d.h) return null; if (d.art === 'umhang') { const L = umhangLayout(d.w); const i = L.karte[y * d.w + x]; return i < 0 ? null : L.flaechen[i]; } return aufteilung().karte[y * N + x]; } const darfMalen = (f) => !!f && (f.umhang || f.schicht === ed.schicht); 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 Seite: beim Skin rechter ↔ linker Arm/Bein und // rechte ↔ linke Seite, beim Umhang innerhalb der Fläche bzw. Kante ↔ Kante function spiegelPixel(x, y) { const f = flaecheBei(x, y); if (!f) return null; const fx = x - f.x; const fy = y - f.y; if (f.umhang) { const p = f.spiegel ? umhangLayout(dok().w).flaechen.find((g) => g.name === f.spiegel) : f; return [p.x + (p.w - 1 - fx), p.y + fy]; } const partner = TEIL[f.teil.spiegel]; const seite = SEITE_GEGENSTUECK[f.seite] || f.seite; const r = seiten(partner, f.schicht, ed.slim)[seite]; return [r[0] + (r[2] - 1 - fx), r[1] + fy]; } // ---------- Daten ändern ---------- function setzePixel(x, y, rgba) { const d = dok(); const i = (y * d.w + x) * 4; const p = d.daten; if (p[i] === rgba[0] && p[i + 1] === rgba[1] && p[i + 2] === rgba[2] && p[i + 3] === rgba[3]) return false; p[i] = rgba[0]; p[i + 1] = rgba[1]; p[i + 2] = rgba[2]; p[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; } // Pinsel (nur beim Umhang größer als 1): Quadrat um den Pixel, beschnitten auf die Fläche function pinselPunkte(x, y, f) { const p = dok().art === 'umhang' ? ed.pinsel : 1; if (p <= 1) return [[x, y]]; const o = Math.floor((p - 1) / 2); const punkte = []; for (let yy = Math.max(f.y, y - o); yy < Math.min(f.y + f.h, y - o + p); yy++) { for (let xx = Math.max(f.x, x - o); xx < Math.min(f.x + f.w, x - o + p); xx++) punkte.push([xx, yy]); } return punkte; } function malFarbe() { if (ed.werkzeug === 'radierer') return [0, 0, 0, 0]; return [...hexZuRgb(ed.farbe), 255]; } // Einen Pixel (samt Pinsel und Spiegelung) mit Stift oder Radierer bearbeiten function malePixel(x, y) { const f = flaecheBei(x, y); if (!darfMalen(f)) return false; let geaendert = false; const rgba = malFarbe(); for (const [bx, by] of pinselPunkte(x, y, f)) { for (const [px, py] of zielPixel(bx, by)) 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 (!darfMalen(f)) return false; const d = dok(); const p = d.daten; const i0 = (y * d.w + x) * 4; const alt = [p[i0], p[i0 + 1], p[i0 + 2], p[i0 + 3]]; const neu = malFarbe(); if (alt.every((v, k) => v === neu[k])) return false; const gleich = (px, py) => { const i = (py * d.w + px) * 4; return p[i] === alt[0] && p[i + 1] === alt[1] && p[i + 2] === alt[2] && p[i + 3] === alt[3]; }; const gesehen = new Uint8Array(d.w * d.h); const stapel = [[x, y]]; const treffer = []; while (stapel.length) { const [px, py] = stapel.pop(); if (px < f.x || py < f.y || px >= f.x + f.w || py >= f.y + f.h) continue; const k = py * d.w + px; if (gesehen[k]) continue; gesehen[k] = 1; 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 d = dok(); const i = (y * d.w + x) * 4; if (d.daten[i + 3] === 0) return false; setzeFarbe(rgbZuHex(d.daten[i], d.daten[i + 1], d.daten[i + 2])); return true; } // ---------- Striche, Rückgängig ---------- function merkeRueckgaengig(d, vorher) { d.undo.push(vorher); while (d.undo.length > maxUndo(d)) d.undo.shift(); d.redo = []; d.geaendert = true; } function beginneStrich() { ed.strich = { dok: dok(), snap: schnappschuss(dok()), letzter: null, letzterBild: null, geaendert: false }; } function beendeStrich() { const s = ed.strich; ed.strich = null; if (!s || !s.geaendert) return; merkeRueckgaengig(s.dok, s.snap); 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 || s.dok !== dok()) 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 stelleHer(d, sn) { const andereGroesse = sn.w !== d.w || sn.h !== d.h; d.w = sn.w; d.h = sn.h; d.daten = sn.daten; d.geaendert = true; if (andereGroesse) nachGroessenwechsel(); texturGeaendert(); aktualisiereKnoepfe(); } function rueckgaengig() { const d = dok(); if (!d.undo.length || ed.strich) return; d.redo.push(schnappschuss(d)); stelleHer(d, d.undo.pop()); } function wiederholen() { const d = dok(); if (!d.redo.length || ed.strich) return; d.undo.push(schnappschuss(d)); stelleHer(d, d.redo.pop()); } // ganze Änderung (Laden, Leeren, Größe …) als ein Rückgängig-Schritt function aenderung(fn) { const d = dok(); const vorher = schnappschuss(d); fn(); const gleich = vorher.w === d.w && vorher.h === d.h && vorher.daten.every((v, i) => v === d.daten[i]); if (gleich) return false; merkeRueckgaengig(d, vorher); if (vorher.w !== d.w) nachGroessenwechsel(); 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 ---------- const radiererGesperrt = () => dok().art === 'skin' && ed.schicht === 0; function waehleWerkzeug(w) { if (w === 'radierer' && radiererGesperrt()) 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'), el('se-u2d')]) h.dataset.werkzeug = w; } function aktualisiereRadierer() { const radierer = document.querySelector('[data-se-tool="radierer"]'); const gesperrt = radiererGesperrt(); radierer.disabled = gesperrt; radierer.title = gesperrt ? 'Radierer – nur in der 2. Schicht (die Grundschicht darf keine Löcher haben)' : 'Radierer (E) – macht Pixel durchsichtig'; if (gesperrt && ed.werkzeug === 'radierer') waehleWerkzeug('stift'); } 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; } aktualisiereRadierer(); el('se-durch').disabled = s === 0; ed.hover = null; texturGeaendert(); } const MINI_TITEL = { '2d': '3D-Vorschau', '3d': 'Vorlage', umhang: '3D-Vorschau (Rücken)' }; const TIPP = { skin: 'Beide Ansichten sind bemalbar. Die 2. Schicht liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.', umhang: 'Auch die Figur ist bemalbar. Eigene Umhänge liegen auf dem AeroMC-Umhang-Server – im Spiel sehen sie Spieler mit dem AeroMC-Umhang-Mod. Die Datei trägt auch die Elytra.', }; function waehleModus(m) { const warUmhang = ed.modus === 'umhang'; if (ed.strich) beendeStrich(); ed.modus = m; ed.hover = null; 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'); const ablage = el('se-ablage'); if (m === 'umhang') { gross.appendChild(el('se-u2d')); klein.appendChild(el('se-3d')); ablage.appendChild(el('se-2d')); } else { gross.appendChild(m === '2d' ? el('se-2d') : el('se-3d')); klein.appendChild(m === '2d' ? el('se-3d') : el('se-2d')); ablage.appendChild(el('se-u2d')); } el('se-mini-titel').textContent = MINI_TITEL[m]; const umhang = m === 'umhang'; el('se-gruppe-ebene').classList.toggle('hidden', umhang); el('se-gruppe-umhang').classList.toggle('hidden', !umhang); el('se-laden').textContent = umhang ? 'Aktuellen Umhang laden' : 'Aktuellen Skin laden'; el('se-laden').title = umhang ? 'Deinen AeroMC-Umhang bzw. den getragenen offiziellen Umhang laden' : 'Den Skin deines angemeldeten Kontos laden'; el('se-neu').title = umhang ? 'Mit einer einfachen Umhang-Vorlage beginnen' : 'Mit einer leeren Vorlage beginnen'; el('se-uebernehmen').textContent = umhang ? 'Als Umhang übernehmen' : 'Als Skin übernehmen'; el('se-tipp').innerHTML = umhang ? TIPP.umhang : TIPP.skin; aktualisiereRadierer(); aktualisiereKnoepfe(); if (warUmhang !== umhang) zeigeUmhangAufFigur(umhang); texturGeaendert(); passeGroessenAn(); } function aktualisiereKnoepfe() { el('se-undo').disabled = !dok().undo.length; el('se-redo').disabled = !dok().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.umhang ? `Umhang · ${f.name} · Pixel ${h[0]}, ${h[1]}` : `${f.teil.name} · ${SEITE_NAME[f.seite]} · ${SCHICHT_NAME[f.schicht]} · Pixel ${h[0]}, ${h[1]}`; return; } if (ed.modus === 'umhang') { info.textContent = `${umhangDok.w}×${umhangDok.h} · Linke Maustaste malt · Mausrad zoomt · rechte Maustaste verschiebt · Alt+Klick nimmt Farbe auf`; } else { 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() { if (dok().art === 'umhang') { if (umhangTextur.width !== umhangDok.w || umhangTextur.height !== umhangDok.h) { umhangTextur.width = umhangDok.w; umhangTextur.height = umhangDok.h; } umhangTextur.getContext('2d').putImageData(new ImageData(umhangDok.daten, umhangDok.w, umhangDok.h), 0, 0); planeZeichnen(); return; } texturCtx.putImageData(new ImageData(skinDok.daten, N, N), 0, 0); // Schatten: Grundschicht-Pixel an die Stelle der zugehörigen 2. Schicht legen const s = schattenCtx.createImageData(N, N); const p = skinDok.daten; 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] = p[q]; s.data[z + 1] = p[q + 1]; s.data[z + 2] = p[q + 2]; s.data[z + 3] = p[q + 3]; } } } schattenCtx.putImageData(s, 0, 0); planeZeichnen(); } function planeZeichnen() { if (ed.zeichnenGeplant) return; ed.zeichnenGeplant = true; requestAnimationFrame(() => { ed.zeichnenGeplant = false; if (ed.modus === 'umhang') zeichneUmhang2D(); else 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; const p = skinDok.daten; 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] = p[q]; img.data[q + 1] = p[q + 1]; img.data[q + 2] = p[q + 2]; img.data[q + 3] = p[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([]); } } } } // ---------- Umhang-Vorlage (2D) mit Zoom und Verschieben ---------- const ansicht = { zoom: 1, mx: 0.5, my: 0.5 }; // Mittelpunkt als Anteil der Textur let karoMuster = null; let karoDunkel = null; function umhangTransform() { const c = el('se-u2d-canvas'); const d = umhangDok; const basis = Math.min(c.width / d.w, c.height / d.h) * 0.96; const s = basis * ansicht.zoom; return { s, ox: c.width / 2 - ansicht.mx * d.w * s, oy: c.height / 2 - ansicht.my * d.h * s }; } function einpassen() { ansicht.zoom = 1; ansicht.mx = 0.5; ansicht.my = 0.5; planeZeichnen(); } function zeichneUmhang2D() { const c = el('se-u2d-canvas'); if (!c.width || !c.height) return; const g = c.getContext('2d'); const d = umhangDok; const L = umhangLayout(d.w); const dpr = window.devicePixelRatio || 1; const { s, ox, oy } = umhangTransform(); g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, c.width, c.height); g.fillStyle = dunkel() ? '#1c1c1f' : '#c9ccd1'; g.fillRect(ox, oy, d.w * s, d.h * s); // Karomuster in den Flächen (Durchsichtiges sichtbar machen) if (!karoMuster || karoDunkel !== dunkel()) { const kc = document.createElement('canvas'); kc.width = kc.height = 16; const kg = kc.getContext('2d'); const [a, b] = dunkel() ? ['#3a3a40', '#303036'] : ['#ececee', '#dadbdf']; kg.fillStyle = a; kg.fillRect(0, 0, 16, 16); kg.fillStyle = b; kg.fillRect(0, 0, 8, 8); kg.fillRect(8, 8, 8, 8); karoMuster = g.createPattern(kc, 'repeat'); karoDunkel = dunkel(); } g.fillStyle = karoMuster; for (const f of L.flaechen) g.fillRect(ox + f.x * s, oy + f.y * s, f.w * s, f.h * s); g.imageSmoothingEnabled = false; g.drawImage(umhangTextur, ox, oy, d.w * s, d.h * s); // Elytra-Bereich leicht abgedunkelt, solange der Umhang gezeigt wird if (!ed.elytra) { g.fillStyle = dunkel() ? 'rgba(20,20,22,.35)' : 'rgba(40,44,52,.2)'; for (const f of L.flaechen) if (f.elytra) g.fillRect(ox + f.x * s, oy + f.y * s, f.w * s, f.h * s); } // Pixelraster nur, wenn die Pixel groß genug sind (sichtbarer Ausschnitt) if (s >= 6 * dpr) { const x0 = Math.max(0, Math.floor(-ox / s)), x1 = Math.min(d.w, Math.ceil((c.width - ox) / s)); const y0 = Math.max(0, Math.floor(-oy / s)), y1 = Math.min(d.h, Math.ceil((c.height - oy) / s)); g.strokeStyle = dunkel() ? 'rgba(255,255,255,.07)' : 'rgba(0,0,0,.08)'; g.lineWidth = 1; g.beginPath(); for (let i = x0; i <= x1; i++) { const x = Math.round(ox + i * s) + 0.5; g.moveTo(x, oy + y0 * s); g.lineTo(x, oy + y1 * s); } for (let i = y0; i <= y1; i++) { const y = Math.round(oy + i * s) + 0.5; g.moveTo(ox + x0 * s, y); g.lineTo(ox + x1 * s, y); } g.stroke(); } // Flächenränder und Beschriftung g.lineWidth = 1.5 * dpr; g.font = `${Math.round(11 * dpr)}px "Segoe UI", sans-serif`; g.textBaseline = 'top'; for (const f of L.flaechen) { g.strokeStyle = dunkel() ? 'rgba(255,255,255,.45)' : 'rgba(0,0,0,.4)'; g.strokeRect(ox + f.x * s, oy + f.y * s, f.w * s, f.h * s); if (f.w * s > 80 * dpr && f.h * s > 24 * dpr) { g.fillStyle = dunkel() ? 'rgba(255,255,255,.55)' : 'rgba(0,0,0,.5)'; g.fillText(f.name, ox + f.x * s + 4 * dpr, oy + f.y * s + 3 * dpr); } } // Pinsel unter der Maus (+ gespiegelt) if (ed.hover) { const f = flaecheBei(ed.hover[0], ed.hover[1]); if (f) { g.lineWidth = 2 * dpr; g.strokeStyle = dunkel() ? '#ffffff' : '#000000'; const rahmen = (punkte) => { let ax = Infinity, ay = Infinity, bx = -Infinity, by = -Infinity; for (const [x, y] of punkte) { ax = Math.min(ax, x); ay = Math.min(ay, y); bx = Math.max(bx, x); by = Math.max(by, y); } if (ax !== Infinity) g.strokeRect(ox + ax * s, oy + ay * s, (bx - ax + 1) * s, (by - ay + 1) * s); }; const punkte = ed.werkzeug === 'pipette' || ed.werkzeug === 'fuellen' ? [ed.hover] : pinselPunkte(ed.hover[0], ed.hover[1], f); rahmen(punkte); if (ed.spiegeln) { const gesp = punkte.map(([x, y]) => spiegelPixel(x, y)).filter(Boolean); g.setLineDash([4 * dpr, 4 * dpr]); rahmen(gesp); g.setLineDash([]); } } } } function zeichne3D() { const v = ed.viewer; if (!v) return; if (ed.modus === 'umhang') { if (!v.capeTexture || v.capeCanvas.width !== umhangDok.w || v.capeCanvas.height !== umhangDok.h) return; const g = v.capeCanvas.getContext('2d'); g.clearRect(0, 0, umhangDok.w, umhangDok.h); g.drawImage(umhangTextur, 0, 0); if (ed.hover && ed.werkzeug !== 'pipette') { const f = flaecheBei(ed.hover[0], ed.hover[1]); if (f) { const punkte = ed.werkzeug === 'fuellen' ? [ed.hover] : pinselPunkte(ed.hover[0], ed.hover[1], f); g.fillStyle = 'rgba(255,255,255,.6)'; for (const [x, y] of punkte) for (const [px, py] of zielPixel(x, y)) g.fillRect(px, py, 1, 1); } } v.capeTexture.needsUpdate = true; return; } if (!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 p = skinDok.daten; const hell = p[i + 3] === 0 || (p[i] + p[i + 1] + p[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 dpr = window.devicePixelRatio || 1; const h2 = el('se-2d'); if (h2.clientWidth) { const c = el('se-2d-canvas'); const platz = Math.min(h2.clientWidth, h2.clientHeight) - 8; const s = Math.max(1, Math.floor(platz / N)); 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 hu = el('se-u2d'); if (hu.clientWidth) { const c = el('se-u2d-canvas'); c.style.width = hu.clientWidth + 'px'; c.style.height = hu.clientHeight + 'px'; c.width = Math.round(hu.clientWidth * dpr); c.height = Math.round(hu.clientHeight * dpr); } const h3 = el('se-3d'); if (ed.viewer && h3.clientWidth && h3.clientHeight) ed.viewer.setSize(h3.clientWidth, h3.clientHeight); planeZeichnen(); } // ---------- 2D-Eingabe (Skin) ---------- 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 || ed.modus === 'umhang') 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) => { if (ed.modus === 'umhang') return; 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(); } }); } // ---------- 2D-Eingabe (Umhang): malen, zoomen, verschieben ---------- function bildpunkt(e) { const c = el('se-u2d-canvas'); const r = c.getBoundingClientRect(); const dpr = c.width / (r.width || 1); return [(e.clientX - r.left) * dpr, (e.clientY - r.top) * dpr]; } function texelAusUmhang(e) { const [px, py] = bildpunkt(e); const { s, ox, oy } = umhangTransform(); const x = Math.floor((px - ox) / s); const y = Math.floor((py - oy) / s); return x >= 0 && y >= 0 && x < umhangDok.w && y < umhangDok.h ? [x, y] : null; } function verbindeUmhang2D() { const c = el('se-u2d-canvas'); let schieben = null; // { x, y, mx, my } c.addEventListener('contextmenu', (e) => e.preventDefault()); c.addEventListener('pointerdown', (e) => { e.preventDefault(); try { c.setPointerCapture(e.pointerId); } catch { /* egal */ } if (e.button === 1 || e.button === 2) { const [px, py] = bildpunkt(e); schieben = { x: px, y: py, mx: ansicht.mx, my: ansicht.my }; return; } if (e.button !== 0) return; const t = texelAusUmhang(e); if (!t) return; if (e.altKey) { nimmFarbe(t[0], t[1]); return; } beginneStrich(); ed.strich.zweiD = true; wendeAn(t[0], t[1]); }); c.addEventListener('pointermove', (e) => { if (schieben) { const [px, py] = bildpunkt(e); const { s } = umhangTransform(); ansicht.mx = Math.min(1, Math.max(0, schieben.mx - (px - schieben.x) / (umhangDok.w * s))); ansicht.my = Math.min(1, Math.max(0, schieben.my - (py - schieben.y) / (umhangDok.h * s))); planeZeichnen(); return; } const t = texelAusUmhang(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 = () => { schieben = null; beendeStrich(); }; c.addEventListener('pointerup', ende); c.addEventListener('pointercancel', ende); c.addEventListener('pointerleave', () => { if (!ed.strich && ed.hover) { ed.hover = null; planeZeichnen(); } }); // Mausrad zoomt um die Mausposition c.addEventListener('wheel', (e) => { e.preventDefault(); const [px, py] = bildpunkt(e); const vorher = umhangTransform(); const tx = (px - vorher.ox) / vorher.s; const ty = (py - vorher.oy) / vorher.s; const groesster = Math.max(8, umhangDok.w / 8); ansicht.zoom = Math.min(groesster, Math.max(1, ansicht.zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2))); const { s } = umhangTransform(); ansicht.mx = Math.min(1, Math.max(0, (c.width / 2 + tx * s - px) / (umhangDok.w * s))); ansicht.my = Math.min(1, Math.max(0, (c.height / 2 + ty * s - py) / (umhangDok.h * s))); planeZeichnen(); }, { passive: false }); } // ---------- 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; } function sichtbareMeshes(objekt) { const liste = []; objekt.traverse((m) => { if (m.isMesh) liste.push(m); }); return liste.filter((m) => { for (let o = m; o; o = o.parent) if (o.visible === false) return false; return true; }); } /* * Texturpixel unter dem Mauspunkt. ziel: 0/1 = Skin-Schicht, 'umhang' = Umhang * bzw. Elytra (die Figur selbst verdeckt dabei, man malt nicht durch sie hindurch). */ function strahl(clientX, clientY, ziel) { 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 teile = v.playerObject.skin.getBodyParts().filter((t) => t.visible); let ziele; let verdecker = []; if (ziel === 'umhang') { const p = v.playerObject; ziele = sichtbareMeshes(p.backEquipment === 'elytra' ? p.elytra : p.cape); verdecker = teile.map((t) => t.innerLayer).filter((m) => m && m.visible); } else { ziele = teile.map((t) => (ziel ? t.outerLayer : t.innerLayer)).filter((m) => m && m.visible); } const ecken = [new V3(), new V3(), new V3()]; let best = null; const pruefe = (mesh, istZiel) => { 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, istZiel }; } }; for (const m of ziele) pruefe(m, true); for (const m of verdecker) pruefe(m, false); if (!best || !best.istZiel) return null; const W_ = ziel === 'umhang' ? umhangDok.w : N; const H_ = ziel === 'umhang' ? umhangDok.h : N; 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 * W_), x1 = Math.round(uMax * W_) - 1; const y0 = Math.round((1 - vMax) * H_), y1 = Math.round((1 - vMin) * H_) - 1; const x = Math.min(x1, Math.max(x0, Math.floor(U * W_))); const y = Math.min(y1, Math.max(y0, Math.floor((1 - W) * H_))); return { t: best.t, x, y }; } const zielAktuell = () => (ed.modus === 'umhang' ? 'umhang' : ed.schicht); // sichtbarer Pixel für die Pipette: erst die 2. Schicht (falls dort Farbe ist) function sichtbarerPixel(cx, cy) { if (ed.modus === 'umhang') { const u = strahl(cx, cy, 'umhang'); return u ? [u.x, u.y] : null; } if (ed.zeigeAussen) { const a = strahl(cx, cy, 1); if (a && skinDok.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, zielAktuell()); 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, zielAktuell()); 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, zielAktuell()); 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'); if (ed.modus === 'umhang') await zeigeUmhangAufFigur(true); passeGroessenAn(); } // Umhang auf der Figur zeigen (Rücken zur Kamera) bzw. wieder abnehmen async function zeigeUmhangAufFigur(an) { const v = ed.viewer; if (!v) return; const p = v.playerObject; if (an) { texturGeaendert(); await v.loadCape(umhangTextur, { backEquipment: ed.elytra ? 'elytra' : 'cape' }); p.rotation.set(0, Math.PI - 0.5, 0); } else { v.resetCape(); p.rotation.set(0, -0.35, 0); } planeZeichnen(); } // nach einer Größenänderung des Umhangs: Auswahl, Ansicht und Figur anpassen function nachGroessenwechsel() { el('se-u-groesse').value = String(umhangDok.w); einpassen(); if (ed.viewer && ed.modus === 'umhang') zeigeUmhangAufFigur(true); } 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(); } // ---------- Vorlagen ---------- 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 umhangVorlage(w) { const h = w / 2; const d = new Uint8ClampedArray(w * h * 4); const farbe = (f) => (f.name.includes('außen') || f.name === 'Außenseite' ? [176, 46, 38] : f.name.includes('innen') || f.name === 'Innenseite' ? [122, 31, 26] : [142, 37, 31]); for (const f of umhangLayout(w).flaechen) { const [r, g, b] = farbe(f); 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 * w + xx) * 4; d[i] = r; d[i + 1] = g; d[i + 2] = b; d[i + 3] = 255; } } } return d; } // ---------- Laden ---------- function ladeImg(dataUri) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden.')); img.src = dataUri; }); } async function ladeBild(dataUri) { const img = await ladeImg(dataUri); if (img.width !== N || (img.height !== N && img.height !== N / 2)) { throw new Error(`Ein Skin muss 64×64 oder 64×32 Pixel groß sein (diese Datei: ${img.width}×${img.height}).`); } 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]; } } return daten; } /* * Umhang-Datei einlesen. Erkannt werden das übliche Format 2:1 (64×32, HD …) * sowie die alten Formate 22:17 und 46:22. Ergebnis immer im 2:1-Format mit * einer der Größen 64×32 … 1024×512 (größere Dateien werden verkleinert). */ async function ladeUmhangBild(dataUri) { const img = await ladeImg(dataUri); let k; if (img.width === 2 * img.height) k = img.width / 64; else if (17 * img.width === 22 * img.height) k = img.width / 22; else if (11 * img.width === 23 * img.height) k = img.width / 46; else throw new Error(`Das ist kein Umhang-Format (Seitenverhältnis 2:1 nötig, diese Datei: ${img.width}×${img.height}).`); const ziel = UMHANG_STUFEN.reduce((best, w) => (Math.abs(w / 64 - k) < Math.abs(best / 64 - k) ? w : best), 64); const kz = ziel / 64; const c = document.createElement('canvas'); c.width = ziel; c.height = ziel / 2; const g = c.getContext('2d', { willReadFrequently: true }); g.imageSmoothingEnabled = kz < k; g.imageSmoothingQuality = 'high'; g.drawImage(img, 0, 0, img.width * (kz / k), img.height * (kz / k)); return { w: ziel, h: ziel / 2, daten: g.getImageData(0, 0, ziel, ziel / 2).data }; } async function uebernimmDaten(daten, variante, text) { if (variante) await setzeArmform(variante === 'slim'); const d = skinDok; if (dok() !== d) return; aenderung(() => { d.daten = new Uint8ClampedArray(daten); }); d.geaendert = false; texturGeaendert(); status(text || ''); } function uebernimmUmhang(neu, text) { const d = umhangDok; if (dok() !== d) return; aenderung(() => { d.w = neu.w; d.h = neu.h; d.daten = new Uint8ClampedArray(neu.daten); }); d.geaendert = false; texturGeaendert(); status(text || ''); } function wennVerwerfenOk(aktion) { if (!dok().geaendert) { aktion(); return; } const was = dok().art === 'umhang' ? 'Der Umhang' : 'Der Skin'; askConfirm('Änderungen verwerfen?', `${was} 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 ladeAktuellenUmhang() { status('Aktueller Umhang wird geladen …'); const res = await window.api.capeCurrentData(); if (!res.ok) { if (res.keiner) uebernimmUmhang({ w: umhangDok.w, h: umhangDok.h, daten: umhangVorlage(umhangDok.w) }, 'Dein Konto hat noch keinen Umhang – hier ist eine Vorlage zum Loslegen.'); else status('✖ ' + (res.message || 'Umhang konnte nicht geladen werden.')); return; } try { const neu = await ladeUmhangBild(res.dataUri); uebernimmUmhang(neu, res.quelle === 'aeromc' ? `Dein AeroMC-Umhang geladen (${neu.w}×${neu.h}).` : `Dein offizieller Umhang geladen (${neu.w}×${neu.h}) – als Vorlage für einen eigenen.`); } 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); } } async function ladeUmhangDatei() { const res = await window.api.capeChooseFile('umhang'); if (res.canceled) return; if (!res.ok) { status('✖ ' + (res.message || 'Datei konnte nicht geladen werden.')); return; } try { const neu = await ladeUmhangBild(res.dataUri); uebernimmUmhang(neu, `${res.pfad.split(/[\\/]/).pop()} geladen (${neu.w}×${neu.h}).`); } catch (err) { status('✖ ' + err.message); } } // beliebiges Motiv auf die Außenseite setzen (füllt sie ganz, Ränder werden beschnitten) async function bildEinsetzen() { const res = await window.api.capeChooseFile('bild'); if (res.canceled) return; if (!res.ok) { status('✖ ' + (res.message || 'Bild konnte nicht geladen werden.')); return; } try { const img = await ladeImg(res.dataUri); const d = umhangDok; const f = umhangLayout(d.w).flaechen.find((x) => x.name === 'Außenseite'); const c = document.createElement('canvas'); c.width = f.w; c.height = f.h; const g = c.getContext('2d', { willReadFrequently: true }); g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; const faktor = Math.max(f.w / img.width, f.h / img.height); const bw = img.width * faktor, bh = img.height * faktor; g.drawImage(img, (f.w - bw) / 2, (f.h - bh) / 2, bw, bh); const px = g.getImageData(0, 0, f.w, f.h).data; aenderung(() => { for (let yy = 0; yy < f.h; yy++) { d.daten.set(px.subarray(yy * f.w * 4, (yy + 1) * f.w * 4), ((f.y + yy) * d.w + f.x) * 4); } }); status(`Bild auf die Außenseite gesetzt (${f.w}×${f.h} Pixel).` + (d.w < 256 ? ' Tipp: Für feine Motive oben eine HD-Größe wählen und das Bild erneut einsetzen.' : '')); } catch (err) { status('✖ ' + err.message); } } // Umhang auf eine andere Größe umrechnen (größer: Pixel bleiben scharf) function skaliereUmhang(neuW) { const d = umhangDok; if (neuW === d.w) return; const quelle = document.createElement('canvas'); quelle.width = d.w; quelle.height = d.h; quelle.getContext('2d').putImageData(new ImageData(d.daten, d.w, d.h), 0, 0); const ziel = document.createElement('canvas'); ziel.width = neuW; ziel.height = neuW / 2; const g = ziel.getContext('2d', { willReadFrequently: true }); g.imageSmoothingEnabled = neuW < d.w; g.imageSmoothingQuality = 'high'; g.drawImage(quelle, 0, 0, neuW, neuW / 2); const daten = new Uint8ClampedArray(g.getImageData(0, 0, neuW, neuW / 2).data); aenderung(() => { d.w = neuW; d.h = neuW / 2; d.daten = daten; }); status(`Umhang auf ${neuW}×${neuW / 2} umgestellt.` + (neuW > 64 ? ' Mit Mausrad hineinzoomen, rechte Maustaste verschiebt.' : '')); } // ---------- Speichern / Übernehmen ---------- function alsBase64() { const d = dok(); const c = document.createElement('canvas'); c.width = d.w; c.height = d.h; c.getContext('2d').putImageData(new ImageData(d.daten, d.w, d.h), 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 (skinDok.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 umhang = dok().art === 'umhang'; const res = umhang ? await window.api.capeSaveData(alsBase64()) : await window.api.skinSaveData(alsBase64()); if (res.canceled) return; if (!res.ok) { status('✖ ' + (res.message || 'Speichern fehlgeschlagen.')); return; } dok().geaendert = false; status('✔ Gespeichert: ' + res.pfad.split(/[\\/]/).pop() + '.' + (umhang ? '' : loecherHinweis())); } async function uebernimm() { const btn = el('se-uebernehmen'); const umhang = dok().art === 'umhang'; btn.disabled = true; status(umhang ? 'Umhang wird hochgeladen …' : 'Wird hochgeladen …'); try { const res = umhang ? await window.api.capeUpload(alsBase64()) : await window.api.skinApplyData(alsBase64(), ed.slim ? 'slim' : 'classic'); if (res.ok) { dok().geaendert = false; if (umhang) { status('✔ Umhang hochgeladen. Im Launcher sofort sichtbar – im Spiel bei Spielern mit dem AeroMC-Umhang-Mod.'); toast('Umhang gespeichert.'); } else { 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')); // Umhang el('se-u-groesse').addEventListener('change', () => skaliereUmhang(Number(el('se-u-groesse').value))); el('se-u-pinsel').addEventListener('change', () => { ed.pinsel = Number(el('se-u-pinsel').value) || 1; planeZeichnen(); }); el('se-u-bild').addEventListener('click', bildEinsetzen); el('se-u-einpassen').addEventListener('click', einpassen); el('se-u-elytra').addEventListener('change', () => { ed.elytra = el('se-u-elytra').checked; if (ed.viewer && ed.modus === 'umhang') ed.viewer.playerObject.backEquipment = ed.elytra ? 'elytra' : 'cape'; planeZeichnen(); }); el('se-undo').addEventListener('click', rueckgaengig); el('se-redo').addEventListener('click', wiederholen); el('se-neu').addEventListener('click', () => wennVerwerfenOk(() => { if (dok().art === 'umhang') uebernimmUmhang({ w: umhangDok.w, h: umhangDok.h, daten: umhangVorlage(umhangDok.w) }, 'Neue Umhang-Vorlage – leg los!'); else uebernimmDaten(vorlage(), null, 'Neue Vorlage – leg los!'); })); el('se-laden').addEventListener('click', () => wennVerwerfenOk(dok().art === 'umhang' ? ladeAktuellenUmhang : ladeAktuellen)); el('se-oeffnen').addEventListener('click', () => wennVerwerfenOk(() => (dok().art === 'umhang' ? ladeUmhangDatei() : 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' && ed.modus !== 'umhang') { e.preventDefault(); waehleSchicht(ed.schicht ? 0 : 1); return; } if (k === '0' && ed.modus === 'umhang') { e.preventDefault(); einpassen(); } }); verbinde2D(); verbindeUmhang2D(); 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); skinDok.daten = vorlage(); umhangDok.daten = umhangVorlage(64); waehleModus('2d'); await erzeugeViewer(); texturGeaendert(); } const variante = p.variante === 'slim' || p.variante === 'classic' ? p.variante : null; if (p.modus === 'umhang') { if (ed.modus !== 'umhang') waehleModus('umhang'); if (p.quelle === 'aktuell') wennVerwerfenOk(ladeAktuellenUmhang); return; } if (ed.modus === 'umhang' && (p.pfad || p.quelle)) waehleModus('2d'); if (p.pfad) wennVerwerfenOk(() => ladeDatei(p.pfad, variante)); else if (p.quelle === 'aktuell') wennVerwerfenOk(ladeAktuellen); else if (variante && !skinDok.geaendert) await setzeArmform(variante === 'slim'); } window.openSkinEditor = openSkinEditor; // für Tests window.__skinEditor = { ed, skinDok, umhangDok, strahl, spiegelPixel, seiten, AUFTEILUNG, umhangLayout, waehleModus, skaliereUmhang, ansicht }; })();