mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-24 17:11:00 +00:00
feat(SkinsView): new UI refresh (wip), add ency and individul
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 678 B |
@@ -0,0 +1,614 @@
|
||||
import { useEffect, useRef, memo } from "react";
|
||||
import * as THREE from "three";
|
||||
type UVSet = Record<string, number[]>;
|
||||
interface SkinModel3DProps {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
capeUrl?: string | null;
|
||||
animate?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const clamp = (v: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, v));
|
||||
|
||||
let snapshotChain: Promise<void> = Promise.resolve();
|
||||
const snapshotCache = new Map<string, string>();
|
||||
function createFaceMaterial(
|
||||
tex: THREE.Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
flipX = false,
|
||||
flipY = false,
|
||||
) {
|
||||
const img = tex.image as { width: number; height: number } | undefined;
|
||||
const imgH = img && img.height ? img.height : 64;
|
||||
const matTex = tex.clone();
|
||||
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / imgH);
|
||||
matTex.offset.set((flipX ? x + w : x) / 64, 1 - (flipY ? y : y + h) / imgH);
|
||||
matTex.needsUpdate = true;
|
||||
return new THREE.MeshLambertMaterial({
|
||||
map: matTex,
|
||||
transparent: true,
|
||||
alphaTest: 0.5,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
}
|
||||
|
||||
function createPart(
|
||||
tex: THREE.Texture,
|
||||
w: number,
|
||||
h: number,
|
||||
d: number,
|
||||
uv: UVSet,
|
||||
overlayUv?: UVSet,
|
||||
swapMats = false,
|
||||
isLegacyMirror = false,
|
||||
) {
|
||||
const group = new THREE.Group();
|
||||
const geo = new THREE.BoxGeometry(w, h, d);
|
||||
const getMats = (uvSet: UVSet) => {
|
||||
const flipX = isLegacyMirror;
|
||||
return [
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
swapMats ? uvSet.right[0] : uvSet.left[0],
|
||||
uvSet.left[1],
|
||||
uvSet.left[2],
|
||||
uvSet.left[3],
|
||||
flipX,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
swapMats ? uvSet.left[0] : uvSet.right[0],
|
||||
uvSet.right[1],
|
||||
uvSet.right[2],
|
||||
uvSet.right[3],
|
||||
flipX,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.top[0],
|
||||
uvSet.top[1],
|
||||
uvSet.top[2],
|
||||
uvSet.top[3],
|
||||
flipX,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.bottom[0],
|
||||
uvSet.bottom[1],
|
||||
uvSet.bottom[2],
|
||||
uvSet.bottom[3],
|
||||
flipX,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.front[0],
|
||||
uvSet.front[1],
|
||||
uvSet.front[2],
|
||||
uvSet.front[3],
|
||||
flipX,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.back[0],
|
||||
uvSet.back[1],
|
||||
uvSet.back[2],
|
||||
uvSet.back[3],
|
||||
!flipX,
|
||||
),
|
||||
];
|
||||
};
|
||||
group.add(new THREE.Mesh(geo, getMats(uv)));
|
||||
if (overlayUv) {
|
||||
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
|
||||
group.add(new THREE.Mesh(oGeo, getMats(overlayUv)));
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
const limbUv = (x: number, y: number, w = 4): UVSet => ({
|
||||
top: [x + 4, y, w, 4],
|
||||
bottom: [x + 4 + w, y, w, 4],
|
||||
right: [x, y + 4, 4, 12],
|
||||
front: [x + 4, y + 4, w, 12],
|
||||
left: [x + 4 + w, y + 4, 4, 12],
|
||||
back: [x + 8 + w, y + 4, w, 12],
|
||||
});
|
||||
|
||||
function buildPlayer(
|
||||
playerGroup: THREE.Group,
|
||||
texture: THREE.Texture,
|
||||
slimOpt?: boolean,
|
||||
) {
|
||||
const img = texture.image as HTMLImageElement;
|
||||
const isLegacy = (img?.height || 64) === 32;
|
||||
const isSlim =
|
||||
slimOpt !== undefined
|
||||
? slimOpt
|
||||
: !isLegacy &&
|
||||
(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img?.width || 64;
|
||||
canvas.height = img?.height || 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return false;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
try {
|
||||
return ctx.getImageData(42, 48, 1, 1).data[3] === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const armW = isSlim ? 3 : 4;
|
||||
const headUv: UVSet = {
|
||||
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 hatUv: UVSet = {
|
||||
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 head = createPart(texture, 8, 8, 8, headUv, hatUv);
|
||||
head.position.y = 10;
|
||||
playerGroup.add(head);
|
||||
const bodyUv: UVSet = {
|
||||
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],
|
||||
};
|
||||
const jacketUv: UVSet = {
|
||||
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],
|
||||
};
|
||||
playerGroup.add(
|
||||
createPart(texture, 8, 12, 4, bodyUv, isLegacy ? undefined : jacketUv),
|
||||
);
|
||||
|
||||
const rightArm = createPart(
|
||||
texture,
|
||||
armW,
|
||||
12,
|
||||
4,
|
||||
limbUv(40, 16, armW),
|
||||
isLegacy ? undefined : limbUv(40, 32, armW),
|
||||
);
|
||||
rightArm.position.set(isSlim ? -5.5 : -6, 0, 0);
|
||||
playerGroup.add(rightArm);
|
||||
const leftArm = createPart(
|
||||
texture,
|
||||
armW,
|
||||
12,
|
||||
4,
|
||||
isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW),
|
||||
isLegacy ? undefined : limbUv(48, 48, armW),
|
||||
isLegacy,
|
||||
isLegacy,
|
||||
);
|
||||
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
|
||||
playerGroup.add(leftArm);
|
||||
const rightLeg = createPart(
|
||||
texture,
|
||||
4,
|
||||
12,
|
||||
4,
|
||||
limbUv(0, 16),
|
||||
isLegacy ? undefined : limbUv(0, 32),
|
||||
);
|
||||
rightLeg.position.set(-2, -12, 0);
|
||||
playerGroup.add(rightLeg);
|
||||
const leftLeg = createPart(
|
||||
texture,
|
||||
4,
|
||||
12,
|
||||
4,
|
||||
isLegacy ? limbUv(0, 16) : limbUv(16, 48),
|
||||
isLegacy ? undefined : limbUv(0, 48),
|
||||
isLegacy,
|
||||
isLegacy,
|
||||
);
|
||||
leftLeg.position.set(2, -12, 0);
|
||||
playerGroup.add(leftLeg);
|
||||
}
|
||||
|
||||
function addCape(playerGroup: THREE.Group, texture: THREE.Texture) {
|
||||
const capeUv: UVSet = {
|
||||
top: [1, 0, 10, 1],
|
||||
bottom: [11, 0, 10, 1],
|
||||
right: [0, 1, 1, 16],
|
||||
front: [1, 1, 10, 16],
|
||||
left: [11, 1, 1, 16],
|
||||
back: [12, 1, 10, 16],
|
||||
};
|
||||
const capeGroup = new THREE.Group();
|
||||
const capeGeo = new THREE.BoxGeometry(10, 16, 1);
|
||||
const capeMats = [
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.left[0],
|
||||
capeUv.left[1],
|
||||
capeUv.left[2],
|
||||
capeUv.left[3],
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.right[0],
|
||||
capeUv.right[1],
|
||||
capeUv.right[2],
|
||||
capeUv.right[3],
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.top[0],
|
||||
capeUv.top[1],
|
||||
capeUv.top[2],
|
||||
capeUv.top[3],
|
||||
false,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.bottom[0],
|
||||
capeUv.bottom[1],
|
||||
capeUv.bottom[2],
|
||||
capeUv.bottom[3],
|
||||
false,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.back[0],
|
||||
capeUv.back[1],
|
||||
capeUv.back[2],
|
||||
capeUv.back[3],
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.front[0],
|
||||
capeUv.front[1],
|
||||
capeUv.front[2],
|
||||
capeUv.front[3],
|
||||
),
|
||||
];
|
||||
const capeMesh = new THREE.Mesh(capeGeo, capeMats);
|
||||
capeMesh.position.set(0, -8, -0.5);
|
||||
capeGroup.add(capeMesh);
|
||||
capeGroup.position.set(0, 6, -2.35);
|
||||
capeGroup.rotation.x = 0.15;
|
||||
playerGroup.add(capeGroup);
|
||||
}
|
||||
|
||||
function fitCamera(camera: THREE.PerspectiveCamera, target: THREE.Object3D) {
|
||||
const box = new THREE.Box3().setFromObject(target);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
const vFov = THREE.MathUtils.degToRad(camera.fov);
|
||||
const distance = maxDim / (2 * Math.tan(vFov / 2) * 0.72);
|
||||
camera.position.set(center.x, center.y, center.z + distance);
|
||||
camera.lookAt(center);
|
||||
}
|
||||
|
||||
function addLights(scene: THREE.Scene) {
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dl.position.set(10, 20, 10);
|
||||
scene.add(dl);
|
||||
}
|
||||
|
||||
function disposeScene(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
extraTextures: THREE.Texture[],
|
||||
) {
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
if (object.geometry) object.geometry.dispose();
|
||||
const mats = Array.isArray(object.material)
|
||||
? object.material
|
||||
: [object.material];
|
||||
mats.forEach((m) => {
|
||||
if (m.map) m.map.dispose();
|
||||
m.dispose();
|
||||
});
|
||||
}
|
||||
});
|
||||
extraTextures.forEach((t) => t.dispose());
|
||||
renderer.dispose();
|
||||
}
|
||||
|
||||
function renderSkinSnapshot(opts: {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
capeUrl?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = opts.width;
|
||||
canvas.height = opts.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("no 2d context"));
|
||||
return;
|
||||
}
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
35,
|
||||
opts.width / opts.height,
|
||||
0.1,
|
||||
1000,
|
||||
);
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(opts.width, opts.height);
|
||||
addLights(scene);
|
||||
const playerGroup = new THREE.Group();
|
||||
scene.add(playerGroup);
|
||||
const extraTextures: THREE.Texture[] = [];
|
||||
let active = true;
|
||||
let skinDone = false;
|
||||
let capeDone = !opts.capeUrl;
|
||||
const finish = () => {
|
||||
if (!active || !skinDone || !capeDone) return;
|
||||
fitCamera(camera, playerGroup);
|
||||
renderer.render(scene, camera);
|
||||
ctx.drawImage(renderer.domElement, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
disposeScene(scene, renderer, extraTextures);
|
||||
active = false;
|
||||
resolve(dataUrl);
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
disposeScene(scene, renderer, extraTextures);
|
||||
reject(err);
|
||||
};
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.load(
|
||||
opts.src,
|
||||
(tex) => {
|
||||
if (!active) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
buildPlayer(playerGroup, tex, opts.slim);
|
||||
extraTextures.push(tex);
|
||||
skinDone = true;
|
||||
finish();
|
||||
},
|
||||
undefined,
|
||||
fail,
|
||||
);
|
||||
if (opts.capeUrl) {
|
||||
loader.load(
|
||||
opts.capeUrl,
|
||||
(tex) => {
|
||||
if (!active) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
addCape(playerGroup, tex);
|
||||
extraTextures.push(tex);
|
||||
capeDone = true;
|
||||
finish();
|
||||
},
|
||||
undefined,
|
||||
fail,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getSkinModelSnapshot(opts: {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
capeUrl?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Promise<string> {
|
||||
const key = `${opts.src}\u0000${opts.slim ? 1 : 0}\u0000${opts.capeUrl || ""}`;
|
||||
const hit = snapshotCache.get(key);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
const result = snapshotChain.then(() => renderSkinSnapshot(opts));
|
||||
snapshotChain = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
result.then((dataUrl) => snapshotCache.set(key, dataUrl)).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
const SkinModel3D = memo(function SkinModel3D({
|
||||
src,
|
||||
slim,
|
||||
capeUrl,
|
||||
animate,
|
||||
className,
|
||||
}: SkinModel3DProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mountRef.current;
|
||||
if (!el) return;
|
||||
const width = el.clientWidth || 240;
|
||||
const height = el.clientHeight || 320;
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(width, height);
|
||||
el.innerHTML = "";
|
||||
el.appendChild(renderer.domElement);
|
||||
addLights(scene);
|
||||
const playerGroup = new THREE.Group();
|
||||
scene.add(playerGroup);
|
||||
const extraTextures: THREE.Texture[] = [];
|
||||
let built = false;
|
||||
let disposed = false;
|
||||
let rotY = -0.35;
|
||||
let rotX = 0.06;
|
||||
let dragging = false;
|
||||
let raf = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
const render = () => {
|
||||
if (!disposed) renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
dragging = true;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
};
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging || disposed) return;
|
||||
rotY += (e.clientX - lastX) * 0.01;
|
||||
rotX = clamp(rotX + (e.clientY - lastY) * 0.01, -0.6, 0.6);
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
dragging = false;
|
||||
};
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
dragging = true;
|
||||
lastX = t.clientX;
|
||||
lastY = t.clientY;
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!dragging || disposed) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
rotY += (t.clientX - lastX) * 0.01;
|
||||
rotX = clamp(rotX + (t.clientY - lastY) * 0.01, -0.6, 0.6);
|
||||
lastX = t.clientX;
|
||||
lastY = t.clientY;
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
dragging = false;
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (animate && !dragging) rotY += 0.004;
|
||||
if (animate || dragging) {
|
||||
playerGroup.rotation.y = rotY;
|
||||
playerGroup.rotation.x = rotX;
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (!mountRef.current || disposed) return;
|
||||
const w = mountRef.current.clientWidth || width;
|
||||
const h = mountRef.current.clientHeight || height;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (built) fitCamera(camera, playerGroup);
|
||||
render();
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onTouchEnd);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
disposeScene(scene, renderer, extraTextures);
|
||||
};
|
||||
|
||||
const loader = new THREE.TextureLoader();
|
||||
let skinLoaded = false;
|
||||
let capeLoaded = !capeUrl;
|
||||
const tryFinish = () => {
|
||||
if (!skinLoaded || !capeLoaded || disposed) return;
|
||||
built = true;
|
||||
fitCamera(camera, playerGroup);
|
||||
render();
|
||||
loop();
|
||||
};
|
||||
loader.load(
|
||||
src,
|
||||
(tex) => {
|
||||
if (disposed) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
buildPlayer(playerGroup, tex, slim);
|
||||
extraTextures.push(tex);
|
||||
skinLoaded = true;
|
||||
tryFinish();
|
||||
},
|
||||
undefined,
|
||||
() => dispose(),
|
||||
);
|
||||
if (capeUrl) {
|
||||
loader.load(
|
||||
capeUrl,
|
||||
(tex) => {
|
||||
if (disposed) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
addCape(playerGroup, tex);
|
||||
extraTextures.push(tex);
|
||||
capeLoaded = true;
|
||||
tryFinish();
|
||||
},
|
||||
undefined,
|
||||
() => dispose(),
|
||||
);
|
||||
}
|
||||
|
||||
renderer.domElement.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
renderer.domElement.addEventListener("touchstart", onTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", onTouchEnd);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return dispose;
|
||||
}, [src, slim, capeUrl, animate]);
|
||||
|
||||
return <div ref={mountRef} className={className} />;
|
||||
});
|
||||
|
||||
export default SkinModel3D;
|
||||
+101
-106
@@ -9,7 +9,7 @@ import {
|
||||
useSkin,
|
||||
useConfig,
|
||||
} from "../../context/LauncherContext";
|
||||
import SkinViewer from "../common/SkinViewer";
|
||||
import SkinModel3D, { getSkinModelSnapshot } from "../common/SkinModel3D";
|
||||
import CapePreview from "../common/CapePreview";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
|
||||
@@ -71,7 +71,7 @@ const DEFAULT_SKINS: SavedSkin[] = [
|
||||
url: "/Skins/PrismaChunk0.png",
|
||||
isSlim: false,
|
||||
},
|
||||
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true }, //neo: :c
|
||||
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true },
|
||||
{ id: "avalilac", name: "AvaLilac", url: "/Skins/ava.png", isSlim: true },
|
||||
{ id: "huckle", name: "Huckle", url: "/Skins/huckle.png", isSlim: true },
|
||||
{
|
||||
@@ -86,6 +86,18 @@ const DEFAULT_SKINS: SavedSkin[] = [
|
||||
url: "/Skins/tranq.png",
|
||||
isSlim: true,
|
||||
},
|
||||
{
|
||||
id: "individul",
|
||||
name: "individul",
|
||||
url: "/Skins/individul.png",
|
||||
isSlim: false,
|
||||
},
|
||||
{
|
||||
id: "ency",
|
||||
name: "ency_pt",
|
||||
url: "/Skins/ency.png",
|
||||
isSlim: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_CAPES: SavedCape[] = [
|
||||
@@ -93,57 +105,63 @@ const DEFAULT_CAPES: SavedCape[] = [
|
||||
{ id: "unused2", name: "Unused Cape 2", url: "/Capes/Unused_Cape_2.png" },
|
||||
];
|
||||
|
||||
const HeadPreview = memo(function HeadPreview({ src }: { src: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const SkinCardModel = memo(function SkinCardModel({
|
||||
src,
|
||||
slim,
|
||||
}: {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
}) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [snapshot, setSnapshot] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const cvs = canvasRef.current;
|
||||
if (!cvs) return;
|
||||
const ctx = cvs.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, cvs.width, cvs.height);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, cvs.width, cvs.height);
|
||||
if (img.height !== 32) {
|
||||
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, cvs.width, cvs.height);
|
||||
}
|
||||
let active = true;
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
const w = Math.max(48, Math.round(el.clientWidth * 2));
|
||||
const h = Math.max(64, Math.round(el.clientHeight * 2));
|
||||
getSkinModelSnapshot({ src, slim, width: w, height: h })
|
||||
.then((dataUrl) => {
|
||||
if (active) setSnapshot(dataUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
img.src = src;
|
||||
}, [src]);
|
||||
}, [src, slim]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={64}
|
||||
height={64}
|
||||
className="absolute w-full h-full"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
<div ref={boxRef} className="absolute inset-0">
|
||||
{snapshot ? (
|
||||
<img
|
||||
src={snapshot}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 w-full h-full object-contain drop-shadow-[0_6px_4px_rgba(0,0,0,0.6)] select-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-[#555] text-xs mc-text-shadow uppercase tracking-widest animate-pulse select-none">
|
||||
...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const SkinsView = memo(function SkinsView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView, setIsUiHidden } = useUI();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { isAndroid } = usePlatform();
|
||||
const {
|
||||
skinUrl,
|
||||
setSkinUrl,
|
||||
skinIsSlim,
|
||||
setSkinIsSlim,
|
||||
capeUrl,
|
||||
setCapeUrl,
|
||||
} = useSkin();
|
||||
|
||||
const config = useConfig();
|
||||
const { skinUrl, setSkinUrl, setSkinIsSlim, capeUrl, setCapeUrl } = useSkin();
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"skin" | "cape">("skin");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const capeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>(
|
||||
"lce-custom-skins",
|
||||
[],
|
||||
@@ -534,15 +552,16 @@ const SkinsView = memo(function SkinsView() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-3xl h-full outline-none"
|
||||
transition={{ duration: config.animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-5xl h-full outline-none"
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest opacity-80 font-bold">
|
||||
{viewMode === "skin" ? t("skins.skinLibrary") : t("skins.capeLibrary")}
|
||||
</h2>
|
||||
|
||||
<div className="w-full max-w-160 flex-1 min-h-0 mb-4 p-5 flex flex-col relative overflow-hidden">
|
||||
<div className="w-full flex items-center gap-4 ml-5 pb-4 mb-4 min-h-10">
|
||||
<div className="w-full max-w-5xl flex-1 min-h-0 mb-4 flex gap-4">
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
data-index="0"
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
@@ -551,7 +570,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
if (viewMode === "skin") handleImportClick();
|
||||
else capeFileInputRef.current?.click();
|
||||
}}
|
||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
className={`w-44 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 0
|
||||
@@ -578,7 +597,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
if (viewMode === "skin") handleDeleteActive();
|
||||
else handleDeleteActiveCape();
|
||||
}}
|
||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
|
||||
className={`w-44 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
|
||||
(viewMode === "skin" && isActiveDefault) ||
|
||||
(viewMode === "cape" && isCapeDeleteDisabled)
|
||||
? "text-gray-400 opacity-80 cursor-not-allowed"
|
||||
@@ -611,7 +630,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
playPressSound();
|
||||
setActiveView("skin-editor");
|
||||
}}
|
||||
className={`w-40 h-10 flex items-center
|
||||
className={`w-44 h-10 flex items-center
|
||||
justify-center transition-colors text-2xl
|
||||
mc-text-shadow outline-none border-none hover:text-[#FFFF55]
|
||||
${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
@@ -628,8 +647,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
<div className="flex justify-end z-10">
|
||||
<div className="flex-1 min-w-4" />
|
||||
<button
|
||||
data-index={viewMode === "skin" ? 3 : 2}
|
||||
onMouseEnter={() => setFocusIndex(viewMode === "skin" ? 3 : 2)}
|
||||
@@ -637,7 +655,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
playPressSound();
|
||||
setViewMode(viewMode === "skin" ? "cape" : "skin");
|
||||
}}
|
||||
className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
|
||||
className="mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all"
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === (viewMode === "skin" ? 3 : 2)
|
||||
@@ -660,8 +678,6 @@ const SkinsView = memo(function SkinsView() {
|
||||
decoding="async"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -678,7 +694,8 @@ const SkinsView = memo(function SkinsView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 flex flex-wrap gap-x-4 gap-y-6 items-start content-start justify-center hidden-scrollbar">
|
||||
<div className="mc-options-bg flex-1 min-h-0 overflow-y-auto p-4 hidden-scrollbar">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{viewMode === "skin" ? (
|
||||
savedSkins.map((skin, i) => {
|
||||
const idx = SKINS_START_INDEX + i;
|
||||
@@ -686,33 +703,29 @@ const SkinsView = memo(function SkinsView() {
|
||||
? activeSkinId === skin.id
|
||||
: skinUrl === skin.url;
|
||||
const isFocused = focusIndex === idx;
|
||||
const isHighlight = isActive || isFocused;
|
||||
return (
|
||||
<div
|
||||
key={skin.id}
|
||||
data-index={idx}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className="flex flex-col items-center gap-1 w-32 outline-none"
|
||||
className="flex flex-col items-center gap-1.5 outline-none"
|
||||
>
|
||||
<div className="h-4 flex items-center justify-center gap-1">
|
||||
{isActive && (
|
||||
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
|
||||
{t("skins.active")}
|
||||
</span>
|
||||
)}
|
||||
{skin.isSlim && (
|
||||
<span className="bg-purple-500/50 border border-purple-500/80 text-white px-1 text-[10px] uppercase rounded">
|
||||
{t("skins.slim")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleSkinSelect(skin)}
|
||||
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer flex items-center justify-center ${isHighlight ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
style={{ aspectRatio: "3 / 4" }}
|
||||
>
|
||||
<HeadPreview src={skin.url} />
|
||||
<SkinCardModel
|
||||
src={skin.url}
|
||||
slim={Boolean(skin.isSlim)}
|
||||
/>
|
||||
{isHighlight && (
|
||||
<div className="pointer-events-none absolute" />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="w-full mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={skin.name}
|
||||
@@ -720,7 +733,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
onChange={(e) =>
|
||||
handleNameChange(skin.id, e.target.value)
|
||||
}
|
||||
className={`w-full h-10 px-3 text-base text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultSkin(skin.id) ? "pointer-events-none" : ""}`}
|
||||
className={`mc-textinput w-full h-9 px-2 text-sm text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isHighlight ? "text-[#FFFF55]" : "text-white"} ${isDefaultSkin(skin.id) ? "pointer-events-none" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
spellCheck={false}
|
||||
readOnly={isDefaultSkin(skin.id)}
|
||||
@@ -735,27 +748,27 @@ const SkinsView = memo(function SkinsView() {
|
||||
data-index={SKINS_START_INDEX}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)}
|
||||
className="flex flex-col items-center gap-1 w-32 outline-none"
|
||||
className="flex flex-col items-center gap-1.5 outline-none"
|
||||
>
|
||||
<div className="h-4 flex items-center justify-center gap-1">
|
||||
{isActiveCapeDefault && (
|
||||
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setCapeUrl(null);
|
||||
setActiveCapeId(null);
|
||||
}}
|
||||
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none flex items-center justify-center ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer flex items-center justify-center ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
style={{ aspectRatio: "3 / 4" }}
|
||||
>
|
||||
<span className="text-gray-500 text-2xl">X</span>
|
||||
<span className="text-gray-500 text-4xl select-none">
|
||||
X
|
||||
</span>
|
||||
{(isActiveCapeDefault ||
|
||||
focusIndex === SKINS_START_INDEX) && (
|
||||
<div className="pointer-events-none absolute" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "text-[#FFFF55]" : "text-white"}`}
|
||||
className={`text-center outline-none border-none text-sm mc-text-shadow w-full truncate transition-colors ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "text-[#FFFF55]" : "text-white"}`}
|
||||
>
|
||||
{t("skins.noCape")}
|
||||
</span>
|
||||
@@ -766,28 +779,26 @@ const SkinsView = memo(function SkinsView() {
|
||||
? activeCapeId === cape.id
|
||||
: capeUrl === cape.url;
|
||||
const isFocused = focusIndex === idx;
|
||||
const isHighlight = isActive || isFocused;
|
||||
return (
|
||||
<div
|
||||
key={cape.id}
|
||||
data-index={idx}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className="flex flex-col items-center gap-1 w-32 outline-none"
|
||||
className="flex flex-col items-center gap-1.5 outline-none"
|
||||
>
|
||||
<div className="h-4 flex items-center justify-center gap-1">
|
||||
{isActive && (
|
||||
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleCapeSelect(cape)}
|
||||
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer ${isHighlight ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
style={{ aspectRatio: "3 / 4" }}
|
||||
>
|
||||
<CapePreview src={cape.url} />
|
||||
{isHighlight && (
|
||||
<div className="pointer-events-none absolute inset-0 shadow-[inset_0_0_16px_rgba(255,255,85,0.4)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mc-textinput-outer w-full">
|
||||
<div className="w-full mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={cape.name}
|
||||
@@ -795,7 +806,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
onChange={(e) =>
|
||||
handleCapeNameChange(cape.id, e.target.value)
|
||||
}
|
||||
className={`mc-textinput w-full h-10 px-3 text-base text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`}
|
||||
className={`mc-textinput w-full h-9 px-2 text-sm text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isHighlight ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
spellCheck={false}
|
||||
readOnly={isDefaultCape(cape.id)}
|
||||
@@ -808,23 +819,7 @@ const SkinsView = memo(function SkinsView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-160 shadow-2xl flex flex-col items-center">
|
||||
<SkinViewer
|
||||
username=""
|
||||
setUsername={() => {}}
|
||||
playPressSound={playPressSound}
|
||||
skinUrl={skinUrl}
|
||||
setSkinUrl={setSkinUrl}
|
||||
capeUrl={capeUrl}
|
||||
setActiveView={setActiveView}
|
||||
setIsUiHidden={setIsUiHidden}
|
||||
isFocusedSection={false}
|
||||
onNavigateRight={() => {}}
|
||||
hideControls
|
||||
style={{ top: "45%" }}
|
||||
slim={skinIsSlim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAndroid && (
|
||||
|
||||
Reference in New Issue
Block a user