From 42b3cf62e5f497b1fe9785a782b370b43cef89f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Carvalho?= Date: Tue, 18 Aug 2026 09:39:10 -0300 Subject: [PATCH] =?UTF-8?q?feat(skins):=202D=20integrated=20skin=20editor?= =?UTF-8?q?=20(direct/3D=20character=20painting=20=E2=80=A6=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: /home/neo <158327205+neoapps-dev@users.noreply.github.com> --- src/components/common/SkinEditorPreview.tsx | 220 ++++++ src/components/common/SkinViewer.tsx | 4 +- src/components/views/SkinEditorView.tsx | 721 ++++++++++++++++++++ src/components/views/SkinsView.tsx | 43 +- src/hooks/useDiscordRPC.ts | 1 + src/pages/App.tsx | 4 + 6 files changed, 986 insertions(+), 7 deletions(-) create mode 100644 src/components/common/SkinEditorPreview.tsx create mode 100644 src/components/views/SkinEditorView.tsx diff --git a/src/components/common/SkinEditorPreview.tsx b/src/components/common/SkinEditorPreview.tsx new file mode 100644 index 0000000..a34264a --- /dev/null +++ b/src/components/common/SkinEditorPreview.tsx @@ -0,0 +1,220 @@ +import { useEffect, useRef, memo } from "react"; +import * as THREE from "three"; + +type UVSet = Record; + +interface SkinEditorPreviewProps { + canvas: HTMLCanvasElement | null; + slim: boolean; + previewTick: number; +} + +const SkinEditorPreview = memo(function SkinEditorPreview({ + canvas, + slim, + previewTick, +}: SkinEditorPreviewProps) { + const mountRef = useRef(null); + const clonedTexsRef = useRef([]); + const requestRenderRef = useRef<(() => void) | null>(null); + const rebuildPlayerRef = useRef<((source: HTMLCanvasElement, isSlim: boolean) => void) | null>(null); + + useEffect(() => { + if (!mountRef.current) return; + + const width = mountRef.current.clientWidth || 240; + const height = mountRef.current.clientHeight || 400; + const scene = new THREE.Scene(); + + const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000); + camera.position.set(0, 0, 68); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setClearColor(0x000000, 0); + + mountRef.current.innerHTML = ""; + mountRef.current.appendChild(renderer.domElement); + + 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); + + const playerGroup = new THREE.Group(); + playerGroup.position.y = -1.5; + scene.add(playerGroup); + + const render = () => renderer.render(scene, camera); + requestRenderRef.current = render; + + const clearPlayer = () => { + while (playerGroup.children.length) { + const obj = playerGroup.children[0]; + playerGroup.remove(obj); + obj.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((mat) => { + if (mat.map) mat.map.dispose(); + mat.dispose(); + }); + } + }); + } + clonedTexsRef.current = []; + }; + + let hasInitRot = false; + rebuildPlayerRef.current = (source: HTMLCanvasElement, isSlim: boolean) => { + const rotX = playerGroup.rotation.x; + const rotY = playerGroup.rotation.y; + clearPlayer(); + const texH = source.height || 64; + const isLegacy = texH === 32; + const armW = isSlim ? 3 : 4; + const base = new THREE.CanvasTexture(source); + base.magFilter = THREE.NearestFilter; + base.minFilter = THREE.NearestFilter; + base.colorSpace = THREE.SRGBColorSpace; + base.needsUpdate = true; + + const createFaceMaterial = (x: number, y: number, w: number, h: number, flipX = false, flipY = false) => { + const matTex = base.clone(); + matTex.magFilter = THREE.NearestFilter; + matTex.minFilter = THREE.NearestFilter; + matTex.colorSpace = THREE.SRGBColorSpace; + matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / texH); + matTex.offset.set((flipX ? x + w : x) / 64, 1 - (flipY ? y : y + h) / texH); + matTex.needsUpdate = true; + clonedTexsRef.current.push(matTex); + return new THREE.MeshLambertMaterial({ map: matTex, transparent: true, alphaTest: 0.05, side: THREE.FrontSide }); + }; + + const createPart = (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; + //+x is the characters left from the camera, this isnt swapped by accident + return [ + createFaceMaterial(swapMats ? uvSet.right[0] : uvSet.left[0], uvSet.left[1], uvSet.left[2], uvSet.left[3], flipX), + createFaceMaterial(swapMats ? uvSet.left[0] : uvSet.right[0], uvSet.right[1], uvSet.right[2], uvSet.right[3], flipX), + createFaceMaterial(uvSet.top[0], uvSet.top[1], uvSet.top[2], uvSet.top[3], flipX, true), + createFaceMaterial(uvSet.bottom[0], uvSet.bottom[1], uvSet.bottom[2], uvSet.bottom[3], flipX, true), + createFaceMaterial(uvSet.front[0], uvSet.front[1], uvSet.front[2], uvSet.front[3], flipX), + createFaceMaterial(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] + }); + + const headUv = { 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 = { 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(8, 8, 8, headUv, hatUv); + head.position.y = 10; + playerGroup.add(head); + + const bodyUv = { 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 = isLegacy ? undefined : { 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(8, 12, 4, bodyUv, jacketUv)); + + const rightArm = createPart(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(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(4, 12, 4, limbUv(0, 16), isLegacy ? undefined : limbUv(0, 32)); + rightLeg.position.set(-2, -12, 0); + playerGroup.add(rightLeg); + + const leftLeg = createPart(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); + + clonedTexsRef.current.unshift(base); + playerGroup.rotation.x = rotX; + playerGroup.rotation.y = hasInitRot ? rotY : -0.35; + hasInitRot = true; + render(); + }; + + let isDragging = false; + let previousMousePosition = { x: 0, y: 0 }; + const onMouseDown = (e: MouseEvent) => { + isDragging = true; + previousMousePosition = { x: e.clientX, y: e.clientY }; + }; + const onMouseUp = () => { isDragging = false; }; + const onMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + playerGroup.rotation.y += (e.clientX - previousMousePosition.x) * 0.01; + playerGroup.rotation.x += (e.clientY - previousMousePosition.y) * 0.01; + previousMousePosition = { x: e.clientX, y: e.clientY }; + render(); + }; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + camera.position.z = Math.max(40, Math.min(120, camera.position.z + e.deltaY * 0.05)); + render(); + }; + + renderer.domElement.addEventListener("mousedown", onMouseDown); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + renderer.domElement.addEventListener("wheel", onWheel, { passive: false }); + + const handleResize = () => { + if (!mountRef.current) return; + const w = mountRef.current.clientWidth || width; + const h = mountRef.current.clientHeight || height; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + render(); + }; + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener("resize", handleResize); + renderer.domElement.removeEventListener("wheel", onWheel); + clearPlayer(); + renderer.dispose(); + requestRenderRef.current = null; + rebuildPlayerRef.current = null; + }; + }, []); + + useEffect(() => { + if (canvas) rebuildPlayerRef.current?.(canvas, slim); + }, [canvas, slim]); + + useEffect(() => { + clonedTexsRef.current.forEach((t) => { t.needsUpdate = true; }); + requestRenderRef.current?.(); + }, [previewTick]); + + return
; +}); + +export default SkinEditorPreview; diff --git a/src/components/common/SkinViewer.tsx b/src/components/common/SkinViewer.tsx index e3b409f..b5a1e6d 100644 --- a/src/components/common/SkinViewer.tsx +++ b/src/components/common/SkinViewer.tsx @@ -273,7 +273,7 @@ const SkinViewer = memo(function SkinViewer({ 4, isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW), isLegacy ? undefined : limbUv(48, 48, armW), - true, + isLegacy, isLegacy, ); leftArm.position.set(isSlim ? 5.5 : 6, 0, 0); @@ -293,7 +293,7 @@ const SkinViewer = memo(function SkinViewer({ 4, isLegacy ? limbUv(0, 16) : limbUv(16, 48), isLegacy ? undefined : limbUv(0, 48), - true, + isLegacy, isLegacy, ); leftLeg.position.set(2, -12, 0); diff --git a/src/components/views/SkinEditorView.tsx b/src/components/views/SkinEditorView.tsx new file mode 100644 index 0000000..4cf4ab0 --- /dev/null +++ b/src/components/views/SkinEditorView.tsx @@ -0,0 +1,721 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { motion } from "framer-motion"; +import { useUI, useAudio, useConfig, useSkin } from "../../context/LauncherContext"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { TauriService } from "../../services/TauriService"; +import SkinEditorPreview from "../common/SkinEditorPreview"; + +type Tool = "pencil" | "eraser" | "eyedropper" | "fill"; + +interface SavedSkin { + id: string; + name: string; + url: string; + isSlim?: boolean; +} + +const DYE_COLORS = [ + "#F9FFFE", "#9D9D97", "#474F52", "#1D1D21", + "#835432", "#B02E26", "#F9801D", "#FED83D", + "#80C71F", "#5E7C16", "#169C9C", "#3AB3DA", + "#3C44AA", "#8932B8", "#C74EBD", "#F38BAA", + "#FFE0BD", "#E0AC69", "#C68642", "#8D5524", + "#5C3317", "#B5804A", "#3C8C8F", "#4C4C4C", +]; + +function hexToRgba(hex: string, alpha: number) { + const h = hex.replace("#", ""); + return { + r: parseInt(h.slice(0, 2), 16) || 0, + g: parseInt(h.slice(2, 4), 16) || 0, + b: parseInt(h.slice(4, 6), 16) || 0, + a: Math.round(alpha * 255), + }; +} + +function rgbToHex(r: number, g: number, b: number) { + return "#" + [r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0")).join(""); +} + +function rgbToHsv(r: number, g: number, b: number) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h = 0; + if (d !== 0) { + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + return { h, s: max === 0 ? 0 : d / max, v: max }; +} + +function hsvToRgb(h: number, s: number, v: number) { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return { r: Math.round((r + m) * 255), g: Math.round((g + m) * 255), b: Math.round((b + m) * 255) }; +} + +function drawLine(x0: number, y0: number, x1: number, y1: number, plot: (x: number, y: number) => void) { + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + let x = x0; + let y = y0; + while (true) { + plot(x, y); + if (x === x1 && y === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x += sx; } + if (e2 < dx) { err += dx; y += sy; } + } +} + +function fillRegion(data: Uint8ClampedArray, w: number, h: number, x: number, y: number, color: { r: number; g: number; b: number; a: number }) { + const idx = (px: number, py: number) => (py * w + px) * 4; + const i = idx(x, y); + const tr = data[i], tg = data[i + 1], tb = data[i + 2], ta = data[i + 3]; + if (tr === color.r && tg === color.g && tb === color.b && ta === color.a) return; + const stack = [[x, y]]; + const seen = new Uint8Array(w * h); + while (stack.length) { + const popped = stack.pop(); + if (!popped) break; + const [px, py] = popped; + if (px < 0 || py < 0 || px >= w || py >= h) continue; + const si = py * w + px; + if (seen[si]) continue; + const pi = idx(px, py); + if (data[pi] !== tr || data[pi + 1] !== tg || data[pi + 2] !== tb || data[pi + 3] !== ta) continue; + seen[si] = 1; + data[pi] = color.r; + data[pi + 1] = color.g; + data[pi + 2] = color.b; + data[pi + 3] = color.a; + stack.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]); + } +} + +const SkinEditorView = memo(function SkinEditorView() { + const { setActiveView } = useUI(); + const { playPressSound, playBackSound } = useAudio(); + const { animationsEnabled, username } = useConfig(); + const { skinUrl, setSkinUrl, skinIsSlim, setSkinIsSlim } = useSkin(); + const [storedSkins, setStoredSkins] = useLocalStorage("lce-custom-skins", []); + + const cvsRef = useRef(null); + const gridRef = useRef(null); + const undoStack = useRef([]); + const isPainting = useRef(false); + const lastPx = useRef<{ x: number; y: number } | null>(null); + const colorRef = useRef("#8B8B8B"); + const alphaRef = useRef(1); + const toolRef = useRef("pencil"); + const pickerRef = useRef(null); + const satValCvsRef = useRef(null); + const hueCvsRef = useRef(null); + const isDraggingSatVal = useRef(false); + const isDraggingHue = useRef(false); + const rafRef = useRef(null); + + const [tool, setTool] = useState("pencil"); + const [color, setColor] = useState("#8B8B8B"); + const [hexValue, setHexValue] = useState("8B8B8B"); + const [hsv, setHsv] = useState(() => rgbToHsv(139, 139, 139)); + const [showPicker, setShowPicker] = useState(false); + const [alpha, setAlpha] = useState(1); + const [slim, setSlim] = useState(skinIsSlim); + const [showGrid, setShowGrid] = useState(true); + const [previewTick, setPreviewTick] = useState(0); + const [texHeight, setTexHeight] = useState(64); + const [previewCvs, setPreviewCvs] = useState(null); + const [focusBtn, setFocusBtn] = useState(null); + + const setColorFromHsv = (next: { h: number; s: number; v: number }) => { + setHsv(next); + const { r, g, b } = hsvToRgb(next.h, next.s, next.v); + setColor(rgbToHex(r, g, b)); + }; + + const setColorFromHex = (hex: string) => { + const normalized = hex.startsWith("#") ? hex : `#${hex}`; + setColor(normalized); + const { r, g, b } = hexToRgba(normalized, 1); + setHsv(rgbToHsv(r, g, b)); + }; + + colorRef.current = color; + alphaRef.current = alpha; + toolRef.current = tool; + + useEffect(() => { + setHexValue(color.replace("#", "").toUpperCase()); + }, [color]); + + const requestTick = () => { //dotn hit setSkinUrl from here, it would spam pck gen + if (rafRef.current != null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + setPreviewTick((n) => n + 1); + }); + }; + + const pushUndo = () => { + const cvs = cvsRef.current; + const ctx = cvs?.getContext("2d"); + if (!cvs || !ctx) return; + undoStack.current.push(ctx.getImageData(0, 0, cvs.width, cvs.height)); + if (undoStack.current.length > 50) undoStack.current.shift(); + }; + + const redrawGrid = useCallback(() => { + const cvs = cvsRef.current; + const grid = gridRef.current; + if (!cvs || !grid) return; + const ctx = grid.getContext("2d"); + if (!ctx) return; + const scale = grid.width / cvs.width; + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, grid.width, grid.height); + ctx.drawImage(cvs, 0, 0, grid.width, grid.height); + if (showGrid) { + ctx.strokeStyle = "rgba(255,255,255,0.18)"; + ctx.lineWidth = 1; + for (let i = 0; i <= cvs.width; i++) { + ctx.beginPath(); + ctx.moveTo(i * scale + 0.5, 0); + ctx.lineTo(i * scale + 0.5, grid.height); + ctx.stroke(); + } + for (let i = 0; i <= cvs.height; i++) { + ctx.beginPath(); + ctx.moveTo(0, i * scale + 0.5); + ctx.lineTo(grid.width, i * scale + 0.5); + ctx.stroke(); + } + } + }, [showGrid]); + + useEffect(() => { + let cancelled = false; + const cvs = document.createElement("canvas"); + cvsRef.current = cvs; + undoStack.current = []; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + if (cancelled) return; + cvs.width = 64; + cvs.height = img.height === 32 ? 32 : 64; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, cvs.width, cvs.height); + ctx.drawImage(img, 0, 0); + setTexHeight(cvs.height); + setPreviewCvs(cvs); + requestTick(); + }; + img.src = skinUrl || "/images/Default.png"; + return () => { cancelled = true; }; + }, [skinUrl]); + + useEffect(() => { + redrawGrid(); + }, [showGrid, previewTick, texHeight, redrawGrid]); + + useEffect(() => { + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + }; + }, []); + + useEffect(() => { + if (!showPicker) return; + const closePicker = (e: MouseEvent) => { + if (!pickerRef.current?.contains(e.target as Node)) setShowPicker(false); + }; + window.addEventListener("mousedown", closePicker); + return () => window.removeEventListener("mousedown", closePicker); + }, [showPicker]); + + useEffect(() => { + const satVal = satValCvsRef.current; + if (!satVal) return; + const ctx = satVal.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = false; + for (let y = 0; y < satVal.height; y++) { + for (let x = 0; x < satVal.width; x++) { + const { r, g, b } = hsvToRgb(hsv.h, x / (satVal.width - 1), 1 - y / (satVal.height - 1)); + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(x, y, 1, 1); + } + } + }, [hsv.h, showPicker]); + + useEffect(() => { + const hueBar = hueCvsRef.current; + if (!hueBar) return; + const ctx = hueBar.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = false; + for (let x = 0; x < hueBar.width; x++) { + const { r, g, b } = hsvToRgb((x / (hueBar.width - 1)) * 360, 1, 1); + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(x, 0, 1, hueBar.height); + } + }, [showPicker]); + + const handleSatValPick = (e: React.PointerEvent) => { + const canvas = satValCvsRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const s = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); + const v = Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height)); + setColorFromHsv({ ...hsv, s, v }); + }; + + const handleHuePick = (e: React.PointerEvent) => { + const canvas = hueCvsRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const h = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)) * 360; + setColorFromHsv({ ...hsv, h }); + }; + + const getGridPixel = (e: React.PointerEvent) => { + const grid = gridRef.current; + const cvs = cvsRef.current; + if (!grid || !cvs) return null; + const rect = grid.getBoundingClientRect(); + const x = Math.floor(((e.clientX - rect.left) / rect.width) * cvs.width); + const y = Math.floor(((e.clientY - rect.top) / rect.height) * cvs.height); + if (x < 0 || y < 0 || x >= cvs.width || y >= cvs.height) return null; + return { x, y }; + }; + + const paintPixel = (x: number, y: number, erase: boolean) => { + const ctx = cvsRef.current?.getContext("2d"); + if (!ctx) return; + if (erase) { + ctx.clearRect(x, y, 1, 1); + return; + } + const { r, g, b, a } = hexToRgba(colorRef.current, alphaRef.current); + ctx.clearRect(x, y, 1, 1); + ctx.fillStyle = `rgba(${r},${g},${b},${a / 255})`; + ctx.fillRect(x, y, 1, 1); + }; + + const applyTool = (x: number, y: number) => { + const cvs = cvsRef.current; + const ctx = cvs?.getContext("2d"); + if (!cvs || !ctx) return; + const currentTool = toolRef.current; + if (currentTool === "eyedropper") { + const p = ctx.getImageData(x, y, 1, 1).data; + const hex = "#" + [p[0], p[1], p[2]].map((n) => n.toString(16).padStart(2, "0")).join(""); + setColor(hex); + setHsv(rgbToHsv(p[0], p[1], p[2])); + setAlpha(p[3] / 255); + return; + } + if (currentTool === "fill") { + const img = ctx.getImageData(0, 0, cvs.width, cvs.height); + fillRegion(img.data, cvs.width, cvs.height, x, y, hexToRgba(colorRef.current, alphaRef.current)); + ctx.putImageData(img, 0, 0); + return; + } + paintPixel(x, y, currentTool === "eraser"); + }; + + const handleGridDown = (e: React.PointerEvent) => { + e.preventDefault(); + const p = getGridPixel(e); + if (!p) return; + e.currentTarget.setPointerCapture(e.pointerId); + pushUndo(); + isPainting.current = toolRef.current !== "eyedropper" && toolRef.current !== "fill"; + applyTool(p.x, p.y); + lastPx.current = p; + requestTick(); + }; + + const handleGridMove = (e: React.PointerEvent) => { + if (!isPainting.current) return; + const p = getGridPixel(e); + if (!p) return; + const last = lastPx.current; + const erase = toolRef.current === "eraser"; + if (last) drawLine(last.x, last.y, p.x, p.y, (x, y) => paintPixel(x, y, erase)); + else paintPixel(p.x, p.y, erase); + lastPx.current = p; + requestTick(); + }; + + const handleGridUp = (e: React.PointerEvent) => { + isPainting.current = false; + lastPx.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }; + + const handleUndo = useCallback(() => { + const snap = undoStack.current.pop(); + const ctx = cvsRef.current?.getContext("2d"); + if (!snap || !ctx) return; + ctx.putImageData(snap, 0, 0); + requestTick(); + }, []); + + const handleSave = () => { + const cvs = cvsRef.current; + if (!cvs) return; + playPressSound(); + const dataUrl = cvs.toDataURL("image/png"); + const existing = storedSkins.find((s) => s.url === skinUrl); + if (existing) { + setStoredSkins(storedSkins.map((s) => (s.id === existing.id ? { ...s, url: dataUrl, isSlim: slim } : s))); + } else { + setStoredSkins([...storedSkins, { id: Date.now().toString(), name: "Custom Skin", url: dataUrl, isSlim: slim }]); + } + setSkinUrl(dataUrl); + setSkinIsSlim(slim); + setActiveView("skins"); + }; + + const handleExport = async () => { + const cvs = cvsRef.current; + if (!cvs) return; + playPressSound(); + try { + const safeName = (username || "skin").replace(/[^a-zA-Z0-9_-]/g, "") || "skin"; + const fileName = `${safeName}.png`; + const path = await TauriService.saveFileDialog("Export Skin", fileName, []); + if (!path) return; + const outPath = path.toLowerCase().endsWith(".png") ? path : `${path}.png`; + const res = await fetch(cvs.toDataURL("image/png")); + const data = new Uint8Array(await res.arrayBuffer()); + await TauriService.writeBinaryFile(outPath, data); + } catch (err: unknown) { + if (err !== "CANCELED") console.error("Failed to export skin", err); + } + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (showPicker) { + setShowPicker(false); + return; + } + playBackSound(); + setActiveView("skins"); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { + e.preventDefault(); + handleUndo(); + return; + } + if (document.activeElement?.tagName === "INPUT") return; + if (e.key === "b" || e.key === "B") setTool("pencil"); + else if (e.key === "e" || e.key === "E") setTool("eraser"); + else if (e.key === "i" || e.key === "I") setTool("eyedropper"); + else if (e.key === "g" || e.key === "G") setTool("fill"); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [playBackSound, setActiveView, handleUndo, showPicker]); + + const tools: { id: Tool; label: string }[] = [ + { id: "pencil", label: "Pencil" }, + { id: "eraser", label: "Eraser" }, + { id: "eyedropper", label: "Pick" }, + { id: "fill", label: "Fill" }, + ]; + + const gridScale = 8; + const gridW = 64 * gridScale; + const gridH = texHeight * gridScale; + const rgb = hsvToRgb(hsv.h, hsv.s, hsv.v); + const pixelated: React.CSSProperties = { imageRendering: "pixelated" }; + const fillBg = (src: string): React.CSSProperties => ({ + backgroundImage: `url('${src}')`, + backgroundSize: "100% 100%", + imageRendering: "pixelated", + }); + const slot: React.CSSProperties = { + boxShadow: "inset 2px 2px 0 #000, inset -2px -2px 0 #8b8b8b", + imageRendering: "pixelated", + }; + const checker: React.CSSProperties = { + ...pixelated, + backgroundImage: "repeating-conic-gradient(#3f3f3f 0% 25%, #2b2b2b 0% 50%)", + backgroundSize: "8px 8px", + inset: 3, + }; + const getBtnStyle = (active: boolean) => + fillBg(active ? "/images/button_highlighted.png" : "/images/Button_Background.png"); + const sliderBg = fillBg("/images/Button_Background2.png"); + const btnClass = (id: string) => + `text-2xl mc-text-shadow ${focusBtn === id ? "text-[#FFFF55]" : "text-white"}`; + const toolClass = (active: boolean) => + `h-10 text-lg mc-text-shadow ${active ? "text-[#FFFF55]" : "text-white"}`; + + const renderToolBtn = (id: string, label: string, active: boolean, onClick: () => void) => ( + + ); + + const renderSlider = (label: string, value: number, max: number, onChange: (n: number) => void) => ( +
+ + {label} {value} + + onChange(Number(e.target.value))} + onMouseUp={playPressSound} + className="mc-slider-custom w-[calc(100%+8px)] h-full z-20" + /> +
+ ); + + return ( + +

+ Skin Editor +

+ +
+
+ {tools.map((t) => + renderToolBtn(t.id, t.label, tool === t.id, () => { + playPressSound(); + setTool(t.id); + }), + )} + +
+ + + {showPicker && ( +
e.stopPropagation()} + > +
+ { + isDraggingSatVal.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + handleSatValPick(e); + }} + onPointerMove={(e) => { if (isDraggingSatVal.current) handleSatValPick(e); }} + onPointerUp={(e) => { + isDraggingSatVal.current = false; + if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + }} + /> + +
+ +
+ { + isDraggingHue.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + handleHuePick(e); + }} + onPointerMove={(e) => { if (isDraggingHue.current) handleHuePick(e); }} + onPointerUp={(e) => { + isDraggingHue.current = false; + if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + }} + /> + +
+ + {(["r", "g", "b"] as const).map((channel) => + renderSlider(channel.toUpperCase(), rgb[channel], 255, (n) => { + const next = { ...rgb, [channel]: n }; + setColorFromHex(rgbToHex(next.r, next.g, next.b)); + }), + )} +
+ )} +
+ +
+ {DYE_COLORS.map((hex) => { + const selected = color.toLowerCase() === hex.toLowerCase(); + return ( +
+ +
+ # + { + const hex = e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6); + setHexValue(hex.toUpperCase()); + if (hex.length === 6) setColorFromHex(`#${hex}`); + }} + className="w-full h-full px-1 text-sm text-white tracking-widest uppercase mc-text-shadow bg-transparent" + /> +
+ +
+ + Alpha {Math.round(alpha * 100)}% + + setAlpha(Number(e.target.value) / 100)} + onMouseUp={playPressSound} + className="mc-slider-custom w-[calc(100%+8px)] h-full z-20" + /> +
+ {renderToolBtn("slim", slim ? "Alex" : "Steve", slim, () => { playPressSound(); setSlim((v) => !v); })} + {renderToolBtn("grid", "Grid", showGrid, () => { playPressSound(); setShowGrid((v) => !v); })} + {renderToolBtn("undo", "Undo", false, () => { playPressSound(); handleUndo(); })} +
+ +
+ +
+ +
+ +
+
+ +
+ {[ + { id: "save", label: "Save", onClick: handleSave }, + { id: "export", label: "Export", onClick: handleExport }, + { id: "back", label: "Back", onClick: () => { playBackSound(); setActiveView("skins"); } }, + ].map((b) => ( + + ))} +
+
+ ); +}); + +export default SkinEditorView; diff --git a/src/components/views/SkinsView.tsx b/src/components/views/SkinsView.tsx index 7659f7d..9139724 100644 --- a/src/components/views/SkinsView.tsx +++ b/src/components/views/SkinsView.tsx @@ -160,7 +160,7 @@ const SkinsView = memo(function SkinsView() { ]; const [activeCapeId, setActiveCapeId] = useState(null); - const TOP_BUTTONS_COUNT = viewMode === "skin" ? 3 : 3; + const TOP_BUTTONS_COUNT = viewMode === "skin" ? 4 : 3; const SKINS_START_INDEX = TOP_BUTTONS_COUNT; const BACK_BUTTON_INDEX = SKINS_START_INDEX + @@ -371,8 +371,16 @@ const SkinsView = memo(function SkinsView() { if (viewMode === "skin") handleDeleteActive(); else handleDeleteActiveCape(); } else if (focusIndex === 2) { + if (viewMode === "skin") { + playPressSound(); + setActiveView("skin-editor"); + } else { + playPressSound(); + setViewMode("skin"); + } + } else if (focusIndex === 3 && viewMode === "skin") { playPressSound(); - setViewMode(viewMode === "skin" ? "cape" : "skin"); + setViewMode("cape"); } else if (focusIndex < BACK_BUTTON_INDEX) { if (viewMode === "cape") { const capeIdx = focusIndex - SKINS_START_INDEX; @@ -588,11 +596,36 @@ const SkinsView = memo(function SkinsView() { {viewMode === "skin" ? "Delete Skin" : "Delete Cape"} -
-
+ {viewMode === "skin" && ( + )} + +
+
+