Upload via GUI (123 Dateien)

This commit is contained in:
2026-10-03 07:09:09 +00:00
parent 10ee45cbc2
commit e5ccf0b4cc
7 changed files with 2446 additions and 787 deletions
+983
View File
@@ -0,0 +1,983 @@
'use strict';
/*
* skineditor.js – Pixel-Editor für Minecraft-Skins
* -------------------------------------------------
* Ein Skin ist eine einzige 64×64-Textur. Jedes Körperteil hat darin zwei
* Bereiche: die Grundschicht (liegt direkt auf der Figur) und die 2. Schicht
* (eine etwas größere Hülle darüber – Haare, Jacke, Ärmel …). Die 2. Schicht
* darf durchsichtig sein und lässt die Figur dadurch plastischer wirken.
*
* Gemalt wird wahlweise auf der aufgeklappten Vorlage (2D) oder direkt auf der
* drehbaren Figur (3D). Beide Ansichten sind immer da – die gerade nicht
* gewählte steht klein rechts und ist ebenfalls bemalbar.
*
* Läuft im Popup-Fenster "skineditor" und nutzt die Helfer aus renderer.js
* (el, show, toast, askConfirm, POPOUT, SKIN_POSES).
*/
(() => {
const N = 64;
// Lage der Körperteile in der Textur. w/h/d = Breite/Höhe/Tiefe in Pixeln,
// base/over = linke obere Ecke des Bereichs der Grund- bzw. 2. Schicht.
const TEILE = [
{ key: 'head', name: 'Kopf', w: 8, h: 8, d: 8, base: [0, 0], over: [32, 0], spiegel: 'head' },
{ key: 'body', name: 'Körper', w: 8, h: 12, d: 4, base: [16, 16], over: [16, 32], spiegel: 'body' },
{ key: 'rightArm', name: 'Rechter Arm', w: 4, h: 12, d: 4, base: [40, 16], over: [40, 32], spiegel: 'leftArm', arm: true },
{ key: 'leftArm', name: 'Linker Arm', w: 4, h: 12, d: 4, base: [32, 48], over: [48, 48], spiegel: 'rightArm', arm: true },
{ key: 'rightLeg', name: 'Rechtes Bein', w: 4, h: 12, d: 4, base: [0, 16], over: [0, 32], spiegel: 'leftLeg' },
{ key: 'leftLeg', name: 'Linkes Bein', w: 4, h: 12, d: 4, base: [16, 48], over: [0, 48], spiegel: 'rightLeg' },
];
const TEIL = Object.fromEntries(TEILE.map((t) => [t.key, t]));
const SEITE_NAME = { oben: 'oben', unten: 'unten', rechts: 'rechte Seite', vorne: 'vorne', links: 'linke Seite', hinten: 'hinten' };
const SEITE_GEGENSTUECK = { rechts: 'links', links: 'rechts' };
const SCHICHT_NAME = ['Grundschicht', '2. Schicht'];
// Rechtecke [x, y, breite, höhe] der sechs Seiten eines Teils (Minecraft-Aufteilung:
// oben/unten in der ersten Reihe, darunter rechts | vorne | links | hinten)
function seiten(teil, schicht, slim) {
const w = teil.arm && slim ? 3 : teil.w;
const { h, d } = teil;
const [u, v] = schicht ? teil.over : teil.base;
return {
oben: [u + d, v, w, d],
unten: [u + d + w, v, w, d],
rechts: [u, v + d, d, h],
vorne: [u + d, v + d, w, h],
links: [u + d + w, v + d, d, h],
hinten: [u + 2 * d + w, v + d, w, h],
};
}
// Für jede Armform: Liste aller Flächen und eine Pixel→Fläche-Karte
const AUFTEILUNG = [false, true].map((slim) => {
const flaechen = [];
const karte = new Array(N * N).fill(null);
for (const teil of TEILE) {
for (const schicht of [0, 1]) {
for (const [seite, r] of Object.entries(seiten(teil, schicht, slim))) {
const f = { teil, schicht, seite, x: r[0], y: r[1], w: r[2], h: r[3] };
flaechen.push(f);
for (let yy = 0; yy < f.h; yy++) for (let xx = 0; xx < f.w; xx++) karte[(f.y + yy) * N + f.x + xx] = f;
}
}
}
return { flaechen, karte };
});
// ---------- Zustand ----------
const ed = {
daten: new Uint8ClampedArray(N * N * 4),
slim: false,
werkzeug: 'stift',
vorPipette: 'stift',
farbe: '#3a6ea5',
schicht: 0,
zeigeAussen: true,
durchscheinen: true,
spiegeln: false,
modus: '2d',
undo: [],
redo: [],
geaendert: false,
hover: null,
strich: null, // { snap, letzter: [x,y] | null, letzterBild: [cx,cy] | null, geaendert }
viewer: null,
bereit: false,
zeichnenGeplant: false,
};
const textur = document.createElement('canvas');
textur.width = textur.height = N;
const texturCtx = textur.getContext('2d', { willReadFrequently: true });
// Grundschicht als Schatten unter die 2. Schicht legen (Option "durchscheinen")
const schatten = document.createElement('canvas');
schatten.width = schatten.height = N;
const schattenCtx = schatten.getContext('2d');
// Hintergrund (Karomuster in den Flächen) je Armform einmal vorberechnet
const hintergrund = [null, null];
const aufteilung = () => AUFTEILUNG[ed.slim ? 1 : 0];
const flaecheBei = (x, y) => (x < 0 || y < 0 || x >= N || y >= N ? null : aufteilung().karte[y * N + x]);
const dunkel = () => document.documentElement.getAttribute('data-theme') === 'dark';
function hexZuRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return [0, 0, 0];
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgbZuHex = (r, g, b) => '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
// Pixel auf der gespiegelten Körperseite (rechter ↔ linker Arm/Bein,
// rechte ↔ linke Seite, waagerecht umgedreht)
function spiegelPixel(x, y) {
const f = flaecheBei(x, y);
if (!f) return null;
const partner = TEIL[f.teil.spiegel];
const seite = SEITE_GEGENSTUECK[f.seite] || f.seite;
const r = seiten(partner, f.schicht, ed.slim)[seite];
const fx = x - f.x;
const fy = y - f.y;
return [r[0] + (r[2] - 1 - fx), r[1] + fy];
}
// ---------- Daten ändern ----------
function setzePixel(x, y, rgba) {
const i = (y * N + x) * 4;
const d = ed.daten;
if (d[i] === rgba[0] && d[i + 1] === rgba[1] && d[i + 2] === rgba[2] && d[i + 3] === rgba[3]) return false;
d[i] = rgba[0]; d[i + 1] = rgba[1]; d[i + 2] = rgba[2]; d[i + 3] = rgba[3];
return true;
}
function zielPixel(x, y) {
const liste = [[x, y]];
if (ed.spiegeln) {
const s = spiegelPixel(x, y);
if (s && (s[0] !== x || s[1] !== y)) liste.push(s);
}
return liste;
}
function malFarbe() {
if (ed.werkzeug === 'radierer') return [0, 0, 0, 0];
return [...hexZuRgb(ed.farbe), 255];
}
// Einen Pixel mit dem aktuellen Werkzeug bearbeiten (Stift/Radierer)
function malePixel(x, y) {
const f = flaecheBei(x, y);
if (!f || f.schicht !== ed.schicht) return false;
let geaendert = false;
const rgba = malFarbe();
for (const [px, py] of zielPixel(x, y)) if (setzePixel(px, py, rgba)) geaendert = true;
return geaendert;
}
// Füllen: zusammenhängende gleichfarbige Pixel innerhalb derselben Fläche
function fuelle(x, y) {
const f = flaecheBei(x, y);
if (!f || f.schicht !== ed.schicht) return false;
const d = ed.daten;
const i0 = (y * N + x) * 4;
const alt = [d[i0], d[i0 + 1], d[i0 + 2], d[i0 + 3]];
const neu = malFarbe();
if (alt.every((v, k) => v === neu[k])) return false;
const gleich = (px, py) => {
const i = (py * N + px) * 4;
return d[i] === alt[0] && d[i + 1] === alt[1] && d[i + 2] === alt[2] && d[i + 3] === alt[3];
};
const gesehen = new Set();
const stapel = [[x, y]];
const treffer = [];
while (stapel.length) {
const [px, py] = stapel.pop();
const k = py * N + px;
if (gesehen.has(k)) continue;
if (px < f.x || py < f.y || px >= f.x + f.w || py >= f.y + f.h) continue;
gesehen.add(k);
if (!gleich(px, py)) continue;
treffer.push([px, py]);
stapel.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]);
}
let geaendert = false;
for (const [px, py] of treffer) for (const [zx, zy] of zielPixel(px, py)) if (setzePixel(zx, zy, neu)) geaendert = true;
return geaendert;
}
function nimmFarbe(x, y) {
const i = (y * N + x) * 4;
const d = ed.daten;
if (d[i + 3] === 0) return false;
setzeFarbe(rgbZuHex(d[i], d[i + 1], d[i + 2]));
return true;
}
// ---------- Striche, Rückgängig ----------
function beginneStrich() {
ed.strich = { snap: ed.daten.slice(), letzter: null, letzterBild: null, geaendert: false };
}
function beendeStrich() {
const s = ed.strich;
ed.strich = null;
if (!s || !s.geaendert) return;
ed.undo.push(s.snap);
if (ed.undo.length > 100) ed.undo.shift();
ed.redo = [];
ed.geaendert = true;
if (ed.werkzeug !== 'radierer') merkeFarbe(ed.farbe);
aktualisiereKnoepfe();
}
// Werkzeug auf einen Texturpixel anwenden (während eines Strichs)
function wendeAn(x, y) {
const s = ed.strich;
if (!s) return;
if (ed.werkzeug === 'pipette') {
nimmFarbe(x, y);
return;
}
if (ed.werkzeug === 'fuellen') {
if (s.letzter) return; // ein Klick = eine Füllung
s.letzter = [x, y];
if (fuelle(x, y)) { s.geaendert = true; texturGeaendert(); }
return;
}
let geaendert = false;
// Lücken zwischen zwei Mausereignissen schließen (nur innerhalb der Vorlage
// sinnvoll, auf der Figur liegen benachbarte Pixel nicht nebeneinander)
const punkte = s.letzter && s.zweiD ? linie(s.letzter[0], s.letzter[1], x, y) : [[x, y]];
for (const [px, py] of punkte) if (malePixel(px, py)) geaendert = true;
s.letzter = [x, y];
if (geaendert) { s.geaendert = true; texturGeaendert(); }
}
function linie(x0, y0, x1, y1) {
const punkte = [];
const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
for (;;) {
punkte.push([x0, y0]);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
return punkte;
}
function rueckgaengig() {
if (!ed.undo.length) return;
ed.redo.push(ed.daten.slice());
ed.daten = ed.undo.pop();
ed.geaendert = true;
texturGeaendert();
aktualisiereKnoepfe();
}
function wiederholen() {
if (!ed.redo.length) return;
ed.undo.push(ed.daten.slice());
ed.daten = ed.redo.pop();
ed.geaendert = true;
texturGeaendert();
aktualisiereKnoepfe();
}
// ganze Änderung (Laden, Leeren …) als ein Rückgängig-Schritt
function aenderung(fn) {
const snap = ed.daten.slice();
fn();
if (snap.every((v, i) => v === ed.daten[i])) return false;
ed.undo.push(snap);
if (ed.undo.length > 100) ed.undo.shift();
ed.redo = [];
ed.geaendert = true;
texturGeaendert();
aktualisiereKnoepfe();
return true;
}
// ---------- Farben ----------
const FARB_SCHLUESSEL = 'aeromc-skin-farben';
const STANDARD_FARBEN = ['#000000', '#ffffff', '#c69c6d', '#a87b4f', '#6b4a2f', '#3b2416', '#d32f2f', '#f9a825',
'#43a047', '#1e88e5', '#3949ab', '#8e24aa', '#00897b', '#757575', '#424242', '#ff8a65'];
let zuletzt = [];
try { zuletzt = JSON.parse(localStorage.getItem(FARB_SCHLUESSEL) || '[]'); } catch { zuletzt = []; }
if (!Array.isArray(zuletzt) || !zuletzt.length) zuletzt = STANDARD_FARBEN.slice();
function merkeFarbe(hex) {
zuletzt = [hex, ...zuletzt.filter((f) => f !== hex)].slice(0, 16);
try { localStorage.setItem(FARB_SCHLUESSEL, JSON.stringify(zuletzt)); } catch { /* egal */ }
zeigeZuletzt();
}
function zeigeZuletzt() {
const box = el('se-zuletzt');
box.innerHTML = '';
for (const f of zuletzt) {
const b = document.createElement('button');
b.className = 'se-farbfeld' + (f === ed.farbe ? ' on' : '');
b.style.background = f;
b.title = f;
b.addEventListener('click', () => setzeFarbe(f));
box.appendChild(b);
}
}
function setzeFarbe(hex) {
ed.farbe = hex.toLowerCase();
el('se-farbe').value = ed.farbe;
el('se-hex').value = ed.farbe;
// nach dem Aufnehmen zurück zum vorherigen Werkzeug; mit dem Radierer
// ergibt eine neue Farbe keinen Sinn -> Stift
if (ed.werkzeug === 'pipette') waehleWerkzeug(ed.vorPipette === 'radierer' ? 'stift' : ed.vorPipette);
else if (ed.werkzeug === 'radierer') waehleWerkzeug('stift');
zeigeZuletzt();
}
// ---------- Bedienelemente ----------
function waehleWerkzeug(w) {
if (w === 'radierer' && ed.schicht === 0) w = 'stift';
if (w === 'pipette' && ed.werkzeug !== 'pipette') ed.vorPipette = ed.werkzeug;
ed.werkzeug = w;
document.querySelectorAll('[data-se-tool]').forEach((b) => b.classList.toggle('on', b.dataset.seTool === w));
for (const h of [el('se-2d'), el('se-3d')]) h.dataset.werkzeug = w;
}
function waehleSchicht(s) {
ed.schicht = s;
document.querySelectorAll('[data-se-schicht]').forEach((b) => b.classList.toggle('on', Number(b.dataset.seSchicht) === s));
// die 2. Schicht muss sichtbar sein, wenn man darauf malen will
if (s === 1 && !ed.zeigeAussen) { ed.zeigeAussen = true; el('se-aussen').checked = true; }
const radierer = document.querySelector('[data-se-tool="radierer"]');
radierer.disabled = s === 0;
radierer.title = s === 0
? 'Radierer – nur in der 2. Schicht (die Grundschicht darf keine Löcher haben)'
: 'Radierer (E) – macht Pixel durchsichtig';
if (s === 0 && ed.werkzeug === 'radierer') waehleWerkzeug('stift');
el('se-durch').disabled = s === 0;
ed.hover = null;
texturGeaendert();
}
function waehleModus(m) {
ed.modus = m;
document.querySelectorAll('[data-se-modus]').forEach((b) => b.classList.toggle('on', b.dataset.seModus === m));
const gross = el('se-buehne');
const klein = el('se-mini');
gross.appendChild(m === '2d' ? el('se-2d') : el('se-3d'));
klein.appendChild(m === '2d' ? el('se-3d') : el('se-2d'));
el('se-mini-titel').textContent = m === '2d' ? '3D-Vorschau' : 'Vorlage';
zeigeInfo();
passeGroessenAn();
}
function aktualisiereKnoepfe() {
el('se-undo').disabled = !ed.undo.length;
el('se-redo').disabled = !ed.redo.length;
}
function zeigeInfo() {
const info = el('se-info');
const h = ed.hover;
const f = h && flaecheBei(h[0], h[1]);
if (f) {
info.textContent = `${f.teil.name} · ${SEITE_NAME[f.seite]} · ${SCHICHT_NAME[f.schicht]} · Pixel ${h[0]}, ${h[1]}`;
return;
}
info.textContent = ed.modus === '3d'
? 'Linke Maustaste malt · rechte Maustaste dreht · Mausrad zoomt · Alt+Klick nimmt Farbe auf'
: 'Linke Maustaste malt · Alt+Klick nimmt eine Farbe auf · abgedunkelte Bereiche gehören zur anderen Schicht';
}
function status(text) { el('se-status').textContent = text || ''; el('se-status').title = text || ''; }
// ---------- Zeichnen ----------
function texturGeaendert() {
texturCtx.putImageData(new ImageData(ed.daten, N, N), 0, 0);
// Schatten: Grundschicht-Pixel an die Stelle der zugehörigen 2. Schicht legen
const s = schattenCtx.createImageData(N, N);
for (const f of aufteilung().flaechen) {
if (f.schicht !== 1) continue;
const r = seiten(f.teil, 0, ed.slim)[f.seite];
for (let yy = 0; yy < f.h; yy++) {
for (let xx = 0; xx < f.w; xx++) {
const q = ((r[1] + yy) * N + r[0] + xx) * 4;
const z = ((f.y + yy) * N + f.x + xx) * 4;
s.data[z] = ed.daten[q]; s.data[z + 1] = ed.daten[q + 1]; s.data[z + 2] = ed.daten[q + 2]; s.data[z + 3] = ed.daten[q + 3];
}
}
}
schattenCtx.putImageData(s, 0, 0);
planeZeichnen();
}
function planeZeichnen() {
if (ed.zeichnenGeplant) return;
ed.zeichnenGeplant = true;
requestAnimationFrame(() => {
ed.zeichnenGeplant = false;
zeichne2D();
zeichne3D();
zeigeInfo();
});
}
function hintergrundFuer(slim) {
const k = slim ? 1 : 0;
if (hintergrund[k] && hintergrund[k].dunkel === dunkel()) return hintergrund[k].canvas;
const c = document.createElement('canvas');
c.width = c.height = N;
const g = c.getContext('2d');
const img = g.createImageData(N, N);
const [a, b] = dunkel() ? [[58, 58, 64], [48, 48, 54]] : [[236, 236, 238], [218, 219, 223]];
for (const f of AUFTEILUNG[k].flaechen) {
for (let yy = f.y; yy < f.y + f.h; yy++) {
for (let xx = f.x; xx < f.x + f.w; xx++) {
const farbe = (xx + yy) % 2 ? a : b;
const i = (yy * N + xx) * 4;
img.data[i] = farbe[0]; img.data[i + 1] = farbe[1]; img.data[i + 2] = farbe[2]; img.data[i + 3] = 255;
}
}
}
g.putImageData(img, 0, 0);
hintergrund[k] = { canvas: c, dunkel: dunkel() };
return c;
}
// sichtbare Textur für die 2D-Ansicht: ohne ungenutzte Bereiche und, falls
// ausgeblendet, ohne 2. Schicht
const sicht = document.createElement('canvas');
sicht.width = sicht.height = N;
const sichtCtx = sicht.getContext('2d');
function zeichne2D() {
const canvas = el('se-2d-canvas');
const k = Number(canvas.dataset.k) || 0;
if (!k) return;
const g = canvas.getContext('2d');
g.setTransform(1, 0, 0, 1, 0, 0);
g.clearRect(0, 0, canvas.width, canvas.height);
g.imageSmoothingEnabled = false;
g.setTransform(k, 0, 0, k, 0, 0);
g.fillStyle = dunkel() ? '#1c1c1f' : '#c9ccd1';
g.fillRect(0, 0, N, N);
g.drawImage(hintergrundFuer(ed.slim), 0, 0);
if (ed.schicht === 1 && ed.durchscheinen) {
g.globalAlpha = 0.35;
g.drawImage(schatten, 0, 0);
g.globalAlpha = 1;
}
sichtCtx.clearRect(0, 0, N, N);
const img = sichtCtx.createImageData(N, N);
const karte = aufteilung().karte;
for (let i = 0; i < N * N; i++) {
const f = karte[i];
if (!f || (f.schicht === 1 && !ed.zeigeAussen)) continue;
const q = i * 4;
img.data[q] = ed.daten[q]; img.data[q + 1] = ed.daten[q + 1]; img.data[q + 2] = ed.daten[q + 2]; img.data[q + 3] = ed.daten[q + 3];
}
sichtCtx.putImageData(img, 0, 0);
g.drawImage(sicht, 0, 0);
// andere Schicht abdunkeln
g.fillStyle = dunkel() ? 'rgba(20,20,22,.62)' : 'rgba(40,44,52,.45)';
for (const f of aufteilung().flaechen) if (f.schicht !== ed.schicht) g.fillRect(f.x, f.y, f.w, f.h);
const px = 1 / k;
// Pixelraster
if (k >= 6) {
g.strokeStyle = dunkel() ? 'rgba(255,255,255,.07)' : 'rgba(0,0,0,.08)';
g.lineWidth = px;
g.beginPath();
for (let i = 1; i < N; i++) { g.moveTo(i, 0); g.lineTo(i, N); g.moveTo(0, i); g.lineTo(N, i); }
g.stroke();
}
// Flächenränder
g.lineWidth = 1.5 * px;
for (const f of aufteilung().flaechen) {
g.strokeStyle = f.schicht === ed.schicht
? (dunkel() ? 'rgba(255,255,255,.45)' : 'rgba(0,0,0,.4)')
: (dunkel() ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.12)');
g.strokeRect(f.x, f.y, f.w, f.h);
}
// Mauszeiger und gespiegelter Partner
if (ed.hover) {
const [hx, hy] = ed.hover;
g.lineWidth = 2 * px;
g.strokeStyle = dunkel() ? '#ffffff' : '#000000';
g.strokeRect(hx + px, hy + px, 1 - 2 * px, 1 - 2 * px);
if (ed.spiegeln) {
const s = spiegelPixel(hx, hy);
if (s && (s[0] !== hx || s[1] !== hy)) {
g.setLineDash([2 * px, 2 * px]);
g.strokeRect(s[0] + px, s[1] + px, 1 - 2 * px, 1 - 2 * px);
g.setLineDash([]);
}
}
}
}
function zeichne3D() {
const v = ed.viewer;
if (!v || !v.skinTexture) return;
const g = v.skinCanvas.getContext('2d');
if (v.skinCanvas.width !== N || v.skinCanvas.height !== N) { v.skinCanvas.width = N; v.skinCanvas.height = N; }
g.clearRect(0, 0, N, N);
g.drawImage(textur, 0, 0);
// Pixel unter der Maus hervorheben
if (ed.hover && ed.werkzeug !== 'pipette') {
const ziele = ed.spiegeln ? zielPixel(ed.hover[0], ed.hover[1]) : [ed.hover];
for (const [hx, hy] of ziele) {
const i = (hy * N + hx) * 4;
const hell = ed.daten[i + 3] === 0 || (ed.daten[i] + ed.daten[i + 1] + ed.daten[i + 2]) < 380;
g.fillStyle = hell ? 'rgba(255,255,255,.75)' : 'rgba(0,0,0,.55)';
g.fillRect(hx, hy, 1, 1);
}
}
v.skinTexture.needsUpdate = true;
v.playerObject.skin.setOuterLayerVisible(ed.zeigeAussen);
}
// ---------- Größen ----------
function passeGroessenAn() {
const h2 = el('se-2d');
const c = el('se-2d-canvas');
const platz = Math.min(h2.clientWidth, h2.clientHeight) - 8;
const s = Math.max(1, Math.floor(platz / N));
const dpr = window.devicePixelRatio || 1;
c.style.width = c.style.height = (s * N) + 'px';
c.width = c.height = Math.round(s * N * dpr);
c.dataset.k = String((s * N * dpr) / N);
const h3 = el('se-3d');
if (ed.viewer && h3.clientWidth && h3.clientHeight) ed.viewer.setSize(h3.clientWidth, h3.clientHeight);
planeZeichnen();
}
// ---------- 2D-Eingabe ----------
function texelAus2D(e) {
const c = el('se-2d-canvas');
const r = c.getBoundingClientRect();
const x = Math.floor(((e.clientX - r.left) / r.width) * N);
const y = Math.floor(((e.clientY - r.top) / r.height) * N);
return x >= 0 && y >= 0 && x < N && y < N ? [x, y] : null;
}
function verbinde2D() {
const c = el('se-2d-canvas');
c.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
const t = texelAus2D(e);
if (!t) return;
e.preventDefault();
try { c.setPointerCapture(e.pointerId); } catch { /* egal */ }
if (e.altKey) { nimmFarbe(t[0], t[1]); return; }
beginneStrich();
ed.strich.zweiD = true;
wendeAn(t[0], t[1]);
});
c.addEventListener('pointermove', (e) => {
const t = texelAus2D(e);
if (ed.strich && t) wendeAn(t[0], t[1]);
const neu = t && flaecheBei(t[0], t[1]) ? t : null;
if (String(neu) !== String(ed.hover)) { ed.hover = neu; planeZeichnen(); }
});
const ende = () => beendeStrich();
c.addEventListener('pointerup', ende);
c.addEventListener('pointercancel', ende);
c.addEventListener('pointerleave', () => { if (!ed.strich && ed.hover) { ed.hover = null; planeZeichnen(); } });
}
// ---------- 3D: Strahl gegen die Figur ----------
// three.js' Raycaster wird von skinview3d nicht mitgeliefert – daher eigene
// Schnittprüfung Strahl/Dreieck (Möller–Trumbore) gegen die Quader der Figur.
function schnitt(o, d, a, b, c) {
const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
const px = d.y * e2z - d.z * e2y, py = d.z * e2x - d.x * e2z, pz = d.x * e2y - d.y * e2x;
const det = e1x * px + e1y * py + e1z * pz;
if (Math.abs(det) < 1e-9) return null;
const inv = 1 / det;
const tx = o.x - a.x, ty = o.y - a.y, tz = o.z - a.z;
const u = (tx * px + ty * py + tz * pz) * inv;
if (u < 0 || u > 1) return null;
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
const v = (d.x * qx + d.y * qy + d.z * qz) * inv;
if (v < 0 || u + v > 1) return null;
const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
return t > 1e-6 ? { t, u, v } : null;
}
// liefert den Texturpixel der getroffenen Schicht (0/1) oder null
function strahl(clientX, clientY, schicht) {
const v = ed.viewer;
if (!v) return null;
const r = v.canvas.getBoundingClientRect();
if (!r.width || !r.height) return null;
const cam = v.camera;
const V3 = cam.position.constructor;
cam.updateMatrixWorld();
const o = new V3().setFromMatrixPosition(cam.matrixWorld);
const nx = ((clientX - r.left) / r.width) * 2 - 1;
const ny = -((clientY - r.top) / r.height) * 2 + 1;
const d = new V3(nx, ny, 0.5).unproject(cam).sub(o).normalize();
v.playerObject.updateMatrixWorld(true);
const ecken = [new V3(), new V3(), new V3()];
let best = null;
for (const teil of v.playerObject.skin.getBodyParts()) {
if (!teil.visible) continue;
const mesh = schicht ? teil.outerLayer : teil.innerLayer;
if (!mesh || !mesh.visible) continue;
const geo = mesh.geometry;
const pos = geo.attributes.position;
const idx = geo.index;
const anzahl = idx ? idx.count : pos.count;
for (let i = 0; i < anzahl; i += 3) {
const ia = idx ? [idx.getX(i), idx.getX(i + 1), idx.getX(i + 2)] : [i, i + 1, i + 2];
for (let k = 0; k < 3; k++) ecken[k].fromBufferAttribute(pos, ia[k]).applyMatrix4(mesh.matrixWorld);
const h = schnitt(o, d, ecken[0], ecken[1], ecken[2]);
if (h && (!best || h.t < best.t)) best = { t: h.t, b: [1 - h.u - h.v, h.u, h.v], ia, uv: geo.attributes.uv };
}
}
if (!best) return null;
let U = 0, W = 0;
let uMin = 1, uMax = 0, vMin = 1, vMax = 0;
for (let k = 0; k < 3; k++) {
const u = best.uv.getX(best.ia[k]);
const w = best.uv.getY(best.ia[k]);
U += best.b[k] * u; W += best.b[k] * w;
uMin = Math.min(uMin, u); uMax = Math.max(uMax, u); vMin = Math.min(vMin, w); vMax = Math.max(vMax, w);
}
// Dreieck gehört zu genau einer Seitenfläche – Rundung an der Kante darf
// nicht in die Nachbarfläche rutschen
const x0 = Math.round(uMin * N), x1 = Math.round(uMax * N) - 1;
const y0 = Math.round((1 - vMax) * N), y1 = Math.round((1 - vMin) * N) - 1;
const x = Math.min(x1, Math.max(x0, Math.floor(U * N)));
const y = Math.min(y1, Math.max(y0, Math.floor((1 - W) * N)));
return { t: best.t, x, y };
}
// sichtbarer Pixel für die Pipette: erst die 2. Schicht (falls dort Farbe ist)
function sichtbarerPixel(cx, cy) {
if (ed.zeigeAussen) {
const a = strahl(cx, cy, 1);
if (a && ed.daten[(a.y * N + a.x) * 4 + 3] > 0) return [a.x, a.y];
}
const b = strahl(cx, cy, 0);
return b ? [b.x, b.y] : null;
}
function verbinde3D() {
const huelle = el('se-3d');
const canvas = el('se-3d-canvas');
// in der Einfangphase: trifft die Maus die Figur, malen wir und die
// Drehsteuerung von skinview3d bekommt das Ereignis gar nicht erst
huelle.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !ed.viewer) return;
if (e.altKey || ed.werkzeug === 'pipette') {
const t = sichtbarerPixel(e.clientX, e.clientY);
if (!t) return;
e.stopPropagation(); e.preventDefault();
nimmFarbe(t[0], t[1]);
return;
}
const t = strahl(e.clientX, e.clientY, ed.schicht);
if (!t) return;
e.stopPropagation(); e.preventDefault();
try { canvas.setPointerCapture(e.pointerId); } catch { /* egal */ }
beginneStrich();
ed.strich.letzterBild = [e.clientX, e.clientY];
wendeAn(t.x, t.y);
}, true);
canvas.addEventListener('pointermove', (e) => {
if (ed.strich) {
// Strecke seit dem letzten Ereignis in kleinen Schritten abtasten
const [ax, ay] = ed.strich.letzterBild || [e.clientX, e.clientY];
const schritte = Math.max(1, Math.ceil(Math.hypot(e.clientX - ax, e.clientY - ay) / 2));
for (let i = 1; i <= schritte; i++) {
const t = strahl(ax + ((e.clientX - ax) * i) / schritte, ay + ((e.clientY - ay) * i) / schritte, ed.schicht);
if (t) wendeAn(t.x, t.y);
}
ed.strich.letzterBild = [e.clientX, e.clientY];
}
if (e.buttons && !ed.strich) return; // dreht gerade
const t = strahl(e.clientX, e.clientY, ed.schicht);
const neu = t ? [t.x, t.y] : null;
if (String(neu) !== String(ed.hover)) { ed.hover = neu; planeZeichnen(); }
});
const ende = () => beendeStrich();
canvas.addEventListener('pointerup', ende);
canvas.addEventListener('pointercancel', ende);
canvas.addEventListener('pointerleave', () => { if (!ed.strich && ed.hover) { ed.hover = null; planeZeichnen(); } });
}
async function erzeugeViewer() {
if (ed.viewer || typeof skinview3d === 'undefined') {
if (typeof skinview3d === 'undefined') el('se-3d-leer').textContent = '3D-Ansicht nicht verfügbar';
return;
}
const h = el('se-3d');
const viewer = new skinview3d.SkinViewer({
canvas: el('se-3d-canvas'),
width: h.clientWidth || 300,
height: h.clientHeight || 300,
skin: textur,
model: ed.slim ? 'slim' : 'default',
});
await new Promise((resolve) => {
const check = (n) => {
if ((viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) || n > 150) resolve();
else setTimeout(() => check(n + 1), 40);
};
check(0);
});
viewer.fov = 36;
viewer.zoom = 0.78;
viewer.autoRotate = false;
viewer.animation = null;
// leicht abgespreizte Arme/Beine, damit man auch an die Innenseiten kommt
const p = viewer.playerObject;
p.rotation.set(0, -0.35, 0);
p.skin.leftArm.rotation.set(0, 0, 0.2);
p.skin.rightArm.rotation.set(0, 0, -0.2);
p.skin.leftLeg.rotation.set(0, 0, 0.06);
p.skin.rightLeg.rotation.set(0, 0, -0.06);
viewer.controls.enableRotate = true;
viewer.controls.enableZoom = true;
viewer.controls.enablePan = false;
// rechte Maustaste dreht immer – auch wenn man gerade auf die Figur zeigt
viewer.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 0 };
ed.viewer = viewer;
el('se-3d-leer').classList.add('hidden');
passeGroessenAn();
}
async function setzeArmform(slim) {
ed.slim = !!slim;
el('se-variante').value = ed.slim ? 'slim' : 'classic';
ed.hover = null;
if (ed.viewer) {
await ed.viewer.loadSkin(textur, { model: ed.slim ? 'slim' : 'default' });
}
texturGeaendert();
}
// ---------- Laden / Vorlage ----------
function vorlage() {
const d = new Uint8ClampedArray(N * N * 4);
const farben = { head: '#c69c6d', body: '#2f9e9e', rightArm: '#c69c6d', leftArm: '#c69c6d', rightLeg: '#3b3f9e', leftLeg: '#3b3f9e' };
for (const f of AUFTEILUNG[ed.slim ? 1 : 0].flaechen) {
if (f.schicht !== 0) continue;
const [r, g, b] = hexZuRgb(farben[f.teil.key]);
for (let yy = f.y; yy < f.y + f.h; yy++) {
for (let xx = f.x; xx < f.x + f.w; xx++) {
const i = (yy * N + xx) * 4;
d[i] = r; d[i + 1] = g; d[i + 2] = b; d[i + 3] = 255;
}
}
}
return d;
}
function ladeBild(dataUri) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
if (img.width !== N || (img.height !== N && img.height !== N / 2)) {
reject(new Error(`Ein Skin muss 64×64 oder 64×32 Pixel groß sein (diese Datei: ${img.width}×${img.height}).`));
return;
}
const c = document.createElement('canvas');
c.width = c.height = N;
const g = c.getContext('2d', { willReadFrequently: true });
g.drawImage(img, 0, 0);
const daten = g.getImageData(0, 0, N, N).data;
if (img.height === N / 2) {
// alte 64×32-Skins: linker Arm und linkes Bein sind dort gespiegelte
// Kopien der rechten – wie Minecraft es selbst macht
const karte = AUFTEILUNG[0].karte;
for (let i = 0; i < N * N / 2; i++) {
const f = karte[i];
if (!f || f.schicht !== 0 || (f.teil.key !== 'rightArm' && f.teil.key !== 'rightLeg')) continue;
const partner = TEIL[f.teil.spiegel];
const seite = SEITE_GEGENSTUECK[f.seite] || f.seite;
const r = seiten(partner, 0, false)[seite];
const x = i % N, y = Math.floor(i / N);
const z = ((r[1] + (y - f.y)) * N + r[0] + (r[2] - 1 - (x - f.x))) * 4;
for (let k = 0; k < 4; k++) daten[z + k] = daten[i * 4 + k];
}
}
resolve(daten);
};
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden.'));
img.src = dataUri;
});
}
async function uebernimmDaten(daten, variante, text) {
if (variante) await setzeArmform(variante === 'slim');
aenderung(() => { ed.daten = new Uint8ClampedArray(daten); });
ed.geaendert = false;
texturGeaendert();
status(text || '');
}
function wennVerwerfenOk(aktion) {
if (!ed.geaendert) { aktion(); return; }
askConfirm('Änderungen verwerfen?', 'Der Skin im Editor hat ungespeicherte Änderungen. Trotzdem fortfahren?', 'Verwerfen', aktion);
}
async function ladeAktuellen() {
status('Aktueller Skin wird geladen …');
const res = await window.api.skinCurrentData();
if (!res.ok) {
if (res.standard) {
await uebernimmDaten(vorlage(), res.variante, 'Dein Konto nutzt den Standard-Skin – hier ist eine leere Vorlage zum Loslegen.');
} else {
status('✖ ' + (res.message || 'Skin konnte nicht geladen werden.'));
}
return;
}
try {
await uebernimmDaten(await ladeBild(res.dataUri), res.variante, `Skin von ${res.name || 'deinem Konto'} geladen.`);
} catch (err) { status('✖ ' + err.message); }
}
async function ladeDatei(pfad, variante) {
const res = pfad ? await window.api.skinReadData(pfad) : await window.api.skinChoose();
if (res.canceled) return;
if (!res.ok) { status('✖ ' + (res.message || 'Datei konnte nicht geladen werden.')); return; }
try {
await uebernimmDaten(await ladeBild(res.dataUri), variante, `${(res.pfad || pfad || '').split(/[\\/]/).pop()} geladen.`);
} catch (err) { status('✖ ' + err.message); }
}
// ---------- Speichern / Übernehmen ----------
function alsBase64() {
const c = document.createElement('canvas');
c.width = c.height = N;
c.getContext('2d').putImageData(new ImageData(ed.daten, N, N), 0, 0);
return c.toDataURL('image/png').split(',')[1];
}
// Minecraft zeigt durchsichtige Pixel der Grundschicht schwarz an
function loecherInGrundschicht() {
let n = 0;
for (const f of aufteilung().flaechen) {
if (f.schicht !== 0) continue;
for (let yy = f.y; yy < f.y + f.h; yy++) for (let xx = f.x; xx < f.x + f.w; xx++) if (ed.daten[(yy * N + xx) * 4 + 3] < 255) n++;
}
return n;
}
function loecherHinweis() {
const n = loecherInGrundschicht();
return n ? ` Hinweis: ${n} durchsichtige Pixel in der Grundschicht erscheinen im Spiel schwarz.` : '';
}
async function speichere() {
const res = await window.api.skinSaveData(alsBase64());
if (res.canceled) return;
if (!res.ok) { status('✖ ' + (res.message || 'Speichern fehlgeschlagen.')); return; }
ed.geaendert = false;
status('✔ Gespeichert: ' + res.pfad.split(/[\\/]/).pop() + '.' + loecherHinweis());
}
async function uebernimm() {
const btn = el('se-uebernehmen');
btn.disabled = true;
status('Wird hochgeladen …');
try {
const res = await window.api.skinApplyData(alsBase64(), ed.slim ? 'slim' : 'classic');
if (res.ok) {
ed.geaendert = false;
status('✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.' + loecherHinweis());
toast('Skin geändert.');
} else {
status('✖ ' + (res.message || 'Fehlgeschlagen.'));
}
} finally {
btn.disabled = false;
}
}
// ---------- Öffnen ----------
let verdrahtet = false;
function verdrahte() {
if (verdrahtet) return;
verdrahtet = true;
document.querySelectorAll('[data-se-tool]').forEach((b) => b.addEventListener('click', () => waehleWerkzeug(b.dataset.seTool)));
document.querySelectorAll('[data-se-schicht]').forEach((b) => b.addEventListener('click', () => waehleSchicht(Number(b.dataset.seSchicht))));
document.querySelectorAll('[data-se-modus]').forEach((b) => b.addEventListener('click', () => waehleModus(b.dataset.seModus)));
el('se-farbe').addEventListener('input', () => setzeFarbe(el('se-farbe').value));
el('se-hex').addEventListener('change', () => {
let v = el('se-hex').value.trim();
if (!v.startsWith('#')) v = '#' + v;
if (/^#[0-9a-f]{6}$/i.test(v)) setzeFarbe(v);
else el('se-hex').value = ed.farbe;
});
el('se-aussen').addEventListener('change', () => {
ed.zeigeAussen = el('se-aussen').checked;
if (!ed.zeigeAussen && ed.schicht === 1) waehleSchicht(0);
planeZeichnen();
});
el('se-durch').addEventListener('change', () => { ed.durchscheinen = el('se-durch').checked; planeZeichnen(); });
el('se-spiegeln').addEventListener('change', () => { ed.spiegeln = el('se-spiegeln').checked; planeZeichnen(); });
el('se-leeren').addEventListener('click', () => {
askConfirm('2. Schicht leeren?', 'Alle Pixel der 2. Schicht werden durchsichtig. Mit Strg+Z lässt sich das rückgängig machen.', 'Leeren', () => {
const geaendert = aenderung(() => {
for (const f of aufteilung().flaechen) {
if (f.schicht !== 1) continue;
for (let yy = f.y; yy < f.y + f.h; yy++) for (let xx = f.x; xx < f.x + f.w; xx++) setzePixel(xx, yy, [0, 0, 0, 0]);
}
});
status(geaendert ? '2. Schicht geleert.' : 'Die 2. Schicht war schon leer.');
});
});
el('se-variante').addEventListener('change', () => setzeArmform(el('se-variante').value === 'slim'));
el('se-undo').addEventListener('click', rueckgaengig);
el('se-redo').addEventListener('click', wiederholen);
el('se-neu').addEventListener('click', () => wennVerwerfenOk(() => uebernimmDaten(vorlage(), null, 'Neue Vorlage – leg los!')));
el('se-laden').addEventListener('click', () => wennVerwerfenOk(ladeAktuellen));
el('se-oeffnen').addEventListener('click', () => wennVerwerfenOk(() => ladeDatei(null, null)));
el('se-speichern').addEventListener('click', speichere);
el('se-uebernehmen').addEventListener('click', uebernimm);
document.addEventListener('keydown', (e) => {
if (el('modal-skineditor').classList.contains('hidden')) return;
if (!el('modal-confirm').classList.contains('hidden')) return;
const a = document.activeElement;
if (a && (['INPUT', 'TEXTAREA', 'SELECT'].includes(a.tagName) && a.type !== 'checkbox')) return;
const strg = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (strg && k === 'z' && !e.shiftKey) { e.preventDefault(); rueckgaengig(); return; }
if (strg && (k === 'y' || (k === 'z' && e.shiftKey))) { e.preventDefault(); wiederholen(); return; }
if (strg || e.altKey) return;
const werkzeug = { b: 'stift', e: 'radierer', i: 'pipette', g: 'fuellen' }[k];
if (werkzeug) { e.preventDefault(); waehleWerkzeug(werkzeug); return; }
if (k === 'm') { e.preventDefault(); el('se-spiegeln').click(); return; }
if (k === 'l') { e.preventDefault(); waehleSchicht(ed.schicht ? 0 : 1); }
});
verbinde2D();
verbinde3D();
new ResizeObserver(() => passeGroessenAn()).observe(el('se-buehne'));
new ResizeObserver(() => passeGroessenAn()).observe(el('se-mini'));
// Hell/Dunkel gewechselt -> Hintergrund neu
new MutationObserver(() => planeZeichnen()).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
async function openSkinEditor(p = {}) {
if (POPOUT !== 'skineditor') { window.api.popoutOpen('skineditor', p); return; }
verdrahte();
show('modal-skineditor');
if (!ed.bereit) {
ed.bereit = true;
el('se-farbe').value = ed.farbe;
el('se-hex').value = ed.farbe;
zeigeZuletzt();
waehleWerkzeug('stift');
waehleSchicht(0);
aktualisiereKnoepfe();
ed.daten = vorlage();
texturGeaendert();
waehleModus('2d');
await erzeugeViewer();
texturGeaendert();
}
const variante = p.variante === 'slim' || p.variante === 'classic' ? p.variante : null;
if (p.pfad) wennVerwerfenOk(() => ladeDatei(p.pfad, variante));
else if (p.quelle === 'aktuell') wennVerwerfenOk(ladeAktuellen);
else if (variante && !ed.geaendert) await setzeArmform(variante === 'slim');
}
window.openSkinEditor = openSkinEditor;
// für Tests
window.__skinEditor = { ed, strahl, spiegelPixel, seiten, AUFTEILUNG };
})();