'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 };
})();