mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-05-21 17:54:30 +00:00
feat(devtools/grf): support for editing and creating new GRF/GRH files
This commit is contained in:
@@ -2,8 +2,7 @@ import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { GrfService } from "../../services/GrfService";
|
||||
import { GrfFile, GrfNode } from "../../types/grf";
|
||||
|
||||
import { GrfFile, GrfNode, GrfFileEntry } from "../../types/grf";
|
||||
export default function GrfEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
@@ -12,9 +11,8 @@ export default function GrfEditorView() {
|
||||
const [filename, setFilename] = useState("game_rules.grf");
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<"rules" | "files">("rules");
|
||||
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
@@ -37,6 +35,51 @@ export default function GrfEditorView() {
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleNewGrf = () => {
|
||||
playPressSound();
|
||||
setGrf(GrfService.createDefaultGRF());
|
||||
setFilename("new_rules.grf");
|
||||
showNotification("New GRF Created");
|
||||
};
|
||||
|
||||
const handleAddFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !grf) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const newFile: GrfFileEntry = {
|
||||
filename: file.name,
|
||||
data: new Uint8Array(buffer)
|
||||
};
|
||||
setGrf({
|
||||
...grf,
|
||||
files: [...grf.files, newFile]
|
||||
});
|
||||
showNotification(`Added ${file.name}`);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDeleteFile = (index: number) => {
|
||||
if (!grf) return;
|
||||
playPressSound();
|
||||
const newFiles = [...grf.files];
|
||||
const removed = newFiles.splice(index, 1)[0];
|
||||
setGrf({ ...grf, files: newFiles });
|
||||
showNotification(`Removed ${removed.filename}`);
|
||||
};
|
||||
|
||||
const handleUpdateParameter = (nodePath: string[], paramIndex: number, value: string) => {
|
||||
if (!grf) return;
|
||||
const newGrf = { ...grf };
|
||||
let current = newGrf.root;
|
||||
for (const part of nodePath) {
|
||||
const found = current.children.find(c => c.name === part);
|
||||
if (found) current = found;
|
||||
}
|
||||
current.parameters[paramIndex].value = value;
|
||||
setGrf(newGrf);
|
||||
};
|
||||
|
||||
const handleSaveGrf = () => {
|
||||
if (!grf) return;
|
||||
playPressSound();
|
||||
@@ -65,12 +108,20 @@ export default function GrfEditorView() {
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".grf" />
|
||||
<input type="file" ref={addFileInputRef} onChange={handleAddFile} className="hidden" />
|
||||
<div className="flex items-center justify-between mb-6 px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">GRF Editor</h2>
|
||||
{grf && <span className="text-white/40 mc-text-shadow italic">editing: <span className="text-[#FFFF55]">{filename}</span></span>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleNewGrf}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
New GRF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
@@ -115,31 +166,49 @@ export default function GrfEditorView() {
|
||||
{activeTab === "rules" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{grf.root.children.map((node, i) => (
|
||||
<GrfNodeView key={i} node={node} level={0} />
|
||||
<GrfNodeView key={i} node={node} level={0} path={[]} onUpdate={handleUpdateParameter} />
|
||||
))}
|
||||
{grf.root.children.length === 0 && <span className="text-white/40 italic">No rules found</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#252525]">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Filename</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grf.files.length === 0 && (
|
||||
<tr><td colSpan={2} className="p-4 text-center text-white/40">No files in GRF</td></tr>
|
||||
)}
|
||||
{grf.files.map((f, i) => (
|
||||
<tr key={i} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors">
|
||||
<td className="p-3 text-white font-medium">{f.filename}</td>
|
||||
<td className="p-3 text-white/60 text-right text-xs">{(f.data.length / 1024).toFixed(2)} KB</td>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
onClick={() => addFileInputRef.current?.click()}
|
||||
className="self-start px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add File
|
||||
</button>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#252525]">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Filename</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Size</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-center">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grf.files.length === 0 && (
|
||||
<tr><td colSpan={3} className="p-4 text-center text-white/40">No files in GRF</td></tr>
|
||||
)}
|
||||
{grf.files.map((f, i) => (
|
||||
<tr key={i} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors">
|
||||
<td className="p-3 text-white font-medium">{f.filename}</td>
|
||||
<td className="p-3 text-white/60 text-right text-xs">{(f.data.length / 1024).toFixed(2)} KB</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDeleteFile(i)}
|
||||
className="text-[#FF5555] hover:text-[#FF8888] transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,11 +243,13 @@ export default function GrfEditorView() {
|
||||
);
|
||||
}
|
||||
|
||||
function GrfNodeView({ node, level }: { node: GrfNode, level: number }) {
|
||||
function GrfNodeView({ node, level, path, onUpdate }: { node: GrfNode, level: number, path: string[], onUpdate: (path: string[], paramIdx: number, val: string) => void }) {
|
||||
const [expanded, setExpanded] = useState(level < 1);
|
||||
const currentPath = [...path, node.name];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-1 select-none">
|
||||
<div
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 hover:bg-white/10 cursor-pointer transition-colors"
|
||||
style={{ paddingLeft: `${level * 24 + 8}px` }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
@@ -206,15 +277,20 @@ function GrfNodeView({ node, level }: { node: GrfNode, level: number }) {
|
||||
{node.parameters.length > 0 && (
|
||||
<div className="flex flex-col bg-black/20 p-2 mb-2 ml-4">
|
||||
{node.parameters.map((p, i) => (
|
||||
<div key={i} className="flex gap-4 border-b border-[#373737]/30 py-1 text-sm">
|
||||
<div key={i} className="flex gap-4 border-b border-[#373737]/30 py-2 text-sm items-center">
|
||||
<span className="text-[#AAAAAA] w-1/3 truncate">{p.name}</span>
|
||||
<span className="text-white flex-1 whitespace-pre-wrap font-mono">{p.value}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={p.value}
|
||||
onChange={(e) => onUpdate(path, i, e.target.value)}
|
||||
className="flex-1 bg-white/5 border border-white/10 px-2 py-1 text-white outline-none focus:border-[#FFFF55]/50 font-mono transition-colors"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{node.children.map((child, i) => (
|
||||
<GrfNodeView key={i} node={child} level={level + 1} />
|
||||
<GrfNodeView key={i} node={child} level={level + 1} path={currentPath} onUpdate={onUpdate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -302,4 +302,21 @@ export class GrfService {
|
||||
|
||||
return finalBuffer.buffer;
|
||||
}
|
||||
|
||||
public static createDefaultGRF(): GrfFile {
|
||||
return {
|
||||
header: {
|
||||
compressionLevel: GrfCompressionLevel.CompressedRle,
|
||||
crc: 0,
|
||||
compressionType: GrfCompressionType.Zlib,
|
||||
unknownData: new Uint8Array([0, 0, 0, 0])
|
||||
},
|
||||
files: [],
|
||||
root: {
|
||||
name: "__ROOT__",
|
||||
parameters: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user