LegacyLauncher/skin_manager.js
2026-03-13 16:49:42 -07:00

470 lines
18 KiB
JavaScript

// skin_manager.js
// Handles skin uploading, conversion, 3D preview, and saving to char.png
let mainMenuScene, mainMenuCamera, mainMenuRenderer, mainMenuPlayerGroup;
let isMainSkinDragging = false;
let mainMenuSkinRenderMode = '3d';
let skinScene, skinCamera, skinRenderer, skinPlayerGroup;
let isSkinDragging = false;
let previousSkinMousePosition = { x: 0, y: 0 };
let processedSkinDataUrl = null;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const skinInput = document.getElementById('skin-input');
const dropZone = document.getElementById('drop-zone');
const saveSkinBtn = document.getElementById('save-skin-btn');
const closeSkinBtn = document.getElementById('btn-close-skin');
if (skinInput) skinInput.addEventListener('change', (e) => handleSkinFile(e.target.files[0]));
if (dropZone) {
dropZone.addEventListener('click', () => skinInput?.click());
dropZone.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
skinInput?.click();
}
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-green-500');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('border-green-500'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-green-500');
handleSkinFile(e.dataTransfer.files[0]);
});
}
if (saveSkinBtn) {
saveSkinBtn.addEventListener('click', saveSkinToDisk);
}
if (closeSkinBtn) {
closeSkinBtn.addEventListener('click', closeSkinManager);
}
// Initialize Main Menu Viewer
initMainMenuSkinViewer();
});
function initMainMenuSkinViewer() {
const container = document.getElementById('main-skin-viewer');
if (!container) return;
mainMenuScene = new THREE.Scene();
mainMenuCamera = new THREE.PerspectiveCamera(45, container.offsetWidth / container.offsetHeight, 0.1, 1000);
mainMenuCamera.position.set(0, 5, 60);
mainMenuRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
mainMenuRenderer.setSize(container.offsetWidth, container.offsetHeight);
mainMenuRenderer.setPixelRatio(window.devicePixelRatio);
mainMenuRenderer.outputEncoding = THREE.sRGBEncoding;
container.appendChild(mainMenuRenderer.domElement);
mainMenuScene.add(new THREE.AmbientLight(0xffffff, 1.1));
const dl = new THREE.DirectionalLight(0xffffff, 0.5);
dl.position.set(10, 20, 15);
mainMenuScene.add(dl);
mainMenuPlayerGroup = new THREE.Group();
mainMenuScene.add(mainMenuPlayerGroup);
// Interaction for Main Menu Viewer
let prevX = 0;
container.addEventListener('mousedown', (e) => {
if (mainMenuSkinRenderMode !== '3d') return;
isMainSkinDragging = true;
prevX = e.clientX;
});
window.addEventListener('mouseup', () => isMainSkinDragging = false);
window.addEventListener('mousemove', (e) => {
if (mainMenuSkinRenderMode === '3d' && isMainSkinDragging && mainMenuPlayerGroup) {
mainMenuPlayerGroup.rotation.y += (e.clientX - prevX) * 0.01;
prevX = e.clientX;
}
});
// Auto-rotate slowly
function animateMain() {
requestAnimationFrame(animateMain);
if (mainMenuSkinRenderMode === '3d' && !isMainSkinDragging && mainMenuPlayerGroup) {
mainMenuPlayerGroup.rotation.y += 0.005;
}
if (mainMenuRenderer && mainMenuScene && mainMenuCamera) mainMenuRenderer.render(mainMenuScene, mainMenuCamera);
}
animateMain();
// Load current skin after short delay to ensure paths are ready
setTimeout(loadMainMenuSkin, 500);
// Handle window resize
window.addEventListener('resize', () => {
if (mainMenuCamera && mainMenuRenderer && container) {
mainMenuCamera.aspect = container.offsetWidth / container.offsetHeight;
mainMenuCamera.updateProjectionMatrix();
mainMenuRenderer.setSize(container.offsetWidth, container.offsetHeight);
}
});
}
async function loadMainMenuSkin() {
try {
// Ensure install dir is available (currentInstance might not be ready)
const installDir = await window.getInstallDir();
if (!installDir) return;
const skinPath = path.join(installDir, 'Common', 'res', 'mob', 'char.png');
if (fs.existsSync(skinPath)) {
const skinData = fs.readFileSync(skinPath);
const blob = new Blob([skinData]);
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const isLegacy = img.height === 32;
updateSkinModel(img.src, isLegacy, mainMenuPlayerGroup, mainMenuSkinRenderMode);
};
img.src = url;
} else {
console.log("No skin found at " + skinPath);
}
} catch (e) {
console.warn("Could not load main menu skin (startup race condition?):", e);
}
}
function toggleMainSkinRenderMode() {
mainMenuSkinRenderMode = mainMenuSkinRenderMode === '3d' ? '2d' : '3d';
const modeButton = document.getElementById('btn-skin-render-mode');
if (modeButton) modeButton.textContent = mainMenuSkinRenderMode.toUpperCase();
const container = document.getElementById('main-skin-viewer');
if (container) {
container.style.cursor = mainMenuSkinRenderMode === '3d' ? 'grab' : 'default';
}
isMainSkinDragging = false;
if (mainMenuSkinRenderMode === '2d' && mainMenuPlayerGroup) {
mainMenuPlayerGroup.rotation.y = 0;
}
loadMainMenuSkin();
}
function updateSkinModel(dataUrl, isLegacy, targetGroup, renderMode = '3d') {
if (!targetGroup) return;
new THREE.TextureLoader().load(dataUrl, (texture) => {
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.encoding = THREE.sRGBEncoding;
// Clear existing children
while(targetGroup.children.length > 0) targetGroup.remove(targetGroup.children[0]);
const createFaceMaterial = (tex, x, y, w, h) => {
const texWidth = tex.image.width;
const texHeight = tex.image.height;
const matTex = tex.clone();
matTex.magFilter = THREE.NearestFilter;
matTex.minFilter = THREE.NearestFilter;
matTex.repeat.set(w / texWidth, h / texHeight);
matTex.offset.set(x / texWidth, 1 - (y + h) / texHeight);
matTex.needsUpdate = true;
return new THREE.MeshLambertMaterial({ map: matTex, transparent: true, alphaTest: 0.5, side: THREE.FrontSide });
};
const createBodyPart = (w, h, d, tex, uv, offset = 0) => {
const geometry = new THREE.BoxGeometry(w, h, d);
const materials = [
createFaceMaterial(tex, uv.left[0], uv.left[1], uv.left[2], uv.left[3]), // Left (Standard MC "Right")
createFaceMaterial(tex, uv.right[0], uv.right[1], uv.right[2], uv.right[3]), // Right (Standard MC "Left")
createFaceMaterial(tex, uv.top[0], uv.top[1], uv.top[2], uv.top[3]),
createFaceMaterial(tex, uv.bottom[0], uv.bottom[1], uv.bottom[2], uv.bottom[3]),
createFaceMaterial(tex, uv.front[0], uv.front[1], uv.front[2], uv.front[3]),
createFaceMaterial(tex, uv.back[0], uv.back[1], uv.back[2], uv.back[3])
];
const mesh = new THREE.Mesh(geometry, materials);
if (offset !== 0) mesh.scale.set(1 + offset, 1 + offset, 1 + offset);
return mesh;
};
const limbUv = (x, y) => ({
top: [x+4, y, 4, 4], bottom: [x+8, y, 4, 4],
right: [x, y+4, 4, 12], front: [x+4, y+4, 4, 12],
left: [x+8, y+4, 4, 12], back: [x+12, y+4, 4, 12]
});
const create2DPart = (tex, uvFront, width, height, x, y, scale = 1) => {
const texWidth = tex.image.width;
const texHeight = tex.image.height;
const partTex = tex.clone();
partTex.magFilter = THREE.NearestFilter;
partTex.minFilter = THREE.NearestFilter;
partTex.repeat.set(uvFront[2] / texWidth, uvFront[3] / texHeight);
partTex.offset.set(uvFront[0] / texWidth, 1 - (uvFront[1] + uvFront[3]) / texHeight);
partTex.needsUpdate = true;
const geometry = new THREE.PlaneGeometry(width, height);
const material = new THREE.MeshBasicMaterial({ map: partTex, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, 0);
mesh.scale.set(scale, scale, 1);
return mesh;
};
if (renderMode === '2d') {
const frontParts = [
create2DPart(texture, [8, 8, 8, 8], 8, 8, 0, 10),
create2DPart(texture, [40, 8, 8, 8], 8, 8, 0, 10, 1.12),
create2DPart(texture, [20, 20, 8, 12], 8, 12, 0, 0),
create2DPart(texture, [44, 20, 4, 12], 4, 12, -6, 0),
create2DPart(texture, isLegacy ? [44, 20, 4, 12] : [36, 52, 4, 12], 4, 12, 6, 0),
create2DPart(texture, [4, 20, 4, 12], 4, 12, -2, -12),
create2DPart(texture, isLegacy ? [4, 20, 4, 12] : [20, 52, 4, 12], 4, 12, 2, -12)
];
if (!isLegacy) {
frontParts.push(
create2DPart(texture, [20, 36, 8, 12], 8, 12, 0, 0, 1.05),
create2DPart(texture, [44, 36, 4, 12], 4, 12, -6, 0, 1.05),
create2DPart(texture, [52, 52, 4, 12], 4, 12, 6, 0, 1.05),
create2DPart(texture, [4, 36, 4, 12], 4, 12, -2, -12, 1.05),
create2DPart(texture, [4, 52, 4, 12], 4, 12, 2, -12, 1.05)
);
}
frontParts.forEach((part) => targetGroup.add(part));
targetGroup.rotation.y = 0;
return;
}
// Head
const headUvs = { top: [8, 0, 8, 8], bottom: [16, 0, 8, 8], right: [0, 8, 8, 8], left: [16, 8, 8, 8], front: [8, 8, 8, 8], back: [24, 8, 8, 8] };
const head = createBodyPart(8, 8, 8, texture, headUvs);
head.position.y = 10;
targetGroup.add(head);
// Hat
const hatUvs = { top: [40, 0, 8, 8], bottom: [48, 0, 8, 8], right: [32, 8, 8, 8], left: [48, 8, 8, 8], front: [40, 8, 8, 8], back: [56, 8, 8, 8] };
const hat = createBodyPart(8, 8, 8, texture, hatUvs, 0.12);
hat.position.y = 10;
targetGroup.add(hat);
// Torso
const torsoUvs = { top: [20, 16, 8, 4], bottom: [28, 16, 8, 4], right: [16, 20, 4, 12], left: [28, 20, 4, 12], front: [20, 20, 8, 12], back: [32, 20, 8, 12] };
targetGroup.add(createBodyPart(8, 12, 4, texture, torsoUvs));
// Jacket (non-legacy only)
if (!isLegacy) {
const jacketUvs = { top: [20, 32, 8, 4], bottom: [28, 32, 8, 4], right: [16, 36, 4, 12], left: [28, 36, 4, 12], front: [20, 36, 8, 12], back: [32, 36, 8, 12] };
targetGroup.add(createBodyPart(8, 12, 4, texture, jacketUvs, 0.05));
}
// Limbs
const limbs = [
{ pos: [-6, 0, 0], uv: limbUv(40, 16), layerUv: limbUv(40, 32) },
{ pos: [6, 0, 0], uv: isLegacy ? limbUv(40, 16) : limbUv(32, 48), layerUv: limbUv(48, 48) },
{ pos: [-2, -12, 0], uv: limbUv(0, 16), layerUv: limbUv(0, 32) },
{ pos: [2, -12, 0], uv: isLegacy ? limbUv(0, 16) : limbUv(16, 48), layerUv: limbUv(0, 48) }
];
limbs.forEach(l => {
const base = createBodyPart(4, 12, 4, texture, l.uv);
base.position.set(...l.pos);
targetGroup.add(base);
if (!isLegacy) {
const layer = createBodyPart(4, 12, 4, texture, l.layerUv, 0.05);
layer.position.set(...l.pos);
targetGroup.add(layer);
}
});
targetGroup.rotation.y = 0;
});
}
function openSkinManager() {
const modal = document.getElementById('skin-modal');
modal.style.display = 'flex';
modal.style.opacity = '1';
// Clear previous state in modal
const previewContainer = document.getElementById('preview-container');
if (previewContainer) previewContainer.classList.add('hidden');
const sysMsg = document.getElementById('sys-message');
if (sysMsg) sysMsg.classList.add('hidden');
// Load current skin into preview (optional, maybe we only want to see uploaded ones)
// loadCurrentSkinToPreview();
}
function closeSkinManager() {
const modal = document.getElementById('skin-modal');
modal.style.opacity = '0';
setTimeout(() => {
modal.style.display = 'none';
// Reset state
const prompt = document.getElementById('upload-prompt');
if (prompt) prompt.style.display = 'block';
const previewContainer = document.getElementById('preview-container');
if (previewContainer) previewContainer.classList.add('hidden');
processedSkinDataUrl = null;
}, 300);
}
function handleSkinFile(file) {
if (!file || !file.type.includes('png')) return window.showToast("Only PNG files are supported!");
const prompt = document.getElementById('upload-prompt');
if (prompt) prompt.style.display = 'none';
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
processSkinImage(img, e.target.result);
};
img.onerror = () => {
window.showToast("Failed to load image");
if(prompt) prompt.style.display = 'block';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function processSkinImage(img, srcUrl, isInitialLoad = false) {
const canvas = document.getElementById('skin-canvas');
const ctx = canvas.getContext('2d');
const formatLabel = document.getElementById('format-label');
const statusMessage = document.getElementById('status-message');
const previewContainer = document.getElementById('preview-container');
const saveBtn = document.getElementById('save-skin-btn');
const prompt = document.getElementById('upload-prompt');
if (img.width !== 64) {
if(prompt) prompt.style.display = 'block';
if(previewContainer) previewContainer.classList.add('hidden');
return window.showToast("Invalid skin width. Must be 64px.");
}
if(prompt) prompt.style.display = 'none';
const isLegacy = img.height === 32;
ctx.clearRect(0, 0, 64, 32);
ctx.drawImage(img, 0, 0, 64, 32, 0, 0, 64, 32);
processedSkinDataUrl = canvas.toDataURL('image/png');
previewContainer.classList.remove('hidden');
if (isInitialLoad) {
if(formatLabel) formatLabel.textContent = "Current Skin";
if(statusMessage) statusMessage.innerHTML = "<span class='text-blue-400 font-black' style='color: #60a5fa;'>LOADED FROM DISK</span>";
if(saveBtn) {
saveBtn.textContent = "SAVED";
saveBtn.classList.add('disabled');
}
} else {
if(formatLabel) formatLabel.textContent = isLegacy ? "64x32 (Legacy)" : "64x64 (Modern)";
if(statusMessage) statusMessage.innerHTML = isLegacy ? "<span class='text-green-400 font-black' style='color: #4ade80;'>LEGACY READY</span>" : "<span class='text-yellow-400 font-black' style='color: #facc15;'>CONVERTED TO 64x32</span>";
if(saveBtn) {
saveBtn.textContent = "SAVE SKIN";
saveBtn.classList.remove('disabled');
}
}
if (!skinScene) initPreviewEngine();
updateSkinModel(srcUrl, isLegacy, skinPlayerGroup, '3d');
}
function initPreviewEngine() {
const container = document.getElementById('skin-viewer-container');
if (!container) return;
skinScene = new THREE.Scene();
skinCamera = new THREE.PerspectiveCamera(35, container.offsetWidth / container.offsetHeight, 0.1, 1000);
skinCamera.position.set(0, 5, 70);
skinRenderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
skinRenderer.setSize(container.offsetWidth, container.offsetHeight);
skinRenderer.setPixelRatio(window.devicePixelRatio);
skinRenderer.outputEncoding = THREE.sRGBEncoding;
container.appendChild(skinRenderer.domElement);
skinScene.add(new THREE.AmbientLight(0xffffff, 0.9));
const dl = new THREE.DirectionalLight(0xffffff, 0.35);
dl.position.set(10, 20, 15);
skinScene.add(dl);
skinPlayerGroup = new THREE.Group();
skinScene.add(skinPlayerGroup);
container.addEventListener('mousedown', () => isSkinDragging = true);
window.addEventListener('mouseup', () => isSkinDragging = false);
window.addEventListener('mousemove', (e) => {
if (isSkinDragging && skinPlayerGroup) {
skinPlayerGroup.rotation.y += (e.movementX) * 0.01;
}
});
function animate() {
requestAnimationFrame(animate);
if (!isSkinDragging && skinPlayerGroup) skinPlayerGroup.rotation.y += 0.008;
if (skinRenderer && skinScene && skinCamera) skinRenderer.render(skinScene, skinCamera);
}
animate();
}
async function saveSkinToDisk() {
if (!processedSkinDataUrl) return;
try {
const installDir = await window.getInstallDir();
const savePath = path.join(installDir, 'Common', 'res', 'mob', 'char.png');
const dir = path.dirname(savePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const base64Data = processedSkinDataUrl.replace(/^data:image\/png;base64,/, "");
fs.writeFileSync(savePath, base64Data, 'base64');
window.showToast("Skin Saved Successfully!");
const saveBtn = document.getElementById('save-skin-btn');
if(saveBtn) {
saveBtn.textContent = "SAVED!";
saveBtn.classList.add('disabled');
}
// Refresh main menu skin
loadMainMenuSkin();
// Close modal after short delay?
setTimeout(closeSkinManager, 1000);
} catch (e) {
window.showToast("Error Saving Skin: " + e.message);
console.error(e);
}
}
// Global Export
window.openSkinManager = openSkinManager;
window.initMainMenuSkinViewer = initMainMenuSkinViewer;
window.loadMainMenuSkin = loadMainMenuSkin;
window.toggleMainSkinRenderMode = toggleMainSkinRenderMode;