mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 00:07:09 +00:00
feat: colours and game rules editors
This commit is contained in:
parent
3088ea0fd3
commit
3f4a776600
80
package.json
80
package.json
|
|
@ -1,39 +1,41 @@
|
|||
{
|
||||
"name": "emerald-legacy-launcher",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:build": "tauri build && pnpm run post-build:macos",
|
||||
"post-build:macos": "sh src-tauri/scripts/post-build-macos.sh",
|
||||
"flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"framer-motion": "^12.36.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "^0.183.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.183.1",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"tauri-plugin-drpc": "^1.0.3",
|
||||
"tauri-plugin-gamepad-api": "^0.0.5"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "emerald-legacy-launcher",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:build": "tauri build && pnpm run post-build:macos",
|
||||
"post-build:macos": "sh src-tauri/scripts/post-build-macos.sh",
|
||||
"flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"framer-motion": "^12.36.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "^0.183.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tauri-plugin-drpc": "^1.0.3",
|
||||
"tauri-plugin-gamepad-api": "^0.0.5",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ importers:
|
|||
framer-motion:
|
||||
specifier: ^12.36.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pako:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.4
|
||||
|
|
@ -36,6 +39,9 @@ importers:
|
|||
'@tauri-apps/cli':
|
||||
specifier: ^2
|
||||
version: 2.10.1
|
||||
'@types/pako':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
'@types/react':
|
||||
specifier: ^19.1.8
|
||||
version: 19.2.14
|
||||
|
|
@ -371,79 +377,66 @@ packages:
|
|||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
|
|
@ -513,28 +506,24 @@ packages:
|
|||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||
|
|
@ -601,35 +590,30 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
|
||||
|
|
@ -675,6 +659,9 @@ packages:
|
|||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -849,28 +836,24 @@ packages:
|
|||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||
|
|
@ -914,6 +897,9 @@ packages:
|
|||
node-releases@2.0.36:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
|
|
@ -1484,6 +1470,8 @@ snapshots:
|
|||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
|
@ -1692,6 +1680,8 @@ snapshots:
|
|||
|
||||
node-releases@2.0.36: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
|
|
|||
BIN
public/images/tools/col.png
Normal file
BIN
public/images/tools/col.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
BIN
public/images/tools/grf.png
Normal file
BIN
public/images/tools/grf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
395
src/components/views/ColEditorView.tsx
Normal file
395
src/components/views/ColEditorView.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { useState, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ColService } from "../../services/ColService";
|
||||
import { ColFile } from "../../types/col";
|
||||
function argbToHex(argb: number) {
|
||||
return (argb >>> 0).toString(16).padStart(8, '0').toUpperCase();
|
||||
}
|
||||
|
||||
function hexToArgb(hex: string) {
|
||||
const cleanHex = hex.replace("#", "");
|
||||
return parseInt(cleanHex, 16) >>> 0;
|
||||
}
|
||||
|
||||
export default function ColEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [col, setCol] = useState<ColFile | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<"colors" | "worldColors">("colors");
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const currentColors = useMemo(() => {
|
||||
if (!col) return [];
|
||||
return col.colors.map((c, i) => ({ ...c, originalIdx: i }))
|
||||
.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}, [col, searchTerm]);
|
||||
|
||||
const currentWorldColors = useMemo(() => {
|
||||
if (!col) return [];
|
||||
return col.worldColors.map((c, i) => ({ ...c, originalIdx: i }))
|
||||
.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}, [col, searchTerm]);
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
const parsedCol = ColService.readCOL(buffer);
|
||||
setCol(parsedCol);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to parse COL", err);
|
||||
showNotification(err.message || "Failed to parse COL", "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleSaveCol = () => {
|
||||
if (!col) return;
|
||||
playPressSound();
|
||||
try {
|
||||
const buffer = ColService.serializeCOL(col);
|
||||
const blob = new Blob([buffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "colours.col";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("COL Saved Successfully");
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save COL", err);
|
||||
showNotification(err.message || "Failed to save COL", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateColor = (idx: number, field: string, val: string | number) => {
|
||||
if (!col) return;
|
||||
const newCol = { ...col, colors: [...col.colors] };
|
||||
newCol.colors[idx] = { ...newCol.colors[idx], [field]: val };
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
const handleUpdateWorldColor = (idx: number, field: string, val: string | number) => {
|
||||
if (!col) return;
|
||||
const newCol = { ...col, worldColors: [...col.worldColors] };
|
||||
newCol.worldColors[idx] = { ...newCol.worldColors[idx], [field]: val };
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
const handleAddColor = () => {
|
||||
if (!col) return;
|
||||
playPressSound();
|
||||
setCol({
|
||||
...col,
|
||||
colors: [{ name: "NewColor", color: 0xFFFFFFFF }, ...col.colors]
|
||||
});
|
||||
showNotification("Color Added");
|
||||
};
|
||||
|
||||
const handleAddWorldColor = () => {
|
||||
if (!col) return;
|
||||
playPressSound();
|
||||
setCol({
|
||||
...col,
|
||||
worldColors: [{ name: "NewWorldColor", waterColor: 0xFFFFFFFF, underwaterColor: 0xFFFFFFFF, fogColor: 0xFFFFFFFF }, ...col.worldColors]
|
||||
});
|
||||
showNotification("World Color Added");
|
||||
};
|
||||
|
||||
const handleDeleteColor = (idx: number) => {
|
||||
if (!col) return;
|
||||
playBackSound();
|
||||
const newCol = { ...col, colors: [...col.colors] };
|
||||
newCol.colors.splice(idx, 1);
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
const handleDeleteWorldColor = (idx: number) => {
|
||||
if (!col) return;
|
||||
playBackSound();
|
||||
const newCol = { ...col, worldColors: [...col.worldColors] };
|
||||
newCol.worldColors.splice(idx, 1);
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".col" />
|
||||
<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">COL Editor</h2>
|
||||
{col && <span className="text-white/40 mc-text-shadow italic">Version: <span className="text-[#FFFF55]">{col.version}</span></span>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open COL
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveCol}
|
||||
disabled={!col}
|
||||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!col ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save COL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!col ? (
|
||||
<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" }}>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open a COL file to begin editing</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="flex gap-1 p-2 pt-4 border-b-2 border-[#373737]">
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("colors"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "colors" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<span className="text-lg">Colors ({col.colors.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("worldColors"); }}
|
||||
disabled={col.version === 0}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "worldColors" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"} ${col.version === 0 ? "opacity-20 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span className="text-lg">World Colors ({col.worldColors.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search colors..."
|
||||
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={activeTab === "colors" ? handleAddColor : handleAddWorldColor}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add {activeTab === "colors" ? "Color" : "World Color"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-[#252525] z-10 w-full mb-2">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold w-1/4">Name</th>
|
||||
{activeTab === "colors" && <th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Color (ARGB)</th>}
|
||||
{activeTab === "worldColors" && (
|
||||
<>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Water (ARGB)</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Underwater (ARGB)</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Fog (ARGB)</th>
|
||||
</>
|
||||
)}
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right w-16">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeTab === "colors" && currentColors.map((c) => (
|
||||
<tr key={c.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={c.name}
|
||||
onChange={(e) => handleUpdateColor(c.originalIdx, "name", e.target.value)}
|
||||
className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(c.color).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(c.color).substring(0, 2);
|
||||
const newArgb = hexToArgb(`${alpha}${rgb}`);
|
||||
handleUpdateColor(c.originalIdx, "color", newArgb);
|
||||
}}
|
||||
className="w-8 h-8 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(c.color)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateColor(c.originalIdx, "color", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-24 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-sm uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<button onClick={() => handleDeleteColor(c.originalIdx)} className="p-1 hover:text-red-500 opacity-60 hover:opacity-100 transition-colors">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{activeTab === "worldColors" && currentWorldColors.map((w) => (
|
||||
<tr key={w.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={w.name}
|
||||
onChange={(e) => handleUpdateWorldColor(w.originalIdx, "name", e.target.value)}
|
||||
className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(w.waterColor).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(w.waterColor).substring(0, 2);
|
||||
handleUpdateWorldColor(w.originalIdx, "waterColor", hexToArgb(`${alpha}${rgb}`));
|
||||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.waterColor)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "waterColor", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(w.underwaterColor).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(w.underwaterColor).substring(0, 2);
|
||||
handleUpdateWorldColor(w.originalIdx, "underwaterColor", hexToArgb(`${alpha}${rgb}`));
|
||||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.underwaterColor)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "underwaterColor", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(w.fogColor).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(w.fogColor).substring(0, 2);
|
||||
handleUpdateWorldColor(w.originalIdx, "fogColor", hexToArgb(`${alpha}${rgb}`));
|
||||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.fogColor)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "fogColor", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<button onClick={() => handleDeleteWorldColor(w.originalIdx)} className="p-1 hover:text-red-500 opacity-60 hover:opacity-100 transition-colors">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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-[100] 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ interface DevTool {
|
|||
const DEV_TOOLS: DevTool[] = [
|
||||
{ id: "pck", name: "PCK Editor", view: "pck-editor", comingSoon: false },
|
||||
{ id: "arc", name: "ARC Editor", view: "arc-editor", comingSoon: false },
|
||||
{ id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false }
|
||||
{ id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false },
|
||||
{ id: "grf", name: "GRF Editor", view: "grf-editor", comingSoon: false },
|
||||
{ id: "col", name: "COL Editor", view: "col-editor", comingSoon: false }
|
||||
];
|
||||
|
||||
export default function DevtoolsView() {
|
||||
|
|
|
|||
223
src/components/views/GrfEditorView.tsx
Normal file
223
src/components/views/GrfEditorView.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
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";
|
||||
|
||||
export default function GrfEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [grf, setGrf] = useState<GrfFile | null>(null);
|
||||
const [filename, setFilename] = useState("game_rules.grf");
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = 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);
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
const parsedGrf = GrfService.readGRF(buffer);
|
||||
setGrf(parsedGrf);
|
||||
setFilename(file.name);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to parse GRF", err);
|
||||
showNotification(err.message || "Failed to parse GRF", "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleSaveGrf = () => {
|
||||
if (!grf) return;
|
||||
playPressSound();
|
||||
try {
|
||||
const buffer = GrfService.serializeGRF(grf);
|
||||
const blob = new Blob([buffer as any]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("GRF Saved Successfully");
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save GRF", err);
|
||||
showNotification(err.message || "Failed to save GRF", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".grf" />
|
||||
<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={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open GRF
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveGrf}
|
||||
disabled={!grf}
|
||||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!grf ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save GRF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!grf ? (
|
||||
<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" }}>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open a GRF file to begin editing</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="flex gap-1 p-2 pt-4 border-b-2 border-[#373737]">
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("rules"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "rules" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<span className="text-lg">Game Rules</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("files"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "files" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<span className="text-lg">Files ({grf.files.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 custom-scrollbar">
|
||||
{activeTab === "rules" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{grf.root.children.map((node, i) => (
|
||||
<GrfNodeView key={i} node={node} level={0} />
|
||||
))}
|
||||
{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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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-[100] 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>
|
||||
);
|
||||
}
|
||||
|
||||
function GrfNodeView({ node, level }: { node: GrfNode, level: number }) {
|
||||
const [expanded, setExpanded] = useState(level < 1);
|
||||
return (
|
||||
<div className="flex flex-col mb-1 select-none">
|
||||
<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)}
|
||||
>
|
||||
{node.children.length > 0 ? (
|
||||
<img
|
||||
src={expanded ? "/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.children.length > 0 ? "/images/Folder_Icon.png" : "/images/tools/grf.png"}
|
||||
className="w-4 h-4 object-contain grayscale opacity-60"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => (e.currentTarget.src = "/images/tools/pck.png")}
|
||||
/>
|
||||
<span className="text-[#FFFF55] font-bold">{node.name}</span>
|
||||
{node.parameters.length > 0 && <span className="text-white/40 text-xs ml-2">[{node.parameters.length} props]</span>}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="flex flex-col border-l-2 border-[#373737] ml-2 pl-2">
|
||||
{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">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{node.children.map((child, i) => (
|
||||
<GrfNodeView key={i} node={child} level={level + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import SetupView from "../components/views/SetupView";
|
|||
import PckEditorView from "../components/views/PckEditorView";
|
||||
import { ArcEditorView } from "../components/views/ArcEditorView";
|
||||
import LocEditorView from "../components/views/LocEditorView";
|
||||
import GrfEditorView from "../components/views/GrfEditorView";
|
||||
import ColEditorView from "../components/views/ColEditorView";
|
||||
import ScreenshotsView from "../components/views/ScreenshotsView";
|
||||
import SkinViewer from "../components/common/SkinViewer";
|
||||
import TeamModal from "../components/modals/TeamModal";
|
||||
|
|
@ -352,6 +354,12 @@ export default function App() {
|
|||
{activeView === "loc-editor" && (
|
||||
<LocEditorView key="loc-editor-view" />
|
||||
)}
|
||||
{activeView === "grf-editor" && (
|
||||
<GrfEditorView key="grf-editor-view" />
|
||||
)}
|
||||
{activeView === "col-editor" && (
|
||||
<ColEditorView key="col-editor-view" />
|
||||
)}
|
||||
{activeView === "skins" && <SkinsView key="skins-view" />}
|
||||
{activeView === "screenshots" && (
|
||||
<ScreenshotsView key="screenshots-view" />
|
||||
|
|
|
|||
79
src/services/ColService.ts
Normal file
79
src/services/ColService.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { ColFile, ColColor, ColWorldColor } from "../types/col";
|
||||
|
||||
export class ColService {
|
||||
private static textDecoder = new TextDecoder("utf-8");
|
||||
private static textEncoder = new TextEncoder();
|
||||
public static readCOL(buffer: ArrayBuffer): ColFile {
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
const version = view.getInt32(offset, false); offset += 4;
|
||||
const colorCount = view.getInt32(offset, false); offset += 4;
|
||||
const colors: ColColor[] = [];
|
||||
for (let i = 0; i < colorCount; i++) {
|
||||
const nameLength = view.getUint16(offset, false); offset += 2;
|
||||
const name = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + offset, nameLength));
|
||||
offset += nameLength;
|
||||
const color = view.getUint32(offset, false); offset += 4;
|
||||
colors.push({ name, color });
|
||||
}
|
||||
|
||||
const worldColors: ColWorldColor[] = [];
|
||||
if (version > 0 && offset < view.byteLength) {
|
||||
const worldColorCount = view.getInt32(offset, false); offset += 4;
|
||||
for (let i = 0; i < worldColorCount; i++) {
|
||||
const nameLength = view.getUint16(offset, false); offset += 2;
|
||||
const name = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + offset, nameLength));
|
||||
offset += nameLength;
|
||||
const waterColor = view.getUint32(offset, false); offset += 4;
|
||||
const underwaterColor = view.getUint32(offset, false); offset += 4;
|
||||
const fogColor = view.getUint32(offset, false); offset += 4;
|
||||
worldColors.push({ name, waterColor, underwaterColor, fogColor });
|
||||
}
|
||||
}
|
||||
|
||||
return { version, colors, worldColors };
|
||||
}
|
||||
|
||||
public static serializeCOL(col: ColFile): ArrayBuffer {
|
||||
let size = 8;
|
||||
for (const c of col.colors) {
|
||||
const nameBytes = this.textEncoder.encode(c.name);
|
||||
size += 2 + nameBytes.length + 4;
|
||||
}
|
||||
|
||||
if (col.version > 0) {
|
||||
size += 4;
|
||||
for (const w of col.worldColors) {
|
||||
const nameBytes = this.textEncoder.encode(w.name);
|
||||
size += 2 + nameBytes.length + 12;
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = new ArrayBuffer(size);
|
||||
const view = new DataView(buffer);
|
||||
const out = new Uint8Array(buffer);
|
||||
let offset = 0;
|
||||
view.setInt32(offset, col.version, false); offset += 4;
|
||||
view.setInt32(offset, col.colors.length, false); offset += 4;
|
||||
for (const c of col.colors) {
|
||||
const nameBytes = this.textEncoder.encode(c.name);
|
||||
view.setUint16(offset, nameBytes.length, false); offset += 2;
|
||||
out.set(nameBytes, offset); offset += nameBytes.length;
|
||||
view.setUint32(offset, c.color, false); offset += 4;
|
||||
}
|
||||
|
||||
if (col.version > 0) {
|
||||
view.setInt32(offset, col.worldColors.length, false); offset += 4;
|
||||
for (const w of col.worldColors) {
|
||||
const nameBytes = this.textEncoder.encode(w.name);
|
||||
view.setUint16(offset, nameBytes.length, false); offset += 2;
|
||||
out.set(nameBytes, offset); offset += nameBytes.length;
|
||||
view.setUint32(offset, w.waterColor, false); offset += 4;
|
||||
view.setUint32(offset, w.underwaterColor, false); offset += 4;
|
||||
view.setUint32(offset, w.fogColor, false); offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
306
src/services/GrfService.ts
Normal file
306
src/services/GrfService.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import pako from "pako";
|
||||
import {
|
||||
GrfCompressionLevel,
|
||||
GrfCompressionType,
|
||||
GrfFile,
|
||||
GrfFileEntry,
|
||||
GrfHeader,
|
||||
GrfNode
|
||||
} from "../types/grf";
|
||||
|
||||
export class GrfService {
|
||||
private static textDecoder = new TextDecoder("ascii");
|
||||
private static textEncoder = new TextEncoder();
|
||||
private static rleDecode(data: Uint8Array): Uint8Array {
|
||||
const output: number[] = [];
|
||||
const marker = 0xff;
|
||||
const maxLength = 0xfe;
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
const val = data[i++];
|
||||
if (val !== marker) {
|
||||
output.push(val);
|
||||
} else {
|
||||
if (i >= data.length) throw new Error("Invalid RLE");
|
||||
const next = data[i++];
|
||||
if (next === marker) {
|
||||
output.push(marker);
|
||||
} else {
|
||||
const length = next;
|
||||
if (length > 2 && length <= maxLength) {
|
||||
if (i >= data.length) throw new Error("Invalid RLE");
|
||||
const runVal = data[i++];
|
||||
for (let j = 0; j < length + 1; j++) {
|
||||
output.push(runVal);
|
||||
}
|
||||
} else {
|
||||
output.push(length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Uint8Array(output);
|
||||
}
|
||||
|
||||
private static rleEncode(data: Uint8Array): Uint8Array {
|
||||
const output: number[] = [];
|
||||
if (data.length === 0) return new Uint8Array(0);
|
||||
const marker = 0xff;
|
||||
const maxLength = 0xfe;
|
||||
let firstRunValue = data[0];
|
||||
let runLength = 1;
|
||||
const makeRun = (value: number, length: number) => {
|
||||
if ((length <= 3 && value !== marker) || length <= 1) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (value === marker) {
|
||||
output.push(marker, marker);
|
||||
} else {
|
||||
output.push(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.push(marker);
|
||||
output.push(length - 1);
|
||||
output.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const currentValue = data[i];
|
||||
if (currentValue === firstRunValue) {
|
||||
runLength++;
|
||||
} else {
|
||||
makeRun(firstRunValue, runLength);
|
||||
firstRunValue = currentValue;
|
||||
runLength = 1;
|
||||
}
|
||||
|
||||
if (runLength > maxLength) {
|
||||
makeRun(firstRunValue, maxLength);
|
||||
runLength -= maxLength;
|
||||
}
|
||||
}
|
||||
makeRun(firstRunValue, runLength);
|
||||
return new Uint8Array(output);
|
||||
}
|
||||
|
||||
private static readStringList(view: DataView, off: { p: number }): string[] {
|
||||
const count = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const list: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const len = view.getInt16(off.p, false);
|
||||
off.p += 2;
|
||||
const str = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + off.p, len));
|
||||
off.p += len;
|
||||
list.push(str);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static readGRF(buffer: ArrayBuffer): GrfFile {
|
||||
const view = new DataView(buffer);
|
||||
let off = { p: 0 };
|
||||
const firstWord = view.getUint16(off.p, false);
|
||||
off.p += 2;
|
||||
let header: GrfHeader;
|
||||
if (firstWord === 0) {
|
||||
off.p += 14;
|
||||
header = {
|
||||
compressionLevel: GrfCompressionLevel.None,
|
||||
crc: 0xffffffff,
|
||||
compressionType: GrfCompressionType.Unknown,
|
||||
unknownData: new Uint8Array([0, 0, 0, 0])
|
||||
};
|
||||
} else {
|
||||
const compressionLevel = view.getUint8(off.p++) as GrfCompressionLevel;
|
||||
const crc = view.getUint32(off.p, false);
|
||||
off.p += 4;
|
||||
const unknownData = new Uint8Array(view.buffer, view.byteOffset + off.p, 4);
|
||||
off.p += 4;
|
||||
if (unknownData[3] > 0) {
|
||||
throw new Error("World grf's are not currently supported.");
|
||||
}
|
||||
header = {
|
||||
compressionLevel,
|
||||
crc,
|
||||
compressionType: GrfCompressionType.Zlib,
|
||||
unknownData
|
||||
};
|
||||
}
|
||||
|
||||
let bodyData: Uint8Array;
|
||||
let bodyView: DataView;
|
||||
let bodyOff = { p: 0 };
|
||||
if (header.compressionLevel !== GrfCompressionLevel.None) {
|
||||
view.getInt32(off.p, false); // decompressedSize
|
||||
off.p += 4;
|
||||
const compressedSize = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const compressedData = new Uint8Array(view.buffer, view.byteOffset + off.p, compressedSize);
|
||||
off.p += compressedSize;
|
||||
let decompressed: Uint8Array;
|
||||
if (header.compressionType === GrfCompressionType.Zlib || true) {
|
||||
try {
|
||||
decompressed = pako.inflate(compressedData);
|
||||
} catch {
|
||||
decompressed = pako.inflateRaw(compressedData); //neo: fallback to pako.inflateRaw if raw deflate
|
||||
}
|
||||
}
|
||||
|
||||
if (header.compressionLevel > GrfCompressionLevel.Compressed) {
|
||||
bodyData = this.rleDecode(decompressed!);
|
||||
} else {
|
||||
bodyData = decompressed!;
|
||||
}
|
||||
|
||||
bodyView = new DataView(bodyData.buffer, bodyData.byteOffset, bodyData.byteLength);
|
||||
} else {
|
||||
bodyData = new Uint8Array(view.buffer, view.byteOffset + off.p);
|
||||
bodyView = new DataView(bodyData.buffer, bodyData.byteOffset, bodyData.byteLength);
|
||||
}
|
||||
|
||||
const lut = this.readStringList(bodyView, bodyOff);
|
||||
const fileCount = bodyView.getInt32(bodyOff.p, false);
|
||||
bodyOff.p += 4;
|
||||
const files: GrfFileEntry[] = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const nlen = bodyView.getInt16(bodyOff.p, false);
|
||||
bodyOff.p += 2;
|
||||
const filename = this.textDecoder.decode(new Uint8Array(bodyView.buffer, bodyView.byteOffset + bodyOff.p, nlen));
|
||||
bodyOff.p += nlen;
|
||||
const size = bodyView.getInt32(bodyOff.p, false);
|
||||
bodyOff.p += 4;
|
||||
const data = new Uint8Array(bodyView.buffer, bodyView.byteOffset + bodyOff.p, size).slice();
|
||||
bodyOff.p += size;
|
||||
files.push({ filename, data });
|
||||
}
|
||||
|
||||
const root: GrfNode = { name: "__ROOT__", parameters: [], children: [] };
|
||||
this.readGameRuleHierarchy(bodyView, bodyOff, root, lut);
|
||||
return { header, files, root };
|
||||
}
|
||||
|
||||
private static readGameRuleHierarchy(view: DataView, off: { p: number }, parent: GrfNode, lut: string[]) {
|
||||
const count = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nameIdx = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const paramCount = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
|
||||
const node: GrfNode = {
|
||||
name: lut[nameIdx],
|
||||
parameters: [],
|
||||
children: []
|
||||
};
|
||||
|
||||
for (let j = 0; j < paramCount; j++) {
|
||||
const kIdx = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const vLen = view.getInt16(off.p, false);
|
||||
off.p += 2;
|
||||
const value = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + off.p, vLen));
|
||||
off.p += vLen;
|
||||
node.parameters.push({ name: lut[kIdx], value });
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
this.readGameRuleHierarchy(view, off, node, lut);
|
||||
}
|
||||
}
|
||||
|
||||
private static makeString(s: string): Uint8Array {
|
||||
const enc = this.textEncoder.encode(s);
|
||||
const out = new Uint8Array(2 + enc.length);
|
||||
new DataView(out.buffer).setInt16(0, enc.length, false);
|
||||
out.set(enc, 2);
|
||||
return out;
|
||||
}
|
||||
|
||||
public static serializeGRF(grf: GrfFile): ArrayBuffer {
|
||||
const lut: string[] = [];
|
||||
const buildLut = (node: GrfNode) => {
|
||||
if (!lut.includes(node.name) && node.name !== "__ROOT__") lut.push(node.name);
|
||||
for (const p of node.parameters) {
|
||||
if (!lut.includes(p.name)) lut.push(p.name);
|
||||
}
|
||||
for (const c of node.children) buildLut(c);
|
||||
};
|
||||
buildLut(grf.root);
|
||||
let parts: Uint8Array[] = [];
|
||||
const lutCount = new Uint8Array(4);
|
||||
new DataView(lutCount.buffer).setInt32(0, lut.length, false);
|
||||
parts.push(lutCount);
|
||||
for (const s of lut) parts.push(this.makeString(s));
|
||||
const fCount = new Uint8Array(4);
|
||||
new DataView(fCount.buffer).setInt32(0, grf.files.length, false);
|
||||
parts.push(fCount);
|
||||
for (const f of grf.files) {
|
||||
parts.push(this.makeString(f.filename));
|
||||
const sz = new Uint8Array(4);
|
||||
new DataView(sz.buffer).setInt32(0, f.data.length, false);
|
||||
parts.push(sz);
|
||||
parts.push(f.data);
|
||||
}
|
||||
|
||||
const writeNode = (node: GrfNode) => {
|
||||
const cCount = new Uint8Array(4);
|
||||
new DataView(cCount.buffer).setInt32(0, node.children.length, false);
|
||||
parts.push(cCount);
|
||||
for (const c of node.children) {
|
||||
const nid = new Uint8Array(4);
|
||||
new DataView(nid.buffer).setInt32(0, lut.indexOf(c.name), false);
|
||||
parts.push(nid);
|
||||
const pCount = new Uint8Array(4);
|
||||
new DataView(pCount.buffer).setInt32(0, c.parameters.length, false);
|
||||
parts.push(pCount);
|
||||
for (const p of c.parameters) {
|
||||
const pid = new Uint8Array(4);
|
||||
new DataView(pid.buffer).setInt32(0, lut.indexOf(p.name), false);
|
||||
parts.push(pid);
|
||||
parts.push(this.makeString(p.value));
|
||||
}
|
||||
|
||||
writeNode(c);
|
||||
}
|
||||
};
|
||||
writeNode(grf.root);
|
||||
let uncompressedSize = parts.reduce((acc, p) => acc + p.length, 0);
|
||||
const uncompressedData = new Uint8Array(uncompressedSize);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
uncompressedData.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
|
||||
let bodyData = uncompressedData;
|
||||
let header = grf.header;
|
||||
if (header.compressionLevel >= GrfCompressionLevel.CompressedRle) {
|
||||
bodyData = this.rleEncode(bodyData);
|
||||
}
|
||||
|
||||
let compressedSize = 0;
|
||||
if (header.compressionLevel >= GrfCompressionLevel.Compressed) {
|
||||
bodyData = pako.deflate(bodyData, { level: 9 });
|
||||
compressedSize = bodyData.length;
|
||||
}
|
||||
|
||||
const finalBuffer = new Uint8Array(10 + (header.compressionLevel >= GrfCompressionLevel.Compressed ? 8 : 0) + bodyData.length);
|
||||
const fw = new DataView(finalBuffer.buffer);
|
||||
let poff = 0;
|
||||
fw.setInt16(poff, 1, false); poff += 2;
|
||||
fw.setUint8(poff, header.compressionLevel); poff += 1;
|
||||
fw.setUint32(poff, header.crc, false); poff += 4;
|
||||
finalBuffer.set(header.unknownData, poff); poff += 4;
|
||||
if (header.compressionLevel >= GrfCompressionLevel.Compressed) {
|
||||
fw.setInt32(poff, uncompressedSize, false); poff += 4;
|
||||
fw.setInt32(poff, compressedSize, false); poff += 4;
|
||||
}
|
||||
|
||||
finalBuffer.set(bodyData as any, poff);
|
||||
|
||||
return finalBuffer.buffer;
|
||||
}
|
||||
}
|
||||
17
src/types/col.ts
Normal file
17
src/types/col.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export interface ColColor {
|
||||
name: string;
|
||||
color: number;
|
||||
}
|
||||
|
||||
export interface ColWorldColor {
|
||||
name: string;
|
||||
waterColor: number;
|
||||
underwaterColor: number;
|
||||
fogColor: number;
|
||||
}
|
||||
|
||||
export interface ColFile {
|
||||
version: number;
|
||||
colors: ColColor[];
|
||||
worldColors: ColWorldColor[];
|
||||
}
|
||||
42
src/types/grf.ts
Normal file
42
src/types/grf.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export enum GrfCompressionLevel {
|
||||
None = 0,
|
||||
Compressed = 1,
|
||||
CompressedRle = 2,
|
||||
CompressedRleCrc = 3,
|
||||
}
|
||||
|
||||
export enum GrfCompressionType {
|
||||
Unknown = -1,
|
||||
Zlib = 0,
|
||||
Deflate = 1,
|
||||
XMem = 2,
|
||||
}
|
||||
|
||||
export interface GrfHeader {
|
||||
compressionLevel: GrfCompressionLevel;
|
||||
crc: number;
|
||||
compressionType: GrfCompressionType;
|
||||
unknownData: Uint8Array;
|
||||
}
|
||||
|
||||
export interface GrfFileEntry {
|
||||
filename: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface GrfParameter {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GrfNode {
|
||||
name: string;
|
||||
parameters: GrfParameter[];
|
||||
children: GrfNode[];
|
||||
}
|
||||
|
||||
export interface GrfFile {
|
||||
header: GrfHeader;
|
||||
files: GrfFileEntry[];
|
||||
root: GrfNode;
|
||||
}
|
||||
Loading…
Reference in a new issue