From f046e06e12b6977eebd47277b746258d4cbf1eaf Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Wed, 15 Apr 2026 14:06:26 +0300 Subject: [PATCH] feat(PCK): feature parity with Quiver --- src/components/common/SkinPreview3D.tsx | 308 +++++++++++++++++++++++ src/components/views/PckEditorView.tsx | 321 ++++++++++++++++++++++-- 2 files changed, 612 insertions(+), 17 deletions(-) create mode 100644 src/components/common/SkinPreview3D.tsx diff --git a/src/components/common/SkinPreview3D.tsx b/src/components/common/SkinPreview3D.tsx new file mode 100644 index 0000000..9e52956 --- /dev/null +++ b/src/components/common/SkinPreview3D.tsx @@ -0,0 +1,308 @@ +import { useEffect, useRef, memo } from 'react'; +import * as THREE from 'three'; +import { PCKAsset, PCKAssetType } from '../../types/pck'; + +interface SkinPreview3DProps { + asset: PCKAsset; + className?: string; +} + +enum SKIN_ANIM { + STATIONARY_ARMS = 1 << 0, + ZOMBIE_ARMS = 1 << 1, + STATIONARY_LEGS = 1 << 2, + BAD_SANTA_IDLE = 1 << 3, + UNKNOWN_EFFECT = 1 << 4, + SYNCHRONIZED_LEGS = 1 << 5, + SYNCHRONIZED_ARMS = 1 << 6, + STATUE_OF_LIBERTY = 1 << 7, + HIDE_ARMOR = 1 << 8, + FIRST_PERSON_BOBBING_DISABLED = 1 << 9, + HIDE_HEAD = 1 << 10, + HIDE_RIGHT_ARM = 1 << 11, + HIDE_LEFT_ARM = 1 << 12, + HIDE_BODY = 1 << 13, + HIDE_RIGHT_LEG = 1 << 14, + HIDE_LEFT_LEG = 1 << 15, + HIDE_HAT = 1 << 16, + BACKWARDS_CROUCH = 1 << 17, + MODERN_WIDE_FORMAT = 1 << 18, + SLIM_FORMAT = 1 << 19, + HIDE_LEFT_SLEEVE = 1 << 20, + HIDE_RIGHT_SLEEVE = 1 << 21, + HIDE_LEFT_PANT = 1 << 22, + HIDE_RIGHT_PANT = 1 << 23, + HIDE_JACKET = 1 << 24, + ALLOW_HEAD_ARMOR = 1 << 25, + ALLOW_RIGHT_ARM_ARMOR = 1 << 26, + ALLOW_LEFT_ARM_ARMOR = 1 << 27, + ALLOW_CHESTPLATE = 1 << 28, + ALLOW_RIGHT_LEGGING = 1 << 29, + ALLOW_LEFT_LEGGING = 1 << 30, + DINNER_BONE_RENDERING = 1 << 31 +} + +const SkinPreview3D = memo(function SkinPreview3D({ asset, className }: SkinPreview3DProps) { + const mountRef = useRef(null); + const playerGroupRef = useRef(null); + + useEffect(() => { + if (!mountRef.current) return; + + const width = mountRef.current.clientWidth; + const height = mountRef.current.clientHeight; + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000); + camera.position.set(0, 0, 70); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(window.devicePixelRatio); + mountRef.current.innerHTML = ""; + mountRef.current.appendChild(renderer.domElement); + + scene.add(new THREE.AmbientLight(0xffffff, 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 = -2; + scene.add(playerGroup); + playerGroupRef.current = playerGroup; + + const render = () => { + renderer.render(scene, camera); + }; + + const blob = new Blob([asset.data as any], { type: 'image/png' }); + const url = URL.createObjectURL(blob); + const textureLoader = new THREE.TextureLoader(); + + textureLoader.load(url, (texture) => { + texture.magFilter = THREE.NearestFilter; + texture.minFilter = THREE.NearestFilter; + texture.colorSpace = THREE.SRGBColorSpace; + + const img = texture.image; + const isLegacy = img.height === 32; + + const animProp = asset.properties.find(p => p.key === "ANIM"); + const animValue = animProp ? parseInt(animProp.value) || 0 : 0; + const slimFormat = !!(animValue & SKIN_ANIM.SLIM_FORMAT); + + const createFaceMaterial = (x: number, y: number, w: number, h: number, flipX = false, flipY = false) => { + const matTex = texture.clone(); + matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / img.height); + matTex.offset.set((flipX ? (x + w) : x) / 64, 1 - (flipY ? y : (y + h)) / img.height); + matTex.needsUpdate = true; + return new THREE.MeshLambertMaterial({ map: matTex, transparent: true, alphaTest: 0.5, side: THREE.FrontSide }); + }; + + const createPart = (w: number, h: number, d: number, uv: any, overlayUv?: any, isMirror = false) => { + const group = new THREE.Group(); + const geo = new THREE.BoxGeometry(w, h, d); + + const getMats = (uvSet: any) => { + return [ + createFaceMaterial(uvSet.right[0], uvSet.right[1], uvSet.right[2], uvSet.right[3], isMirror), // +x + createFaceMaterial(uvSet.left[0], uvSet.left[1], uvSet.left[2], uvSet.left[3], isMirror), // -x + createFaceMaterial(uvSet.top[0], uvSet.top[1], uvSet.top[2], uvSet.top[3], isMirror, true), // +y + createFaceMaterial(uvSet.bottom[0], uvSet.bottom[1], uvSet.bottom[2], uvSet.bottom[3], isMirror, true), // -y + createFaceMaterial(uvSet.front[0], uvSet.front[1], uvSet.front[2], uvSet.front[3], isMirror), // +z + createFaceMaterial(uvSet.back[0], uvSet.back[1], uvSet.back[2], uvSet.back[3], !isMirror) // -z + ]; + }; + + const mesh = new THREE.Mesh(geo, getMats(uv)); + group.add(mesh); + + if (overlayUv) { + const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5); + const oMesh = new THREE.Mesh(oGeo, getMats(overlayUv)); + group.add(oMesh); + } + return group; + }; + + const limbUv = (x: number, y: number, w = 4) => ({ + 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] + }); + + if (asset.type === (PCKAssetType.CAPE as any)) { + const capeUv = { + 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 cape = createPart(10, 16, 1, capeUv); + cape.position.y = 2; + playerGroup.add(cape); + } else { + const armW = slimFormat ? 3 : 4; + + if (!(animValue & SKIN_ANIM.HIDE_HEAD)) { + 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 = (animValue & SKIN_ANIM.HIDE_HAT) ? undefined : { 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); + } + + if (!(animValue & SKIN_ANIM.HIDE_BODY) && asset.type !== PCKAssetType.CAPE) { + 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 || (animValue & SKIN_ANIM.HIDE_JACKET)) ? 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)); + } + + if (!(animValue & SKIN_ANIM.HIDE_RIGHT_ARM)) { + const rArmUv = limbUv(40, 16, armW); + const rSleeveUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_RIGHT_SLEEVE)) ? undefined : limbUv(40, 32, armW); + const rightArm = createPart(armW, 12, 4, rArmUv, rSleeveUv); + rightArm.position.set(slimFormat ? -5.5 : -6, 0, 0); + playerGroup.add(rightArm); + } + + if (!(animValue & SKIN_ANIM.HIDE_LEFT_ARM)) { + const lArmUv = isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW); + const lSleeveUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_LEFT_SLEEVE)) ? undefined : limbUv(48, 48, armW); + const leftArm = createPart(armW, 12, 4, lArmUv, lSleeveUv, isLegacy); + leftArm.position.set(slimFormat ? 5.5 : 6, 0, 0); + playerGroup.add(leftArm); + } + + if (!(animValue & SKIN_ANIM.HIDE_RIGHT_LEG)) { + const rLegUv = limbUv(0, 16); + const rPantUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_RIGHT_PANT)) ? undefined : limbUv(0, 32); + const rightLeg = createPart(4, 12, 4, rLegUv, rPantUv); + rightLeg.position.set(-2, -12, 0); + playerGroup.add(rightLeg); + } + + if (!(animValue & SKIN_ANIM.HIDE_LEFT_LEG)) { + const lLegUv = isLegacy ? limbUv(0, 16) : limbUv(16, 48); + const lPantUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_LEFT_PANT)) ? undefined : limbUv(0, 48); + const leftLeg = createPart(4, 12, 4, lLegUv, lPantUv, isLegacy); + leftLeg.position.set(2, -12, 0); + playerGroup.add(leftLeg); + } + } + + const boxProps = asset.properties.filter(p => p.key === "BOX"); + boxProps.forEach(prop => { + const parts = prop.value.split(/\s+/); + if (parts.length >= 11) { + const type = parts[0]; + const bx = parseFloat(parts[1]); + const by = parseFloat(parts[2]); + const bz = parseFloat(parts[3]); + const bw = parseFloat(parts[4]); + const bh = parseFloat(parts[5]); + const bd = parseFloat(parts[6]); + const bu = parseFloat(parts[7]); + const bv = parseFloat(parts[8]); + const mir = parseInt(parts[10]) === 1; + const scale = parseFloat(parts[11] || "0"); + + const uv = { + top: [bu + bd, bv, bw, bd], + bottom: [bu + bd + bw, bv, bw, bd], + right: [bu, bv + bd, bd, bh], + front: [bu + bd, bv + bd, bw, bh], + left: [bu + bd + bw, bv + bd, bd, bh], + back: [bu + 2 * bd + bw, bv + bd, bw, bh] + }; + + const boxPart = createPart(bw, bh, bd, uv, undefined, mir); + + let ox = 0, oy = 0, oz = 0; + if (type === "HEAD_DEFAULT") oy = -4; + else if (type === "HEAD") oy = -8; + else if (type === "BODY") oy = 2; + else if (type === "ARM0") { ox = -5; oy = 2; } + else if (type === "ARM1") { ox = 5; oy = 2; } + else if (type === "LEG0") { ox = -1.9; oy = 12; } + else if (type === "LEG1") { ox = 1.9; oy = 12; } + + boxPart.position.set(ox + bx + bw / 2, -(oy + by) - bh / 2, oz + bz + bd / 2); + if (scale !== 0) boxPart.scale.set(1 + scale / bw, 1 + scale / bh, 1 + scale / bd); + playerGroup.add(boxPart); + } + }); + + playerGroup.rotation.y = -0.3; + 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 && playerGroupRef.current) { + playerGroupRef.current.rotation.y += (e.clientX - previousMousePosition.x) * 0.01; + playerGroupRef.current.rotation.x += (e.clientY - previousMousePosition.y) * 0.01; + previousMousePosition = { x: e.clientX, y: e.clientY }; + render(); + } + }; + + const onWheel = (e: WheelEvent) => { + camera.position.z += e.deltaY * 0.1; + camera.position.z = Math.max(20, Math.min(camera.position.z, 200)); + render(); + }; + + renderer.domElement.addEventListener("mousedown", onMouseDown); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + renderer.domElement.addEventListener("wheel", onWheel); + + const handleResize = () => { + if (!mountRef.current || !renderer) return; + const w = mountRef.current.clientWidth; + const h = mountRef.current.clientHeight; + 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); + URL.revokeObjectURL(url); + + scene.traverse((object) => { + if (object instanceof THREE.Mesh) { + if (object.geometry) object.geometry.dispose(); + if (object.material) { + if (Array.isArray(object.material)) { + object.material.forEach((mat) => { + if (mat.map) mat.map.dispose(); + mat.dispose(); + }); + } else { + if (object.material.map) object.material.map.dispose(); + object.material.dispose(); + } + } + } + }); + renderer.dispose(); + }; + }, [asset]); + + return
; +}); + +export default SkinPreview3D; diff --git a/src/components/views/PckEditorView.tsx b/src/components/views/PckEditorView.tsx index 034b0d0..bfdd1ea 100644 --- a/src/components/views/PckEditorView.tsx +++ b/src/components/views/PckEditorView.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; import { PckService } from "../../services/PckService"; import { PCKFile, PCKAsset, PCKAssetType } from "../../types/pck"; +import SkinPreview3D from "../common/SkinPreview3D"; export default function PckEditorView() { const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); @@ -13,6 +14,8 @@ export default function PckEditorView() { const [isEditingProperty, setIsEditingProperty] = useState<{ idx: number, key: string, val: string } | null>(null); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null); + const [showTypeModal, setShowTypeModal] = useState<{ file: File, data: Uint8Array } | null>(null); + const [isChangingType, setIsChangingType] = useState(false); const containerRef = useRef(null); const fileInputRef = useRef(null); const replaceInputRef = useRef(null); @@ -148,9 +151,25 @@ export default function PckEditorView() { setExpandedFolders(new Set()); } catch (err) { console.error("Failed to parse PCK", err); + showNotification("Failed to parse PCK", "error"); } }; + const handleNewPCK = () => { + playPressSound(); + const newPck: PCKFile = { + version: 2, + endianness: "little", + xmlSupport: false, + properties: ["ANIM", "BOX"], + files: [] + }; + setPck(newPck); + setSelectedAssetId(null); + setExpandedFolders(new Set()); + showNotification("New PCK Created"); + }; + const showNotification = (message: string, type: "success" | "error" = "success") => { setNotification({ message, type }); @@ -201,27 +220,45 @@ export default function PckEditorView() { playPressSound(); const buffer = await file.arrayBuffer(); const data = new Uint8Array(buffer); + setShowTypeModal({ file, data }); + e.target.value = ""; + }; + + const confirmAddAsset = (type: PCKAssetType) => { + if (!pck || !showTypeModal) return; + const { file, data } = showTypeModal; const newAsset: PCKAsset = { id: Math.random().toString(36).substr(2, 9), path: file.name, - type: PCKAssetType.TEXTURE, //neo: this is the default btw + type, size: data.length, data, properties: [] }; + if (type === PCKAssetType.SKIN || type === PCKAssetType.CAPE) { + newAsset.properties.push({ key: "ANIM", value: "0" }); + } + setPck({ ...pck, files: [...pck.files, newAsset] }); setSelectedAssetId(newAsset.id); - e.target.value = ""; + setShowTypeModal(null); showNotification("Asset Added"); }; - const handlePropertyEdit = (idx: number, newVal: string) => { + const handlePropertyEdit = (idx: number, newVal: string, isKey = false) => { if (!pck || !selectedAssetId) return; const newFiles = pck.files.map(f => { if (f.id === selectedAssetId) { const newProps = [...f.properties]; - newProps[idx] = { ...newProps[idx], value: newVal }; + if (isKey) { + newProps[idx] = { ...newProps[idx], key: newVal }; + if (!pck.properties.includes(newVal)) { + pck.properties.push(newVal); + } + } else { + newProps[idx] = { ...newProps[idx], value: newVal }; + } return { ...f, properties: newProps }; } return f; @@ -229,6 +266,47 @@ export default function PckEditorView() { setPck({ ...pck, files: newFiles }); }; + const handleAddProperty = () => { + if (!pck || !selectedAssetId) return; + playPressSound(); + const newFiles = pck.files.map(f => { + if (f.id === selectedAssetId) { + return { + ...f, + properties: [...f.properties, { key: "NEW_PROPERTY", value: "0" }] + }; + } + return f; + }); + setPck({ ...pck, files: newFiles }); + }; + + const handleRemoveProperty = (idx: number) => { + if (!pck || !selectedAssetId) return; + playBackSound(); + const newFiles = pck.files.map(f => { + if (f.id === selectedAssetId) { + const newProps = [...f.properties]; + newProps.splice(idx, 1); + return { ...f, properties: newProps }; + } + return f; + }); + setPck({ ...pck, files: newFiles }); + }; + + const handleTypeChange = (newType: PCKAssetType) => { + if (!pck || !selectedAssetId) return; + playPressSound(); + const newFiles = pck.files.map(f => { + if (f.id === selectedAssetId) { + return { ...f, type: newType }; + } + return f; + }); + setPck({ ...pck, files: newFiles }); + }; + const handleMoveAsset = (direction: 'up' | 'down') => { if (!pck || !selectedAssetId) return; const idx = pck.files.findIndex(f => f.id === selectedAssetId); @@ -272,6 +350,19 @@ export default function PckEditorView() { return () => window.removeEventListener("keydown", handleKeyDown); }, [playBackSound, setActiveView, isEditingProperty]); + const getTypeColor = (type: PCKAssetType) => { + switch (type) { + case PCKAssetType.SKIN: return "#FFFF55"; + case PCKAssetType.CAPE: return "#AA00AA"; + case PCKAssetType.TEXTURE: return "#55FFFF"; + case PCKAssetType.AUDIO_DATA: return "#55FF55"; + case PCKAssetType.UI_DATA: return "#FFAA00"; + case PCKAssetType.LOCALISATION: return "#FF55FF"; + case PCKAssetType.MODELS: return "#5555FF"; + default: return "#AAAAAA"; + } + }; + return ( Open PCK + + + + {isChangingType && ( + <> + setIsChangingType(false)} + /> + + {Object.keys(PCKAssetType) + .filter(k => isNaN(Number(k))) + .map((typeName) => { + const typeVal = PCKAssetType[typeName as keyof typeof PCKAssetType]; + const isActive = selectedAsset.type === typeVal; + return ( + + ); + })} + + + )} + +
+
{assetPreviewUrl && ( -
- +
+ {(selectedAsset.type === PCKAssetType.SKIN || selectedAsset.type === PCKAssetType.CAPE) ? ( + + ) : ( + + )} +
+ {selectedAsset.type === PCKAssetType.SKIN ? "3D Skin View" : selectedAsset.type === PCKAssetType.CAPE ? "3D Cape View" : "Texture Preview"} +
)}
-
Metadata Properties
-
+
+
Metadata Properties
+ +
+
{selectedAsset.properties.map((prop, idx) => ( -
- {prop.key} -
+
+
+ handlePropertyEdit(idx, e.target.value, true)} + className="bg-transparent text-white/40 text-[10px] outline-none hover:text-white/60 focus:text-[#FFFF55] w-2/3" + /> + +
+
+ + {prop.key === "ANIM" && ( +
+ {[ + { label: "Slim Format", flag: 1 << 19 }, + { label: "Modern Wide", flag: 1 << 18 }, + { label: "Hide Hat", flag: 1 << 16 }, + { label: "Hide Head", flag: 1 << 10 }, + { label: "Hide Body", flag: 1 << 13 }, + { label: "Hide Right Arm", flag: 1 << 11 }, + { label: "Hide Left Arm", flag: 1 << 12 }, + { label: "Hide Right Leg", flag: 1 << 14 }, + { label: "Hide Left Leg", flag: 1 << 15 }, + { label: "Hide Jacket", flag: 1 << 24 }, + { label: "Hide Right Sleeve", flag: 1 << 21 }, + { label: "Hide Left Sleeve", flag: 1 << 20 }, + { label: "Hide Right Pant", flag: 1 << 23 }, + { label: "Hide Left Pant", flag: 1 << 22 }, + { label: "Zombie Arms", flag: 1 << 1 }, + { label: "Upside Down", flag: 1 << 31 }, + ].map((item) => { + const currentVal = parseInt(prop.value) || 0; + const isChecked = (currentVal & item.flag) !== 0; + return ( +
))} {selectedAsset.properties.length === 0 && ( -
No metadata properties available
+
No metadata properties
)}
@@ -444,7 +685,7 @@ export default function PckEditorView() { initial={{ opacity: 0, y: 50, scale: 0.9 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 50, scale: 0.9 }} - className="fixed top-12 right-12 z-50 p-6 flex flex-col items-center justify-center min-w-[240px]" + className="fixed top-12 right-12 z-[110] p-6 flex flex-col items-center justify-center min-w-[240px]" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", @@ -457,6 +698,52 @@ export default function PckEditorView() { )} + + + {showTypeModal && ( +
+ setShowTypeModal(null)} + /> + +

Select Asset Type

+
+ {Object.keys(PCKAssetType) + .filter(k => isNaN(Number(k))) + .map((typeName) => ( + + ))} +
+ +
+
+ )} +
); }