mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 00:07:09 +00:00
feat: PCK EDITOR
This commit is contained in:
parent
aea670478d
commit
d808fbb8ae
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -30,3 +30,4 @@ Thumbs.db
|
|||
/.flatpak-builder
|
||||
/*.flatpak
|
||||
/emerald-repo
|
||||
/ignore/*
|
||||
|
|
@ -1,36 +1,276 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
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";
|
||||
export default function PckEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playBackSound } = useAudio();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [focusIndex, setFocusIndex] = useState<number>(0);
|
||||
const [pck, setPck] = useState<PCKFile | null>(null);
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isEditingProperty, setIsEditingProperty] = useState<{ idx: number, key: string, val: string } | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const addAssetInputRef = useRef<HTMLInputElement>(null);
|
||||
const treeData = useMemo(() => {
|
||||
if (!pck) return [];
|
||||
interface TempNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isFolder: boolean;
|
||||
asset?: PCKAsset;
|
||||
children: Record<string, TempNode>;
|
||||
}
|
||||
|
||||
const root: Record<string, TempNode> = {};
|
||||
pck.files.forEach(asset => {
|
||||
if (searchTerm && !asset.path.toLowerCase().includes(searchTerm.toLowerCase())) return;
|
||||
const parts = asset.path.split("/");
|
||||
let currentLevel = root;
|
||||
let currentPath = "";
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
const isLast = index === parts.length - 1;
|
||||
if (!currentLevel[part]) {
|
||||
currentLevel[part] = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
isFolder: !isLast,
|
||||
asset: isLast ? asset : undefined,
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
currentLevel = currentLevel[part].children;
|
||||
});
|
||||
});
|
||||
|
||||
const convert = (nodes: Record<string, TempNode>): any[] => {
|
||||
return Object.values(nodes)
|
||||
.sort((a, b) => {
|
||||
if (a.isFolder && !b.isFolder) return -1;
|
||||
if (!a.isFolder && b.isFolder) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map(node => ({
|
||||
...node,
|
||||
children: convert(node.children)
|
||||
}));
|
||||
};
|
||||
|
||||
return convert(root);
|
||||
}, [pck, searchTerm]);
|
||||
|
||||
const selectedAsset = useMemo(() => {
|
||||
return pck?.files.find(f => f.id === selectedAssetId) || null;
|
||||
}, [pck, selectedAssetId]);
|
||||
|
||||
const assetPreviewUrl = useMemo(() => {
|
||||
if (!selectedAsset || ![PCKAssetType.SKIN, PCKAssetType.CAPE, PCKAssetType.TEXTURE].includes(selectedAsset.type)) return null;
|
||||
const blob = new Blob([selectedAsset.data as any], { type: "image/png" });
|
||||
return URL.createObjectURL(blob);
|
||||
}, [selectedAsset]);
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
const next = new Set(expandedFolders);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
setExpandedFolders(next);
|
||||
};
|
||||
|
||||
const renderTree = (nodes: any[], depth = 0) => {
|
||||
return nodes.map(node => {
|
||||
const isExpanded = expandedFolders.has(node.path) || !!searchTerm;
|
||||
const isSelected = selectedAssetId === node.asset?.id;
|
||||
return (
|
||||
<div key={node.path} className="flex flex-col">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (node.isFolder) {
|
||||
toggleFolder(node.path);
|
||||
} else {
|
||||
playPressSound();
|
||||
setSelectedAssetId(node.asset.id);
|
||||
}
|
||||
}}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
className={`flex items-center gap-2 p-2 cursor-pointer transition-all border-l-2 ${isSelected
|
||||
? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]"
|
||||
: "border-transparent hover:bg-white/5 text-white"
|
||||
} ${node.isFolder ? "font-bold" : ""}`}
|
||||
>
|
||||
{node.isFolder ? (
|
||||
<img
|
||||
src={isExpanded ? "/images/Settings_Arrow_Down.png" : "/images/Settings_Arrow_Right.png"}
|
||||
className="w-3 h-3 object-contain opacity-80"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-3" />
|
||||
)}
|
||||
<img
|
||||
src={node.isFolder ? "/images/Folder_Icon.png" : "/images/tools/pck.png"}
|
||||
className={`w-4 h-4 object-contain ${isSelected ? "" : "grayscale opacity-60"}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<span className="truncate mc-text-shadow text-base">
|
||||
{node.name}
|
||||
</span>
|
||||
{!node.isFolder && (
|
||||
<span className="ml-auto text-[10px] opacity-40 uppercase">
|
||||
{(node.asset.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{node.isFolder && isExpanded && (
|
||||
<div className="flex flex-col">
|
||||
{renderTree(node.children, depth + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
const parsed = await PckService.readPCK(buffer);
|
||||
setPck(parsed);
|
||||
setSelectedAssetId(parsed.files[0]?.id || null);
|
||||
setExpandedFolders(new Set());
|
||||
} catch (err) {
|
||||
console.error("Failed to parse PCK", err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleExportAsset = (asset: PCKAsset) => {
|
||||
playPressSound();
|
||||
const blob = new Blob([asset.data as any]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = asset.path.split("/").pop() || "asset";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification(`Exported: ${asset.path.split("/").pop()}`);
|
||||
};
|
||||
|
||||
const handleDeleteAsset = (id: string) => {
|
||||
if (!pck) return;
|
||||
playBackSound();
|
||||
const newFiles = pck.files.filter(f => f.id !== id);
|
||||
const assetPath = pck.files.find(f => f.id === id)?.path;
|
||||
setPck({ ...pck, files: newFiles });
|
||||
if (selectedAssetId === id) setSelectedAssetId(newFiles[0]?.id || null);
|
||||
showNotification(`Deleted: ${assetPath?.split("/").pop()}`);
|
||||
};
|
||||
|
||||
const handleReplaceAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!pck || !selectedAssetId) return;
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
|
||||
const newFiles = pck.files.map(f => f.id === selectedAssetId ? { ...f, data, size: data.length } : f);
|
||||
setPck({ ...pck, files: newFiles });
|
||||
e.target.value = "";
|
||||
showNotification("Asset Replaced");
|
||||
};
|
||||
|
||||
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!pck) return;
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
const newAsset: PCKAsset = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
path: file.name,
|
||||
type: PCKAssetType.TEXTURE, //neo: this is the default btw
|
||||
size: data.length,
|
||||
data,
|
||||
properties: []
|
||||
};
|
||||
|
||||
setPck({ ...pck, files: [...pck.files, newAsset] });
|
||||
setSelectedAssetId(newAsset.id);
|
||||
e.target.value = "";
|
||||
showNotification("Asset Added");
|
||||
};
|
||||
|
||||
const handlePropertyEdit = (idx: number, newVal: string) => {
|
||||
if (!pck || !selectedAssetId) return;
|
||||
const newFiles = pck.files.map(f => {
|
||||
if (f.id === selectedAssetId) {
|
||||
const newProps = [...f.properties];
|
||||
newProps[idx] = { ...newProps[idx], value: newVal };
|
||||
return { ...f, properties: newProps };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
setPck({ ...pck, files: newFiles });
|
||||
};
|
||||
|
||||
const handleMoveAsset = (direction: 'up' | 'down') => {
|
||||
if (!pck || !selectedAssetId) return;
|
||||
const idx = pck.files.findIndex(f => f.id === selectedAssetId);
|
||||
if (idx === -1) return;
|
||||
const newIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (newIdx < 0 || newIdx >= pck.files.length) return;
|
||||
playPressSound();
|
||||
const newFiles = [...pck.files];
|
||||
[newFiles[idx], newFiles[newIdx]] = [newFiles[newIdx], newFiles[idx]];
|
||||
setPck({ ...pck, files: newFiles });
|
||||
};
|
||||
|
||||
const handleSavePCK = () => {
|
||||
if (!pck) return;
|
||||
playPressSound();
|
||||
const buffer = PckService.serializePCK(pck);
|
||||
const blob = new Blob([buffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = pck.files.length > 0 ? "new.pck" : "empty.pck";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("PCK Saved Successfully");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.activeElement?.tagName === "INPUT") return;
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
if (isEditingProperty) {
|
||||
setIsEditingProperty(null);
|
||||
return;
|
||||
}
|
||||
playBackSound();
|
||||
setActiveView("devtools");
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
if (focusIndex === 0) {
|
||||
playBackSound();
|
||||
setActiveView("devtools");
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [playBackSound, setActiveView, focusIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current?.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement;
|
||||
if (el) el.focus();
|
||||
}, [focusIndex]);
|
||||
}, [playBackSound, setActiveView, isEditingProperty]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -39,51 +279,184 @@ export default function PckEditorView() {
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-3xl outline-none"
|
||||
className="flex flex-col items-center w-full max-w-6xl h-[85vh] 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">
|
||||
PCK Editor
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className="w-full max-w-160 h-85 mb-4 p-8 shadow-2xl flex flex-col items-center justify-center p-12"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/tools/pck.png"
|
||||
className="w-24 h-24 mb-6 opacity-20 grayscale"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<h3 className="text-xl text-[#FFFF55] mc-text-shadow mb-2">PCK Editor Coming Soon</h3>
|
||||
<p className="text-center text-white/60 mc-text-shadow max-w-md">
|
||||
This tool will allow you to explore and modify PCK archive files.
|
||||
</p>
|
||||
<div className="w-full flex justify-between items-center mb-4 px-8">
|
||||
<h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold">
|
||||
PCK Editor
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open PCK
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePCK}
|
||||
disabled={!pck}
|
||||
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${!pck ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save PCK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".pck" />
|
||||
<input type="file" ref={replaceInputRef} onChange={handleReplaceAsset} className="hidden" />
|
||||
<input type="file" ref={addAssetInputRef} onChange={handleAddAsset} className="hidden" />
|
||||
{!pck ? (
|
||||
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<img src="/images/tools/pck.png" className="w-32 h-32 mb-8 opacity-20 grayscale" style={{ imageRendering: "pixelated" }} />
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open a PCK file to begin editing</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex gap-4 overflow-hidden">
|
||||
<div className="w-2/3 flex flex-col p-4" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addAssetInputRef.current?.click()}
|
||||
className="px-4 py-2 text-white mc-text-shadow text-sm shrink-0"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add Asset
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{renderTree(treeData)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3 flex flex-col p-6 overflow-y-auto" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<AnimatePresence mode="wait">
|
||||
{!selectedAsset ? (
|
||||
<div className="flex-1 flex items-center justify-center text-white/20 italic">Select an asset</div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={selectedAsset.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4 border-b border-[#373737] pb-2">
|
||||
<h3 className="text-[#FFFF55] text-xl mc-text-shadow truncate pr-4">
|
||||
{selectedAsset.path.split("/").pop()}
|
||||
</h3>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button onClick={() => handleMoveAsset('up')} className="hover:scale-110 active:scale-95 transition-transform">
|
||||
<img src="/images/Settings_Arrow_Up.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
<button onClick={() => handleMoveAsset('down')} className="hover:scale-110 active:scale-95 transition-transform">
|
||||
<img src="/images/Settings_Arrow_Down.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assetPreviewUrl && (
|
||||
<div className="w-full h-40 bg-black/40 border-2 border-[#373737] mb-6 flex items-center justify-center overflow-hidden">
|
||||
<img src={assetPreviewUrl} className="max-w-full max-h-full object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 flex-1">
|
||||
<div>
|
||||
<div className="text-white/40 text-[10px] uppercase tracking-widest mb-2 px-1">Metadata Properties</div>
|
||||
<div className="space-y-3">
|
||||
{selectedAsset.properties.map((prop, idx) => (
|
||||
<div key={idx} className="flex flex-col gap-1">
|
||||
<span className="text-white/40 text-[10px] px-1">{prop.key}</span>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
value={prop.value}
|
||||
onChange={(e) => handlePropertyEdit(idx, e.target.value)}
|
||||
className="w-full bg-black/40 p-2 text-white border border-[#373737] text-sm focus:border-[#FFFF55] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{selectedAsset.properties.length === 0 && (
|
||||
<div className="text-white/20 italic text-sm px-1">No metadata properties available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col gap-3 mt-auto">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleExportAsset(selectedAsset)}
|
||||
className="py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => replaceInputRef.current?.click()}
|
||||
className="py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteAsset(selectedAsset.id)}
|
||||
className="w-full py-2 text-red-500/80 mc-text-shadow text-sm transition-all hover:text-red-500 hover:scale-[1.02]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Delete This Asset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-index="0"
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
setActiveView("devtools");
|
||||
}}
|
||||
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"
|
||||
}`}
|
||||
className="w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-6 outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 0
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{notification && (
|
||||
<motion.div
|
||||
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]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated"
|
||||
}}
|
||||
>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
|
||||
{notification.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
200
src/services/PckService.ts
Normal file
200
src/services/PckService.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { PCKAssetType, PCKAsset, PCKFile, PCKProperty } from "../types/pck";
|
||||
export class PckService {
|
||||
private static decodeU16(buffer: ArrayBuffer, length: number, offset: number, littleEndian: boolean): string {
|
||||
const uint16Array = new Uint16Array(length);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < length; i++) {
|
||||
uint16Array[i] = view.getUint16(offset + (i * 2), littleEndian);
|
||||
}
|
||||
return String.fromCharCode(...uint16Array);
|
||||
}
|
||||
|
||||
private static encodeU16(str: string, littleEndian: boolean): Uint8Array {
|
||||
const buf = new ArrayBuffer(str.length * 2);
|
||||
const view = new DataView(buf);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint16(i * 2, str.charCodeAt(i), littleEndian);
|
||||
}
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
static async readPCK(buffer: ArrayBuffer): Promise<PCKFile> {
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
let version = view.getUint32(offset, true);
|
||||
let littleEndian = true;
|
||||
if (version > 3) {
|
||||
version = view.getUint32(offset, false);
|
||||
littleEndian = false;
|
||||
}
|
||||
offset += 4;
|
||||
if (version > 3) throw new Error("Invalid PCK version");
|
||||
const propertyCount = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
const properties: string[] = [];
|
||||
for (let i = 0; i < propertyCount; i++) {
|
||||
view.getUint32(offset, littleEndian); //neo: this is propertyIndex
|
||||
offset += 4;
|
||||
const stringLength = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
const property = this.decodeU16(buffer, stringLength, offset, littleEndian);
|
||||
offset += stringLength * 2;
|
||||
offset += 4;
|
||||
properties.push(property);
|
||||
}
|
||||
|
||||
let xmlSupport = properties.includes("XMLVERSION");
|
||||
if (xmlSupport) {
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
const fileCount = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
|
||||
const fileInfos: { size: number; type: number; path: string }[] = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const fileSize = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
const fileType = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
const pathLength = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
const path = this.decodeU16(buffer, pathLength, offset, littleEndian).replace(/\\/g, "/");
|
||||
offset += pathLength * 2;
|
||||
offset += 4;
|
||||
fileInfos.push({ size: fileSize, type: fileType, path });
|
||||
}
|
||||
|
||||
const assets: PCKAsset[] = [];
|
||||
for (const info of fileInfos) {
|
||||
const assetPropertyCount = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
|
||||
const assetProperties: PCKProperty[] = [];
|
||||
for (let j = 0; j < assetPropertyCount; j++) {
|
||||
const propIdx = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
const valLen = view.getUint32(offset, littleEndian);
|
||||
offset += 4;
|
||||
const val = this.decodeU16(buffer, valLen, offset, littleEndian);
|
||||
offset += valLen * 2;
|
||||
offset += 4;
|
||||
assetProperties.push({ key: properties[propIdx], value: val });
|
||||
}
|
||||
|
||||
const data = new Uint8Array(buffer.slice(offset, offset + info.size));
|
||||
offset += info.size;
|
||||
|
||||
assets.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
path: info.path,
|
||||
type: info.type as PCKAssetType,
|
||||
size: info.size,
|
||||
data,
|
||||
properties: assetProperties
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
endianness: littleEndian ? "little" : "big",
|
||||
xmlSupport,
|
||||
properties,
|
||||
files: assets
|
||||
};
|
||||
}
|
||||
|
||||
static serializePCK(pck: PCKFile): ArrayBuffer {
|
||||
const littleEndian = pck.endianness === "little";
|
||||
let totalSize = 4 + 4;
|
||||
const propertySet = new Set<string>();
|
||||
if (pck.xmlSupport) propertySet.add("XMLVERSION");
|
||||
pck.files.forEach(f => f.properties.forEach(p => propertySet.add(p.key)));
|
||||
const finalProperties = Array.from(propertySet);
|
||||
|
||||
finalProperties.forEach((prop) => {
|
||||
totalSize += 4 + 4 + (prop.length * 2) + 4;
|
||||
});
|
||||
|
||||
if (pck.xmlSupport) totalSize += 4;
|
||||
totalSize += 4;
|
||||
pck.files.forEach(f => {
|
||||
totalSize += 4 + 4 + 4 + (f.path.length * 2) + 4;
|
||||
});
|
||||
|
||||
pck.files.forEach(f => {
|
||||
totalSize += 4;
|
||||
f.properties.forEach(p => {
|
||||
totalSize += 4 + 4 + (p.value.length * 2) + 4;
|
||||
});
|
||||
totalSize += f.data.length;
|
||||
});
|
||||
|
||||
const buffer = new ArrayBuffer(totalSize);
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
|
||||
view.setUint32(offset, pck.version, littleEndian);
|
||||
offset += 4;
|
||||
|
||||
view.setUint32(offset, finalProperties.length, littleEndian);
|
||||
offset += 4;
|
||||
|
||||
finalProperties.forEach((prop) => {
|
||||
view.setUint32(offset, finalProperties.indexOf(prop), littleEndian);
|
||||
offset += 4;
|
||||
view.setUint32(offset, prop.length, littleEndian);
|
||||
offset += 4;
|
||||
const encoded = this.encodeU16(prop, littleEndian);
|
||||
new Uint8Array(buffer, offset, encoded.length).set(encoded);
|
||||
offset += encoded.length;
|
||||
view.setUint32(offset, 0, littleEndian);
|
||||
offset += 4;
|
||||
});
|
||||
|
||||
if (pck.xmlSupport) {
|
||||
view.setUint32(offset, 3, littleEndian);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
view.setUint32(offset, pck.files.length, littleEndian);
|
||||
offset += 4;
|
||||
|
||||
pck.files.forEach(f => {
|
||||
view.setUint32(offset, f.data.length, littleEndian);
|
||||
offset += 4;
|
||||
view.setUint32(offset, f.type, littleEndian);
|
||||
offset += 4;
|
||||
view.setUint32(offset, f.path.length, littleEndian);
|
||||
offset += 4;
|
||||
const encoded = this.encodeU16(f.path, littleEndian);
|
||||
new Uint8Array(buffer, offset, encoded.length).set(encoded);
|
||||
offset += encoded.length;
|
||||
view.setUint32(offset, 0, littleEndian);
|
||||
offset += 4;
|
||||
});
|
||||
|
||||
pck.files.forEach(f => {
|
||||
view.setUint32(offset, f.properties.length, littleEndian);
|
||||
offset += 4;
|
||||
|
||||
f.properties.forEach(p => {
|
||||
const propIdx = finalProperties.indexOf(p.key);
|
||||
view.setUint32(offset, propIdx, littleEndian);
|
||||
offset += 4;
|
||||
view.setUint32(offset, p.value.length, littleEndian);
|
||||
offset += 4;
|
||||
const encoded = this.encodeU16(p.value, littleEndian);
|
||||
new Uint8Array(buffer, offset, encoded.length).set(encoded);
|
||||
offset += encoded.length;
|
||||
view.setUint32(offset, 0, littleEndian);
|
||||
offset += 4;
|
||||
});
|
||||
|
||||
new Uint8Array(buffer, offset, f.data.length).set(f.data);
|
||||
offset += f.data.length;
|
||||
});
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
39
src/types/pck.ts
Normal file
39
src/types/pck.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export enum PCKAssetType {
|
||||
SKIN = 0,
|
||||
CAPE = 1,
|
||||
TEXTURE = 2,
|
||||
UI_DATA = 3,
|
||||
INFO = 4,
|
||||
TEXTURE_PACK_INFO = 5,
|
||||
LOCALISATION = 6,
|
||||
GAME_RULES = 7,
|
||||
AUDIO_DATA = 8,
|
||||
COLOUR_TABLE = 9,
|
||||
GAME_RULES_HEADER = 10,
|
||||
SKIN_DATA = 11,
|
||||
MODELS = 12,
|
||||
BEHAVIOURS = 13,
|
||||
MATERIALS = 14,
|
||||
}
|
||||
|
||||
export interface PCKProperty {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PCKAsset {
|
||||
id: string;
|
||||
path: string;
|
||||
type: PCKAssetType;
|
||||
size: number;
|
||||
data: Uint8Array;
|
||||
properties: PCKProperty[];
|
||||
}
|
||||
|
||||
export interface PCKFile {
|
||||
version: number;
|
||||
endianness: "little" | "big";
|
||||
xmlSupport: boolean;
|
||||
properties: string[];
|
||||
files: PCKAsset[];
|
||||
}
|
||||
Loading…
Reference in a new issue