Co-authored-by: str1k3r <115313679+S1l3ntStr1ke87@users.noreply.github.com> Co-authored-by: ByteBukkit <codeddev21@gmail.com>
79
.github/workflows/experimental.yml
vendored
|
|
@ -3,7 +3,39 @@ on:
|
|||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-goldmapper:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: install mingw toolchain
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
- name: build GoldMapper
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm goldmapper
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: |
|
||||
src-tauri/resources/GoldMapperLib.dll
|
||||
src-tauri/resources/GoldMapperLauncher.exe
|
||||
src-tauri/resources/SDL2.dll
|
||||
|
||||
build-tauri:
|
||||
needs: [build-goldmapper]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -14,9 +46,12 @@ jobs:
|
|||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
artifact-name: macos-x86_64
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
- platform: "ubuntu-24.04"
|
||||
args: "--bundles deb,rpm"
|
||||
artifact-name: linux
|
||||
- platform: "ubuntu-24.04-arm"
|
||||
args: "--target aarch64-unknown-linux-gnu --bundles deb,rpm"
|
||||
artifact-name: linux-arm64
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
artifact-name: windows
|
||||
|
|
@ -24,12 +59,14 @@ jobs:
|
|||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libudev-dev
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
|
@ -41,10 +78,16 @@ jobs:
|
|||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || matrix.platform == 'ubuntu-24.04-arm' && 'aarch64-unknown-linux-gnu' || '' }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
|
@ -55,11 +98,15 @@ jobs:
|
|||
run: pnpm install
|
||||
|
||||
- name: build Tauri app
|
||||
run: pnpm run tauri:build -- ${{ matrix.args }}
|
||||
run: pnpm tauri build ${{ matrix.args }}
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: run macOS postbuild
|
||||
run: pnpm post-build:macos
|
||||
if: matrix.platform == 'macos-latest'
|
||||
|
||||
- name: Create PKG with post-install script (macOS only)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
run: |
|
||||
|
|
@ -88,6 +135,7 @@ jobs:
|
|||
src-tauri/target/**/release/bundle/**/*
|
||||
|
||||
build-flatpak:
|
||||
needs: [build-goldmapper]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: bilelmoussaoui/flatpak-github-actions:freedesktop-23.08
|
||||
|
|
@ -96,6 +144,11 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
- name: sleep for 10 seconds
|
||||
run: sleep 10
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
|
|
@ -110,6 +163,7 @@ jobs:
|
|||
path: Emerald.Legacy.Launcher.flatpak
|
||||
|
||||
build-appimage:
|
||||
needs: [build-goldmapper]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
|
@ -140,6 +194,12 @@ jobs:
|
|||
gtk3 libayatana-appindicator webkit2gtk-4.1 glib-networking librsvg \
|
||||
xorg-server-xvfb
|
||||
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
|
@ -170,6 +230,7 @@ jobs:
|
|||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-goldmapper]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
@ -184,6 +245,12 @@ jobs:
|
|||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
|
||||
- name: setup Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
|
|
|
|||
74
.github/workflows/publish.yml
vendored
|
|
@ -17,8 +17,39 @@ jobs:
|
|||
cat RELEASE_NOTES.md >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-goldmapper:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: install mingw toolchain
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
- name: build GoldMapper
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm goldmapper
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: |
|
||||
src-tauri/resources/GoldMapperLib.dll
|
||||
src-tauri/resources/GoldMapperLauncher.exe
|
||||
src-tauri/resources/SDL2.dll
|
||||
|
||||
publish-tauri:
|
||||
needs: get-version
|
||||
needs: [get-version, build-goldmapper]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
|
|
@ -29,20 +60,24 @@ jobs:
|
|||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
- platform: "ubuntu-24.04"
|
||||
args: "--bundles deb,rpm"
|
||||
- platform: "ubuntu-24.04-arm"
|
||||
args: "--target aarch64-unknown-linux-gnu --bundles deb,rpm"
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libudev-dev
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
|
@ -54,10 +89,16 @@ jobs:
|
|||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || matrix.platform == 'ubuntu-24.04-arm' && 'aarch64-unknown-linux-gnu' || '' }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
|
@ -81,6 +122,7 @@ jobs:
|
|||
args: ${{ matrix.args }}
|
||||
|
||||
build-appimage:
|
||||
needs: [build-goldmapper]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
|
@ -111,6 +153,12 @@ jobs:
|
|||
gtk3 libayatana-appindicator webkit2gtk-4.1 glib-networking librsvg \
|
||||
xorg-server-xvfb
|
||||
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
|
@ -141,6 +189,7 @@ jobs:
|
|||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-goldmapper]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
@ -155,6 +204,12 @@ jobs:
|
|||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
|
||||
- name: setup Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
|
|
@ -219,7 +274,7 @@ jobs:
|
|||
path: src-tauri/gen/android/app/build/outputs/apk/universal/release/*.apk
|
||||
|
||||
publish-flatpak:
|
||||
needs: [get-version, build-appimage, build-android]
|
||||
needs: [get-version, build-goldmapper, build-appimage, build-android]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -230,6 +285,11 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: download GoldMapper
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: goldmapper
|
||||
path: src-tauri/resources
|
||||
- name: sleep for 20 seconds
|
||||
run: sleep 20
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
|
|
|
|||
4
.gitignore
vendored
|
|
@ -37,6 +37,10 @@ Thumbs.db
|
|||
/emerald-repo
|
||||
/ignore/*
|
||||
|
||||
# GoldMapper binaries (generated by pnpm goldmapper)
|
||||
/src-tauri/resources/GoldMapperLib.dll
|
||||
/src-tauri/resources/GoldMapperLauncher.exe
|
||||
|
||||
# Android bundled runtime assets (too large for GitHub; must be present in the working tree)
|
||||
/src-tauri/gen/android/app/src/main/assets/imagefs.tar.zst
|
||||
/src-tauri/gen/android/app/src/main/assets/proton-*.tar.zst
|
||||
|
|
|
|||
3
.gitmodules
vendored
|
|
@ -4,3 +4,6 @@
|
|||
[submodule "DiamondRuntime"]
|
||||
path = DiamondRuntime
|
||||
url = https://github.com/LCE-Hub/DiamondRuntime.git
|
||||
[submodule "GoldMapper"]
|
||||
path = GoldMapper
|
||||
url = https://github.com/LCE-Hub/GoldMapper
|
||||
|
|
|
|||
1
GoldMapper
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 8d92ece7c2d9894bfde514f89d5ddb5651716eff
|
||||
14
README.md
|
|
@ -57,7 +57,7 @@ LCE Emerald Launcher is the easiest way to play Minecraft Legacy Console Edition
|
|||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Automated Setup** | One-click installation for neoLegacy, Revelations, 360 Revived, Hellish Ends and others |
|
||||
| **Automated Setup** | One-click installation for neoLegacy, Revelations, Hellish Ends, Moon Edition and others |
|
||||
| **Cross-Platform** | Native support for Windows, macOS (Intel & Apple Silicon), and Linux (Steam Deck is also supported!) |
|
||||
| **Lightweight** | Very light RAM usage thanks to Rust backend and Tauri framework |
|
||||
| **Easy Configuration** | Built-in settings for username, game parameters, and profiles |
|
||||
|
|
@ -68,7 +68,7 @@ LCE Emerald Launcher is the easiest way to play Minecraft Legacy Console Edition
|
|||
| **Workshop** | Community content like DLCs, Textures, Skins and more |
|
||||
| **Free Multiplayer** | Powered by LCEOnline, Emerald provides a free multiplayer service so you can play with anyone without port forwarding! |
|
||||
| **Developer Tools** | Create and edit 4J's propriatery file formats with ease! |
|
||||
| **World conversion** | Import `.ms` files, Java worlds or even an Xbox 360/PS3 world! |
|
||||
| **World conversion** | Import `.ms` files, Java worlds or even an Xbox 360/PS3 world! (Beta) |
|
||||
| **Playtime counting** | Count your playtime and see your most active days! |
|
||||
|
||||
---
|
||||
|
|
@ -220,15 +220,15 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0
|
|||
|
||||
### Game Not Launching
|
||||
|
||||
1. Verify game files are properly installed via the launcher
|
||||
1. Verify game files are properly installed via the launcher (also try clicking Repair which will reinstall it but keeps your data)
|
||||
2. Check that Wine/Proton is installed (Linux only)
|
||||
3. Check that Wine/GPTK3 are installed (macOS only)
|
||||
4. Ensure your GPU drivers are up to date
|
||||
|
||||
### Controller Not Detected
|
||||
|
||||
- Connect controller before launching the launcher
|
||||
- PlayStation controllers are not supported in-game but work in launcher. Use Steam Input.
|
||||
- Connect controller before launching the launcher or game.
|
||||
- PlayStation controllers are not supported in-game but work in launcher. **Enable GoldMapper from Help & Options -> Controls**, or use Steam Input.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -240,6 +240,10 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0
|
|||
- **The LCE Community** - Research and foundations for LCE on PC
|
||||
- **Str1k3r** - Original creator of LCEOnline
|
||||
- **DTention** - Original creator of [LCE-Save-Converter](https://github.com/dtentiion/LCE-Save-Converter)
|
||||
- **dydymiku** - French translation
|
||||
- **Hvrket** - Russian translation
|
||||
- **HuntedRaven (Robin)** - Homebrew, Nix and Gentoo packaging
|
||||
- **AntiApple4life** - AUR packaging
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
"tauri:build": "tauri build && pnpm run post-build:macos",
|
||||
"post-build:macos": "bash src-tauri/scripts/post-build-macos.sh",
|
||||
"android:assets": "node scripts/download-android-assets.mjs",
|
||||
"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"
|
||||
"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",
|
||||
"goldmapper": "node scripts/build-goldmapper.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
|
|
@ -20,10 +21,13 @@
|
|||
"@tauri-apps/plugin-process": "~2.3.1",
|
||||
"@tauri-apps/plugin-updater": "~2.10.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"i18next": "^26.4.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"pako": "^2.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ importers:
|
|||
framer-motion:
|
||||
specifier: ^12.36.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
i18next:
|
||||
specifier: ^26.4.0
|
||||
version: 26.4.0(typescript@5.8.3)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1
|
||||
pako:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
|
|
@ -38,6 +44,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-i18next:
|
||||
specifier: ^17.0.12
|
||||
version: 17.0.12(i18next@26.4.0(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -180,6 +189,10 @@ packages:
|
|||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -962,12 +975,26 @@ packages:
|
|||
hastscript@9.0.1:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
html-parse-stringify@4.0.1:
|
||||
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
i18next-browser-languagedetector@8.2.1:
|
||||
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
|
||||
|
||||
i18next@26.4.0:
|
||||
resolution: {integrity: sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
|
|
@ -1299,6 +1326,22 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-i18next@17.0.12:
|
||||
resolution: {integrity: sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.2.0'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -1440,6 +1483,11 @@ packages:
|
|||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
|
|
@ -1609,6 +1657,8 @@ snapshots:
|
|||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
|
|
@ -2285,10 +2335,20 @@ snapshots:
|
|||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
html-parse-stringify@4.0.1: {}
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
i18next-browser-languagedetector@8.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
|
||||
i18next@26.4.0(typescript@5.8.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
|
|
@ -2792,6 +2852,17 @@ snapshots:
|
|||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-i18next@17.0.12(i18next@26.4.0(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
html-parse-stringify: 4.0.1
|
||||
i18next: 26.4.0(typescript@5.8.3)
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
typescript: 5.8.3
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
|
@ -2996,6 +3067,10 @@ snapshots:
|
|||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
|
|
|||
BIN
public/Skins/ency.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/Skins/individul.png
Normal file
|
After Width: | Height: | Size: 678 B |
1
public/fonts/rus/COPYRIGHT.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Copyright © 2004-2012, BOGDAN Software
|
||||
BIN
public/fonts/rus/minecraft.ttf
Executable file
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.4 KiB |
BIN
public/images/Layout_Button_Bmp.png
Normal file
|
After Width: | Height: | Size: 923 B |
BIN
public/images/Layout_Button_Over.png
Normal file
|
After Width: | Height: | Size: 992 B |
BIN
public/images/MinecraftPS4Edition.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 72 KiB |
BIN
public/images/empty.png
Normal file
|
After Width: | Height: | Size: 651 B |
BIN
public/images/gear.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/images/goldmapper.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/images/helpbg.png
Normal file
|
After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 537 B |
BIN
public/images/projectlce.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
public/images/recess.png
Normal file
|
After Width: | Height: | Size: 151 B |
BIN
public/images/versionrecess.png
Normal file
|
After Width: | Height: | Size: 255 B |
BIN
public/panorama/legacyorbis_Panorama_Background_Day.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/panorama/legacyorbis_Panorama_Background_Night.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
76
scripts/build-goldmapper.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, copyFileSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const GOLDMAPPER_DIR = path.join(ROOT_DIR, "GoldMapper");
|
||||
const BUILD_DIR = path.join(GOLDMAPPER_DIR, "build");
|
||||
const RESOURCES_DIR = path.join(ROOT_DIR, "src-tauri", "resources");
|
||||
const TOOLCHAIN_MINGW = path.join(SCRIPT_DIR, "toolchain-mingw.cmake");
|
||||
const IS_WINDOWS = process.platform === "win32";
|
||||
function run(cmd, args, cwd) {
|
||||
console.log(`> ${cmd} ${args.join(" ")}`);
|
||||
const res = spawnSync(cmd, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: IS_WINDOWS,
|
||||
});
|
||||
if (res.status !== 0) {
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!existsSync(
|
||||
path.join(GOLDMAPPER_DIR, "third_party", "minhook", "include", "MinHook.h"),
|
||||
)
|
||||
) {
|
||||
console.log("Initializing minhook submodule...");
|
||||
run(
|
||||
"git",
|
||||
["-C", GOLDMAPPER_DIR, "submodule", "update", "--init", "--recursive"],
|
||||
ROOT_DIR,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Configuring GoldMapper...");
|
||||
const configureArgs = ["-B", BUILD_DIR, "-DCMAKE_BUILD_TYPE=Release"];
|
||||
if (!IS_WINDOWS) {
|
||||
configureArgs.push("-DCMAKE_TOOLCHAIN_FILE=" + TOOLCHAIN_MINGW);
|
||||
}
|
||||
configureArgs.push(GOLDMAPPER_DIR);
|
||||
run("cmake", configureArgs, ROOT_DIR);
|
||||
console.log("Building GoldMapper...");
|
||||
run("cmake", ["--build", BUILD_DIR, "--config", "Release"], ROOT_DIR);
|
||||
console.log("Copying binaries to resources...");
|
||||
mkdirSync(RESOURCES_DIR, { recursive: true });
|
||||
const outputs = [
|
||||
{
|
||||
source: "GoldMapperLib.dll",
|
||||
names: ["GoldMapperLib.dll", "libGoldMapperLib.dll"], //neo: dont ask me vro idk why it does that
|
||||
},
|
||||
{ source: "GoldMapperLauncher.exe", names: ["GoldMapperLauncher.exe"] },
|
||||
];
|
||||
|
||||
for (const entry of outputs) {
|
||||
const dir = entry.dir ?? BUILD_DIR;
|
||||
const file = path.join(dir, entry.source);
|
||||
if (existsSync(file)) {
|
||||
copyFileSync(file, path.join(RESOURCES_DIR, entry.source));
|
||||
continue;
|
||||
}
|
||||
const candidate = readdirSync(dir).find((f) =>
|
||||
entry.names.some((n) => f.toLowerCase() === n.toLowerCase()),
|
||||
);
|
||||
if (!candidate) {
|
||||
console.error(`Missing build output: ${entry.source}`);
|
||||
process.exit(1);
|
||||
}
|
||||
copyFileSync(
|
||||
path.join(dir, candidate),
|
||||
path.join(RESOURCES_DIR, entry.source),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("GoldMapper build complete.");
|
||||
5
scripts/toolchain-mingw.cmake
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR x86_64)
|
||||
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
|
||||
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
|
||||
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
|
||||
13
src-tauri/Cargo.lock
generated
|
|
@ -3737,6 +3737,7 @@ dependencies = [
|
|||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-socks",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
|
|
@ -5291,6 +5292,18 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-socks"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
|
||||
dependencies = [
|
||||
"either",
|
||||
"futures-util",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ tauri-plugin-opener = "2"
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-gamepad = "0.0.5"
|
||||
reqwest = { version = "0.11", default-features = false, features = ["stream", "json", "rustls-tls-webpki-roots"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["stream", "json", "rustls-tls-webpki-roots", "socks"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
|
|
|
|||
BIN
src-tauri/resources/SDL2.dll
Normal file
|
|
@ -5,6 +5,11 @@ use crate::types::{AppConfig, ThemePalette};
|
|||
use crate::util;
|
||||
#[tauri::command]
|
||||
pub fn save_config(app: AppHandle, config_val: AppConfig) {
|
||||
let mut config_val = config_val;
|
||||
if config_val.goldmapper_mappings.is_none() {
|
||||
let existing = config::load_config_raw(app.clone());
|
||||
config_val.goldmapper_mappings = existing.goldmapper_mappings;
|
||||
}
|
||||
config::save_config_raw(&app, &config_val);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,14 +45,16 @@ fn get_raw_url(host: &str, owner: &str, repo: &str, branch: &str, path: &str, is
|
|||
|
||||
#[tauri::command]
|
||||
pub async fn list_git_directory(
|
||||
app: tauri::AppHandle,
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
path: String,
|
||||
) -> Result<Vec<GitEntry>, String> {
|
||||
list_git_directory_inner(repo_url, branch, path).await
|
||||
list_git_directory_inner(app, repo_url, branch, path).await
|
||||
}
|
||||
|
||||
fn list_git_directory_inner(
|
||||
app: tauri::AppHandle,
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
path: String,
|
||||
|
|
@ -60,9 +62,8 @@ fn list_git_directory_inner(
|
|||
Box::pin(async move {
|
||||
let (owner, repo, host, is_github) = parse_git_url(&repo_url)?;
|
||||
let api_url = get_api_url(&host, &owner, &repo, &path, &branch, is_github);
|
||||
let client = reqwest::Client::new();
|
||||
let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?;
|
||||
let response = client.get(&api_url)
|
||||
.header("User-Agent", "Emerald-Launcher")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch directory listing: {}", e))?;
|
||||
|
|
@ -82,7 +83,7 @@ fn list_git_directory_inner(
|
|||
let is_dir = json.get("type").and_then(|v| v.as_str()) == Some("dir");
|
||||
if is_dir {
|
||||
let sub_path = json.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
return list_git_directory_inner(repo_url, branch, sub_path).await;
|
||||
return list_git_directory_inner(app, repo_url, branch, sub_path).await;
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
|
@ -107,6 +108,7 @@ fn list_git_directory_inner(
|
|||
}
|
||||
|
||||
async fn collect_files(
|
||||
app: &tauri::AppHandle,
|
||||
host: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
|
|
@ -116,11 +118,10 @@ async fn collect_files(
|
|||
) -> Result<Vec<String>, String> {
|
||||
let mut files = Vec::new();
|
||||
let mut dirs_to_list = vec![root_path.to_string()];
|
||||
let client = reqwest::Client::new();
|
||||
let client = util::build_http_client_from_app(app).map_err(|e| e.to_string())?;
|
||||
while let Some(dir) = dirs_to_list.pop() {
|
||||
let api_url = get_api_url(host, owner, repo, &dir, branch, is_github);
|
||||
let response = client.get(&api_url)
|
||||
.header("User-Agent", "Emerald-Launcher")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list {}: {}", dir, e))?;
|
||||
|
|
@ -174,18 +175,17 @@ pub async fn download_dlc_files(
|
|||
let instance_dir = util::get_instance_working_dir(&app, &instance_id);
|
||||
let dlc_dest = instance_dir.join("Windows64Media").join("DLC").join(&dlc_folder);
|
||||
let (owner, repo, host, is_github) = parse_git_url(&repo_url)?;
|
||||
let files_to_download = collect_files(&host, &owner, &repo, &branch, &dlc_folder, is_github).await?;
|
||||
let files_to_download = collect_files(&app, &host, &owner, &repo, &branch, &dlc_folder, is_github).await?;
|
||||
if files_to_download.is_empty() {
|
||||
return Err(format!("No files found in '{}' folder", dlc_folder));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dlc_dest).map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::new();
|
||||
let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?;
|
||||
let total = files_to_download.len();
|
||||
for (i, file_path) in files_to_download.iter().enumerate() {
|
||||
let raw_url = get_raw_url(&host, &owner, &repo, &branch, file_path, is_github);
|
||||
let response = client.get(&raw_url)
|
||||
.header("User-Agent", "Emerald-Launcher")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download {}: {}", file_path, e))?;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ async fn stream_download(
|
|||
lock.insert(instance_id.to_string(), token);
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new().get(url).header(reqwest::header::USER_AGENT, "Emerald-Launcher").send().await.map_err(|e| e.to_string())?;
|
||||
let client = util::build_http_client_from_app(app).map_err(|e| e.to_string())?;
|
||||
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
{ state.tokens.lock().await.remove(instance_id); }
|
||||
return Err(format!("Download failed: {}", response.status()));
|
||||
|
|
@ -300,9 +301,9 @@ pub async fn check_game_update(
|
|||
return Ok(true);
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?;
|
||||
let response = client
|
||||
.head(&url)
|
||||
.header(reqwest::header::USER_AGENT, "Emerald-Launcher")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use tauri_plugin_opener::OpenerExt;
|
|||
use tokio::io::AsyncRead;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::Mutex;
|
||||
use crate::commands::goldmapper;
|
||||
use crate::commands::runners;
|
||||
use crate::config;
|
||||
use crate::types::AppConfig;
|
||||
|
|
@ -139,8 +140,14 @@ async fn launch_game_desktop(
|
|||
all_args.push(a.to_string());
|
||||
}
|
||||
all_args.extend(args.clone());
|
||||
all_args.push(game_exe.to_string_lossy().to_string());
|
||||
all_args.extend(extra_args.clone());
|
||||
let goldmapper_args =
|
||||
goldmapper::build_launch_args(&app, &config_val, &game_exe, &extra_args)?;
|
||||
if let Some(gm_args) = goldmapper_args {
|
||||
all_args.extend(gm_args);
|
||||
} else {
|
||||
all_args.push(game_exe.to_string_lossy().to_string());
|
||||
all_args.extend(extra_args.clone());
|
||||
}
|
||||
let (final_prog, final_args) = apply_launch_prefix(prog, all_args, &config_val);
|
||||
let mut cmd = tokio::process::Command::new(&final_prog);
|
||||
for a in &final_args {
|
||||
|
|
@ -228,7 +235,13 @@ async fn launch_game_desktop(
|
|||
])
|
||||
};
|
||||
|
||||
mac_args.extend(extra_args.clone());
|
||||
let goldmapper_args =
|
||||
goldmapper::build_launch_args(&app, &config_val, &game_exe, &extra_args)?;
|
||||
if let Some(gm_args) = goldmapper_args {
|
||||
mac_args.extend(gm_args);
|
||||
} else {
|
||||
mac_args.extend(extra_args.clone());
|
||||
}
|
||||
|
||||
let (final_prog, final_args) = apply_launch_prefix(&mac_prog, mac_args, &config_val);
|
||||
let mut cmd = tokio::process::Command::new(&final_prog);
|
||||
|
|
@ -289,13 +302,22 @@ async fn launch_game_desktop(
|
|||
not(target_os = "android")
|
||||
))]
|
||||
{
|
||||
let exe_str = game_exe.to_string_lossy().to_string();
|
||||
let all_args: Vec<String> = extra_args.clone();
|
||||
let (final_prog, final_args) = apply_launch_prefix(&exe_str, all_args, &config_val);
|
||||
let goldmapper_args =
|
||||
goldmapper::build_launch_args(&app, &config_val, &game_exe, &extra_args)?;
|
||||
let (prog_str, all_args): (String, Vec<String>) = match goldmapper_args {
|
||||
Some(gm_args) => (
|
||||
gm_args[0].clone(),
|
||||
gm_args[1..].to_vec(),
|
||||
),
|
||||
None => (game_exe.to_string_lossy().to_string(), extra_args.clone()),
|
||||
};
|
||||
let (final_prog, final_args) = apply_launch_prefix(&prog_str, all_args, &config_val);
|
||||
let mut cmd = tokio::process::Command::new(&final_prog);
|
||||
for a in &final_args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
#[cfg(unix)]
|
||||
cmd.process_group(0);
|
||||
apply_launch_env_vars(&mut cmd, &config_val);
|
||||
|
|
|
|||
212
src-tauri/src/commands/goldmapper.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use crate::config;
|
||||
use crate::types::{AppConfig, GoldMapperMapping};
|
||||
use crate::util;
|
||||
|
||||
const CONTROLLER_TARGETS: [&str; 14] = [
|
||||
"PAD_A",
|
||||
"PAD_B",
|
||||
"PAD_X",
|
||||
"PAD_Y",
|
||||
"PAD_LB",
|
||||
"PAD_RB",
|
||||
"PAD_BACK",
|
||||
"PAD_START",
|
||||
"PAD_LTHUMB",
|
||||
"PAD_RTHUMB",
|
||||
"PAD_DPAD_UP",
|
||||
"PAD_DPAD_DOWN",
|
||||
"PAD_DPAD_LEFT",
|
||||
"PAD_DPAD_RIGHT",
|
||||
];
|
||||
|
||||
fn push_key(mappings: &mut Vec<GoldMapperMapping>, name: String) {
|
||||
mappings.push(GoldMapperMapping {
|
||||
to: name.clone(),
|
||||
from: name,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn default_mappings() -> Vec<GoldMapperMapping> {
|
||||
let mut mappings = Vec::new();
|
||||
for b in b'A'..=b'Z' {
|
||||
push_key(&mut mappings, format!("KEY_{}", b as char));
|
||||
}
|
||||
for n in 0..=9 {
|
||||
push_key(&mut mappings, format!("KEY_{n}"));
|
||||
}
|
||||
for f in 1..=12 {
|
||||
push_key(&mut mappings, format!("KEY_F{f}"));
|
||||
}
|
||||
for k in [
|
||||
"KEY_SPACE",
|
||||
"KEY_RETURN",
|
||||
"KEY_ESCAPE",
|
||||
"KEY_TAB",
|
||||
"KEY_BACKSPACE",
|
||||
"KEY_DELETE",
|
||||
"KEY_INSERT",
|
||||
"KEY_HOME",
|
||||
"KEY_END",
|
||||
"KEY_PAGEUP",
|
||||
"KEY_PAGEDOWN",
|
||||
"KEY_UP",
|
||||
"KEY_DOWN",
|
||||
"KEY_LEFT",
|
||||
"KEY_RIGHT",
|
||||
"KEY_PRINTSCREEN",
|
||||
"KEY_PAUSE",
|
||||
"KEY_CAPSLOCK",
|
||||
"KEY_NUMLOCK",
|
||||
"KEY_SCROLLLOCK",
|
||||
"KEY_LSHIFT",
|
||||
"KEY_RSHIFT",
|
||||
"KEY_LCTRL",
|
||||
"KEY_RCTRL",
|
||||
"KEY_LALT",
|
||||
"KEY_RALT",
|
||||
"KEY_LWIN",
|
||||
"KEY_RWIN",
|
||||
"KEY_APPS",
|
||||
] {
|
||||
push_key(&mut mappings, k.to_string());
|
||||
}
|
||||
for n in 0..=9 {
|
||||
push_key(&mut mappings, format!("KEY_NUMPAD{n}"));
|
||||
}
|
||||
for k in [
|
||||
"KEY_MULTIPLY",
|
||||
"KEY_ADD",
|
||||
"KEY_SUBTRACT",
|
||||
"KEY_DECIMAL",
|
||||
"KEY_DIVIDE",
|
||||
"KEY_SEMICOLON",
|
||||
"KEY_EQUALS",
|
||||
"KEY_COMMA",
|
||||
"KEY_MINUS",
|
||||
"KEY_PERIOD",
|
||||
"KEY_SLASH",
|
||||
"KEY_GRAVE",
|
||||
"KEY_LBRACKET",
|
||||
"KEY_BACKSLASH",
|
||||
"KEY_RBRACKET",
|
||||
"KEY_APOSTROPHE",
|
||||
] {
|
||||
push_key(&mut mappings, k.to_string());
|
||||
}
|
||||
for m in ["MOUSE_LEFT", "MOUSE_RIGHT", "MOUSE_MIDDLE"] {
|
||||
push_key(&mut mappings, m.to_string());
|
||||
}
|
||||
for (i, target) in CONTROLLER_TARGETS.iter().enumerate() {
|
||||
mappings.push(GoldMapperMapping {
|
||||
from: format!("DINPUT_{i}"),
|
||||
to: (*target).to_string(),
|
||||
});
|
||||
}
|
||||
mappings
|
||||
}
|
||||
|
||||
pub fn resolve_resource(app: &AppHandle, name: &str) -> Option<PathBuf> {
|
||||
use tauri::path::BaseDirectory;
|
||||
if let Ok(p) = app.path().resolve(format!("resources/{name}"), BaseDirectory::Resource) {
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
if let Ok(current) = std::env::current_dir() {
|
||||
let p = current.join("src-tauri").join("resources").join(name);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
let p = current.join("resources").join(name);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn write_runtime_config(app: &AppHandle, config_val: &AppConfig) -> Result<PathBuf, String> {
|
||||
let dir = util::get_app_dir(app).join("GoldMapper");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join("config.json");
|
||||
let mappings = config_val
|
||||
.goldmapper_mappings
|
||||
.clone()
|
||||
.unwrap_or_else(default_mappings);
|
||||
let json = serde_json::to_string(&serde_json::json!({ "mappings": mappings }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
fs::write(&path, json).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn build_launch_args(
|
||||
app: &AppHandle,
|
||||
config_val: &AppConfig,
|
||||
game_exe: &PathBuf,
|
||||
extra_args: &[String],
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
if !config_val.goldmapper_enabled.unwrap_or(true) {
|
||||
return Ok(None);
|
||||
}
|
||||
let launcher =
|
||||
resolve_resource(app, "GoldMapperLauncher.exe").ok_or_else(|| "GoldMapperLauncher.exe not found in resources".to_string())?;
|
||||
let config_path = write_runtime_config(app, config_val)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut args = vec![
|
||||
launcher.to_string_lossy().to_string(),
|
||||
config_path.to_string_lossy().to_string(),
|
||||
game_exe.to_string_lossy().to_string(),
|
||||
];
|
||||
args.extend(extra_args.iter().cloned());
|
||||
Ok(Some(args))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut args = vec![
|
||||
util::unix_path_to_wine_z_path(&launcher),
|
||||
util::unix_path_to_wine_z_path(&config_path),
|
||||
util::unix_path_to_wine_z_path(game_exe),
|
||||
];
|
||||
args.extend(extra_args.iter().cloned());
|
||||
Ok(Some(args))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn goldmapper_get_defaults() -> Vec<GoldMapperMapping> {
|
||||
default_mappings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn goldmapper_load_config(app: AppHandle) -> Vec<GoldMapperMapping> {
|
||||
config::load_config_raw(app.clone())
|
||||
.goldmapper_mappings
|
||||
.unwrap_or_else(default_mappings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn goldmapper_save_config(
|
||||
app: AppHandle,
|
||||
mappings: Vec<GoldMapperMapping>,
|
||||
) -> Result<(), String> {
|
||||
let mut config_val = config::load_config_raw(app.clone());
|
||||
config_val.goldmapper_mappings = Some(mappings);
|
||||
println!(
|
||||
"[GoldMapper] config saved: {}",
|
||||
serde_json::json!({ "mappings": config_val.goldmapper_mappings })
|
||||
);
|
||||
config::save_config_raw(&app, &config_val);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn goldmapper_reset_config(app: AppHandle) -> Vec<GoldMapperMapping> {
|
||||
let mut config_val = config::load_config_raw(app.clone());
|
||||
config_val.goldmapper_mappings = None;
|
||||
config::save_config_raw(&app, &config_val);
|
||||
default_mappings()
|
||||
}
|
||||
|
|
@ -9,14 +9,15 @@ pub struct LegacyBlockState {
|
|||
pub fn map_modern_block_state(compound: &NbtCompound) -> LegacyBlockState {
|
||||
map_modern_block_state_inner(compound, None)
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn map_modern_block_state_with_context(
|
||||
compound: &NbtCompound,
|
||||
unknown_blocks: Option<&mut Vec<String>>,
|
||||
) -> LegacyBlockState {
|
||||
map_modern_block_state_inner(compound, unknown_blocks)
|
||||
}
|
||||
|
||||
*/
|
||||
fn map_modern_block_state_inner(
|
||||
compound: &NbtCompound,
|
||||
mut unknown_blocks: Option<&mut Vec<String>>,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
use super::block_mapping::{self, LegacyBlockState};
|
||||
use super::block_mapping::{self};
|
||||
use super::nbt;
|
||||
use super::payload;
|
||||
const CHUNK_BLOCKS: usize = 32768;
|
||||
|
|
@ -19,10 +19,10 @@ pub enum JavaChunkFormat {
|
|||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct JavaChunkFormatInfo {
|
||||
pub format: JavaChunkFormat,
|
||||
pub has_level_wrapper: bool,
|
||||
pub data_version: i32,
|
||||
pub min_section_y: Option<i32>,
|
||||
pub max_section_y: Option<i32>,
|
||||
pub _has_level_wrapper: bool,
|
||||
pub _data_version: i32,
|
||||
pub _min_section_y: Option<i32>,
|
||||
pub _max_section_y: Option<i32>,
|
||||
pub has_modern_entity_tags: bool,
|
||||
}
|
||||
|
||||
|
|
@ -110,10 +110,10 @@ pub fn inspect_chunk(root_tag: &nbt::NbtCompound) -> JavaChunkFormatInfo {
|
|||
|
||||
JavaChunkFormatInfo {
|
||||
format,
|
||||
has_level_wrapper,
|
||||
data_version,
|
||||
min_section_y,
|
||||
max_section_y,
|
||||
_has_level_wrapper: has_level_wrapper,
|
||||
_data_version: data_version,
|
||||
_min_section_y: min_section_y,
|
||||
_max_section_y: max_section_y,
|
||||
has_modern_entity_tags,
|
||||
}
|
||||
}
|
||||
|
|
@ -406,7 +406,8 @@ pub fn convert_chunk(
|
|||
build_legacy_chunk_level(root_tag, new_chunk_x, new_chunk_z, preserve_dynamic, global_section_shift);
|
||||
payload::encode_legacy_nbt(&level)
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn convert_chunk_for_save(
|
||||
root_tag: &nbt::NbtCompound,
|
||||
new_chunk_x: i32,
|
||||
|
|
@ -416,7 +417,7 @@ pub fn convert_chunk_for_save(
|
|||
) -> Vec<u8> {
|
||||
let level = build_legacy_chunk_level(root_tag, new_chunk_x, new_chunk_z, preserve_dynamic, global_section_shift);
|
||||
payload::encode_compressed_storage(&level)
|
||||
}
|
||||
}*/
|
||||
|
||||
fn build_legacy_chunk_level(
|
||||
root_tag: &nbt::NbtCompound,
|
||||
|
|
@ -430,7 +431,7 @@ fn build_legacy_chunk_level(
|
|||
.compound("Level")
|
||||
.unwrap_or(root_tag);
|
||||
let is_modern = format_info.uses_modern_content_schema();
|
||||
let (mut blocks, mut data, mut sky_light, mut block_light) =
|
||||
let (mut blocks, mut data, mut sky_light, block_light) =
|
||||
if format_info.is_section_based() {
|
||||
flatten_anvil_sections(source_level, &format_info, global_section_shift)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -59,14 +59,15 @@ impl NbtCompound {
|
|||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn compound_mut(&mut self, name: &str) -> Option<&mut NbtCompound> {
|
||||
self.get_mut(name).and_then(|v| match v {
|
||||
NbtValue::Compound(c) => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
*/
|
||||
pub fn byte(&self, name: &str) -> Option<i8> {
|
||||
self.get(name).and_then(|v| match v {
|
||||
NbtValue::Byte(b) => Some(*b),
|
||||
|
|
@ -94,7 +95,8 @@ impl NbtCompound {
|
|||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn float(&self, name: &str) -> Option<f32> {
|
||||
self.get(name).and_then(|v| match v {
|
||||
NbtValue::Float(f) => Some(*f),
|
||||
|
|
@ -108,6 +110,7 @@ impl NbtCompound {
|
|||
_ => None,
|
||||
})
|
||||
}
|
||||
*/
|
||||
|
||||
pub fn string(&self, name: &str) -> Option<&str> {
|
||||
self.get(name).and_then(|v| match v {
|
||||
|
|
@ -143,7 +146,8 @@ impl NbtCompound {
|
|||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn list_compounds(&self, name: &str) -> Vec<&NbtCompound> {
|
||||
self.list(name)
|
||||
.unwrap_or(&[])
|
||||
|
|
@ -153,7 +157,7 @@ impl NbtCompound {
|
|||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
fn tag_type_id(v: &NbtValue) -> u8 {
|
||||
|
|
@ -405,6 +409,8 @@ pub fn write_gzip_nbt(compound: &NbtCompound) -> Vec<u8> {
|
|||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn read_zlib_nbt(data: &[u8]) -> Result<NbtCompound, String> {
|
||||
use flate2::read::ZlibDecoder;
|
||||
let mut decoder = ZlibDecoder::new(data);
|
||||
|
|
@ -461,7 +467,7 @@ pub fn get_long_array_or(compound: &NbtCompound, name: &str, default_len: usize)
|
|||
})
|
||||
.unwrap_or_else(|| vec![0i64; default_len])
|
||||
}
|
||||
|
||||
*/
|
||||
pub fn get_nibble(data: &[u8], index: usize) -> u8 {
|
||||
let byte_index = index >> 1;
|
||||
if byte_index >= data.len() {
|
||||
|
|
@ -487,10 +493,12 @@ pub fn set_nibble(data: &mut [u8], index: usize, value: u8) {
|
|||
data[byte_index] = (data[byte_index] & 0x0F) | (val << 4);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn clone_or_empty_list(compound: &NbtCompound, name: &str) -> Vec<NbtValue> {
|
||||
compound
|
||||
.list(name)
|
||||
.map(|l| l.to_vec())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ fn read_be_i64(data: &[u8], off: usize) -> i64 {
|
|||
data[off + 6], data[off + 7],
|
||||
])
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
fn write_be_i16(out: &mut Vec<u8>, v: i16) {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
|
|
@ -39,14 +40,15 @@ fn write_be_i32(out: &mut Vec<u8>, v: i32) {
|
|||
|
||||
fn write_be_i64(out: &mut Vec<u8>, v: i64) {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}*/
|
||||
|
||||
fn get_compressed_tile_index(block: usize, tile: usize) -> usize {
|
||||
let mut index = ((block & 0x180) << 6) | ((block & 0x060) << 4) | ((block & 0x01F) << 2);
|
||||
index |= ((tile & 0x30) << 7) | ((tile & 0x0C) << 5) | (tile & 0x03);
|
||||
index
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
fn get_nibble_value(nibble_data: &[u8], xz: usize, y: usize) -> u8 {
|
||||
let pos = (xz << 7) | y;
|
||||
let slot = pos >> 1;
|
||||
|
|
@ -60,7 +62,7 @@ fn get_nibble_value(nibble_data: &[u8], xz: usize, y: usize) -> u8 {
|
|||
} else {
|
||||
(b >> 4) & 0x0F
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
fn set_nibble_value(nibble_data: &mut [u8], xz: usize, y: usize, value: u8) {
|
||||
let pos = (xz << 7) | y;
|
||||
|
|
@ -77,6 +79,8 @@ fn set_nibble_value(nibble_data: &mut [u8], xz: usize, y: usize, value: u8) {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
fn ensure_length(input: &[u8], expected_len: usize) -> Vec<u8> {
|
||||
if input.len() == expected_len {
|
||||
return input.to_vec();
|
||||
|
|
@ -115,7 +119,7 @@ fn extract_upper_block_section(full_blocks: &[u8]) -> Vec<u8> {
|
|||
upper[dst_start..dst_start + COMPRESSED_CHUNK_SECTION_HEIGHT].copy_from_slice(&full_blocks[src_start..src_start + COMPRESSED_CHUNK_SECTION_HEIGHT]);
|
||||
}
|
||||
upper
|
||||
}
|
||||
}*/
|
||||
|
||||
fn extract_lower_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
|
||||
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
|
||||
|
|
@ -135,7 +139,8 @@ fn extract_lower_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
|
|||
}
|
||||
lower
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
fn extract_upper_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
|
||||
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
|
||||
if full_nibbles.len() < FULL_CHUNK_NIBBLES {
|
||||
|
|
@ -149,7 +154,7 @@ fn extract_upper_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
|
|||
.copy_from_slice(&full_nibbles[src_start..src_start + nibble_height]);
|
||||
}
|
||||
upper
|
||||
}
|
||||
}*/
|
||||
|
||||
fn combine_block_sections(lower: &[u8], upper: &[u8]) -> Vec<u8> {
|
||||
if lower.len() == FULL_CHUNK_BLOCKS {
|
||||
|
|
@ -205,6 +210,8 @@ pub fn encode_legacy_nbt(level: &nbt::NbtCompound) -> Vec<u8> {
|
|||
nbt::write_nbt(&root)
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
fn write_compressed_tile_storage(out: &mut Vec<u8>, blocks: &[u8]) {
|
||||
let normalized = ensure_length(blocks, BLOCKS_PER_SECTION);
|
||||
let mut blob = vec![0u8; 1024 + BLOCKS_PER_SECTION];
|
||||
|
|
@ -321,7 +328,7 @@ pub fn encode_compressed_storage(level: &nbt::NbtCompound) -> Vec<u8> {
|
|||
let dynamic_bytes = nbt::write_nbt(&dynamic_root);
|
||||
out.extend_from_slice(&dynamic_bytes);
|
||||
out
|
||||
}
|
||||
}*/
|
||||
|
||||
fn is_compressed_chunk_storage(data: &[u8]) -> bool {
|
||||
if data.len() < 2 + 4 + 4 + 8 {
|
||||
|
|
@ -330,7 +337,8 @@ fn is_compressed_chunk_storage(data: &[u8]) -> bool {
|
|||
let version = read_be_i16(data, 0);
|
||||
version == SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE || version == SAVE_FILE_VERSION_CHUNK_INHABITED_TIME
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn try_read_chunk_coordinates(
|
||||
data: &[u8],
|
||||
) -> Option<(i32, i32, bool)> {
|
||||
|
|
@ -382,7 +390,7 @@ pub fn force_chunk_coordinates(data: &[u8], expected_x: i32, expected_z: i32) ->
|
|||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
*/
|
||||
fn read_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<Vec<u8>, String> {
|
||||
let allocated_size = read_be_i32(data, *offset) as usize;
|
||||
*offset += 4;
|
||||
|
|
@ -445,7 +453,8 @@ fn read_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<Vec<u
|
|||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
fn skip_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<(), String> {
|
||||
let allocated_size = read_be_i32(data, *offset) as usize;
|
||||
*offset += 4;
|
||||
|
|
@ -455,6 +464,7 @@ fn skip_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<(), S
|
|||
*offset += allocated_size;
|
||||
Ok(())
|
||||
}
|
||||
*/
|
||||
|
||||
fn read_sparse_nibble_storage(data: &[u8], offset: &mut usize, supports_all_fifteen: bool) -> Result<Vec<u8>, String> {
|
||||
let count = read_be_i32(data, *offset) as usize;
|
||||
|
|
@ -496,7 +506,8 @@ fn read_sparse_nibble_storage(data: &[u8], offset: &mut usize, supports_all_fift
|
|||
|
||||
Ok(nibble_data)
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
fn skip_sparse_nibble_storage(data: &[u8], offset: &mut usize) -> Result<(), String> {
|
||||
let count = read_be_i32(data, *offset) as usize;
|
||||
*offset += 4;
|
||||
|
|
@ -507,7 +518,7 @@ fn skip_sparse_nibble_storage(data: &[u8], offset: &mut usize) -> Result<(), Str
|
|||
*offset += storage_bytes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
*/
|
||||
fn read_sized_bytes(data: &[u8], offset: &mut usize, length: usize) -> Vec<u8> {
|
||||
let end = (*offset + length).min(data.len());
|
||||
let result = data[*offset..end].to_vec();
|
||||
|
|
@ -636,7 +647,8 @@ fn decode_compressed_chunk_to_legacy_root(data: &[u8]) -> Result<nbt::NbtCompoun
|
|||
root.insert("Level", nbt::NbtValue::Compound(level));
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn try_get_compressed_chunk_nbt_offset(data: &[u8]) -> Option<usize> {
|
||||
if !is_compressed_chunk_storage(data) {
|
||||
return None;
|
||||
|
|
@ -681,3 +693,4 @@ pub fn try_get_compressed_chunk_nbt_offset(data: &[u8]) -> Option<usize> {
|
|||
|
||||
Some(offset)
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ impl JavaWorldReader {
|
|||
|
||||
result
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn is_anvil_world(&self) -> bool {
|
||||
let region_dir = self.get_region_dir("");
|
||||
let dir_path = Path::new(®ion_dir);
|
||||
|
|
@ -89,7 +90,7 @@ impl JavaWorldReader {
|
|||
}
|
||||
false
|
||||
}
|
||||
|
||||
*/
|
||||
fn get_or_create_reader(&mut self, region_path: &str) -> Result<&JavaRegionReader, String> {
|
||||
if !self.region_readers.contains_key(region_path) {
|
||||
let reader = JavaRegionReader::open(region_path)?;
|
||||
|
|
@ -97,7 +98,8 @@ impl JavaWorldReader {
|
|||
}
|
||||
Ok(self.region_readers.get(region_path).unwrap())
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn has_chunk(&mut self, region_path: &str, local_x: i32, local_z: i32) -> bool {
|
||||
let reader = match self.get_or_create_reader(region_path) {
|
||||
Ok(r) => r,
|
||||
|
|
@ -106,7 +108,7 @@ impl JavaWorldReader {
|
|||
let index = (local_x & 31) + (local_z & 31) * 32;
|
||||
reader.offsets[index as usize] != 0
|
||||
}
|
||||
|
||||
*/
|
||||
pub fn read_chunk_nbt(
|
||||
&mut self,
|
||||
region_path: &str,
|
||||
|
|
@ -721,7 +723,8 @@ pub fn decompress_zlib(data: &[u8]) -> Result<Vec<u8>, String> {
|
|||
decoder.read_to_end(&mut buf).map_err(|e| format!("zlib decompress failed: {}", e))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/*
|
||||
//neo: unused
|
||||
pub fn decompress_rle_zlib(data: &[u8], decompressed_size: usize) -> Result<Vec<u8>, String> {
|
||||
let rle_data = decompress_zlib(data)?;
|
||||
Ok(rle_decode(&rle_data, decompressed_size))
|
||||
|
|
@ -800,3 +803,4 @@ pub fn rle_decode(data: &[u8], expected_size: usize) -> Vec<u8> {
|
|||
|
||||
output
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ use serde::Deserialize;
|
|||
#[cfg(target_os = "macos")]
|
||||
use crate::platform::macos;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::util;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::fs;
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -71,7 +73,7 @@ pub async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Resul
|
|||
None,
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?;
|
||||
let release_text = client
|
||||
.get(format!("https://api.github.com/repos/{}/releases/latest", repo))
|
||||
.header("User-Agent", "Emerald-Legacy-Launcher")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub mod dlc;
|
|||
pub mod download;
|
||||
pub mod file_dialogs;
|
||||
pub mod game;
|
||||
pub mod goldmapper;
|
||||
pub mod java2lce;
|
||||
pub mod macos_setup;
|
||||
pub mod platform;
|
||||
|
|
|
|||
|
|
@ -7,24 +7,26 @@ pub struct PlatformInfo {
|
|||
pub is_mac: bool,
|
||||
pub is_windows: bool,
|
||||
pub is_android: bool,
|
||||
pub arch: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_platform() -> PlatformInfo {
|
||||
let arch = std::env::consts::ARCH.to_string();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
PlatformInfo { is_linux: true, is_mac: false, is_windows: false, is_android: false }
|
||||
PlatformInfo { is_linux: true, is_mac: false, is_windows: false, is_android: false, arch }
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
PlatformInfo { is_linux: false, is_mac: true, is_windows: false, is_android: false }
|
||||
PlatformInfo { is_linux: false, is_mac: true, is_windows: false, is_android: false, arch }
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
PlatformInfo { is_linux: false, is_mac: false, is_windows: true, is_android: false }
|
||||
PlatformInfo { is_linux: false, is_mac: false, is_windows: true, is_android: false, arch }
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
PlatformInfo { is_linux: false, is_mac: false, is_windows: false, is_android: true }
|
||||
PlatformInfo { is_linux: false, is_mac: false, is_windows: false, is_android: true, arch }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
use crate::types::HttpResponse;
|
||||
use crate::util;
|
||||
#[tauri::command]
|
||||
pub async fn http_proxy_request(
|
||||
app: tauri::AppHandle,
|
||||
method: String,
|
||||
url: String,
|
||||
body: Option<String>,
|
||||
headers: std::collections::HashMap<String, String>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let client = util::build_http_client_from_app(&app)?;
|
||||
let mut req = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ use crate::types::ScreenshotInfo;
|
|||
use crate::config;
|
||||
use crate::util;
|
||||
#[tauri::command]
|
||||
pub async fn fetch_skin(username: String) -> Result<(String, String), String> {
|
||||
let client = reqwest::Client::new();
|
||||
pub async fn fetch_skin(app: AppHandle, username: String) -> Result<(String, String), String> {
|
||||
let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?;
|
||||
let mojang_url = format!("https://api.mojang.com/users/profiles/minecraft/{}", username);
|
||||
let mojang_res = client.get(&mojang_url).send().await.map_err(|e| format!("Failed request to mojang: {}", e))?;
|
||||
if !mojang_res.status().is_success() {
|
||||
|
|
@ -25,12 +25,23 @@ pub async fn fetch_skin(username: String) -> Result<(String, String), String> {
|
|||
}
|
||||
let mc_api_text = mc_api_res.text().await.map_err(|e| format!("Failed to read mc api text: {}", e))?;
|
||||
let mc_api_data: serde_json::Value = serde_json::from_str(&mc_api_text).map_err(|e| format!("Invalid MC API JSON: {}", e))?;
|
||||
let image_b64 = mc_api_data.get("skin")
|
||||
if let Some(b64) = mc_api_data.get("skin")
|
||||
.and_then(|s| s.get("image"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok((b64.to_string(), name_exact));
|
||||
}
|
||||
let skin_url = mc_api_data.get("skin")
|
||||
.and_then(|s| s.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "No skin found".to_string())?;
|
||||
|
||||
Ok((image_b64.to_string(), name_exact))
|
||||
let skin_resp = client.get(skin_url).send().await.map_err(|e| format!("Failed to fetch skin image: {}", e))?;
|
||||
if !skin_resp.status().is_success() {
|
||||
return Err(format!("Failed to download skin image: {}", skin_resp.status()));
|
||||
}
|
||||
let skin_bytes = skin_resp.bytes().await.map_err(|e| format!("Failed to read skin image bytes: {}", e))?;
|
||||
let image_b64 = base64::engine::general_purpose::STANDARD.encode(&skin_bytes);
|
||||
Ok((image_b64, name_exact))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -47,7 +58,8 @@ pub async fn download_logo(app: AppHandle, id: String, url: String) -> Result<St
|
|||
|
||||
let filename = format!("{}.{}", id, file_ext);
|
||||
let dest_path = logos_dir.join(&filename);
|
||||
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
|
||||
let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?;
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to download logo: {}", response.status()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ async fn download_and_assemble(
|
|||
let mut downloaded: Vec<PathBuf> = Vec::new();
|
||||
for part in parts {
|
||||
let url = format!("{}/{}", raw_base, part);
|
||||
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
|
||||
let client = util::build_http_client_from_app(app).map_err(|e| e.to_string())?;
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to download {}: HTTP {}", part, response.status()));
|
||||
}
|
||||
|
|
@ -101,58 +102,21 @@ async fn download_and_assemble(
|
|||
Ok(combined)
|
||||
}
|
||||
|
||||
fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec<String>, bool), String> {
|
||||
fn list_archive(zip_tmp: &Path) -> Result<Vec<String>, String> {
|
||||
let zip_str = zip_tmp.to_str().unwrap_or("");
|
||||
if cfg!(target_os = "linux") {
|
||||
let mut extract_ok = false;
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
if let Ok(out) = std::process::Command::new("bsdtar")
|
||||
.args(["-tf", zip_str])
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
let listing = String::from_utf8_lossy(&out.stdout);
|
||||
files = listing.lines()
|
||||
.map(|l| l.trim())
|
||||
return Ok(listing.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.ends_with('/'))
|
||||
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
|
||||
.collect();
|
||||
let st = std::process::Command::new("bsdtar")
|
||||
.args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
extract_ok = st.success();
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
if !extract_ok {
|
||||
let unzip_list = std::process::Command::new("unzip")
|
||||
.args(["-l", zip_str])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !unzip_list.status.success() {
|
||||
return Err(format!("Failed to list contents of {}", zip_str));
|
||||
}
|
||||
let listing = String::from_utf8_lossy(&unzip_list.stdout);
|
||||
files = listing.lines()
|
||||
.filter_map(|l| {
|
||||
let mut parts = l.trim().split_whitespace();
|
||||
let size_str = parts.next()?;
|
||||
size_str.parse::<u64>().ok()?;
|
||||
parts.next()?;
|
||||
parts.next()?;
|
||||
Some(parts.collect::<Vec<&str>>().join(" "))
|
||||
})
|
||||
.filter(|l| !l.ends_with('/') && !l.contains('*'))
|
||||
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
|
||||
.collect();
|
||||
let st = std::process::Command::new("unzip")
|
||||
.args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
extract_ok = st.success();
|
||||
}
|
||||
Ok((files, extract_ok))
|
||||
} else if cfg!(target_os = "android") {
|
||||
let unzip_list = std::process::Command::new("unzip")
|
||||
.args(["-l", zip_str])
|
||||
.output()
|
||||
|
|
@ -161,7 +125,7 @@ fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec<String>, bool
|
|||
return Err(format!("Failed to list contents of {}", zip_str));
|
||||
}
|
||||
let listing = String::from_utf8_lossy(&unzip_list.stdout);
|
||||
let files: Vec<String> = listing.lines()
|
||||
Ok(listing.lines()
|
||||
.filter_map(|l| {
|
||||
let mut parts = l.trim().split_whitespace();
|
||||
let size_str = parts.next()?;
|
||||
|
|
@ -171,29 +135,76 @@ fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec<String>, bool
|
|||
Some(parts.collect::<Vec<&str>>().join(" "))
|
||||
})
|
||||
.filter(|l| !l.ends_with('/') && !l.contains('*'))
|
||||
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
|
||||
.collect();
|
||||
let st = std::process::Command::new("unzip")
|
||||
.args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((files, st.success()))
|
||||
} else {
|
||||
let st = std::process::Command::new("tar")
|
||||
.args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()])
|
||||
.collect())
|
||||
} else if cfg!(target_os = "android") {
|
||||
let unzip_list = std::process::Command::new("unzip")
|
||||
.args(["-l", zip_str])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !unzip_list.status.success() {
|
||||
return Err(format!("Failed to list contents of {}", zip_str));
|
||||
}
|
||||
let listing = String::from_utf8_lossy(&unzip_list.stdout);
|
||||
Ok(listing.lines()
|
||||
.filter_map(|l| {
|
||||
let mut parts = l.trim().split_whitespace();
|
||||
let size_str = parts.next()?;
|
||||
size_str.parse::<u64>().ok()?;
|
||||
parts.next()?;
|
||||
parts.next()?;
|
||||
Some(parts.collect::<Vec<&str>>().join(" "))
|
||||
})
|
||||
.filter(|l| !l.ends_with('/') && !l.contains('*'))
|
||||
.collect())
|
||||
} else {
|
||||
let listing = std::process::Command::new("tar")
|
||||
.args(["-tf", zip_str])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let listing_str = String::from_utf8_lossy(&listing.stdout);
|
||||
let files: Vec<String> = listing_str.lines()
|
||||
.map(|l| l.trim())
|
||||
Ok(listing_str.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.ends_with('/'))
|
||||
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
|
||||
.collect();
|
||||
Ok((files, st.status.success()))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<bool, String> {
|
||||
let zip_str = zip_tmp.to_str().unwrap_or("");
|
||||
if cfg!(target_os = "linux") {
|
||||
let mut extract_ok = false;
|
||||
if let Ok(out) = std::process::Command::new("bsdtar")
|
||||
.args(["-tf", zip_str])
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
let st = std::process::Command::new("bsdtar")
|
||||
.args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
extract_ok = st.success();
|
||||
}
|
||||
}
|
||||
if !extract_ok {
|
||||
let st = std::process::Command::new("unzip")
|
||||
.args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
extract_ok = st.success();
|
||||
}
|
||||
Ok(extract_ok)
|
||||
} else if cfg!(target_os = "android") {
|
||||
let st = std::process::Command::new("unzip")
|
||||
.args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(st.success())
|
||||
} else {
|
||||
let st = std::process::Command::new("tar")
|
||||
.args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(st.success())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,11 +252,24 @@ pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -
|
|||
|
||||
fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?;
|
||||
let archive_tmp = download_and_assemble(&app, &tmp_dir, &parts, &raw_base, &request.package_id, &mut done_parts, total_parts).await?;
|
||||
let (extracted_files, extract_ok) = extract_archive(&archive_tmp, &dest_dir)?;
|
||||
let files = list_archive(&archive_tmp)?;
|
||||
for f in &files {
|
||||
let full = dest_dir.join(f);
|
||||
if full.is_file() {
|
||||
let bak = format!("{}.bak", full.to_string_lossy());
|
||||
if !Path::new(&bak).exists() {
|
||||
let _ = fs::rename(&full, &bak);
|
||||
}
|
||||
}
|
||||
}
|
||||
let extract_ok = extract_archive(&archive_tmp, &dest_dir)?;
|
||||
if !extract_ok {
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
return Err(format!("Extraction failed for {}", parts[0]));
|
||||
}
|
||||
let extracted_files: Vec<String> = files.iter()
|
||||
.map(|f| dest_dir.join(f).to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
for f in &extracted_files {
|
||||
if !workshop_files.contains(f) {
|
||||
|
|
@ -291,6 +315,10 @@ pub async fn workshop_uninstall(app: AppHandle, instance_id: String, package_id:
|
|||
if path.is_file() {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
let bak = format!("{}.bak", path.to_string_lossy());
|
||||
if Path::new(&bak).is_file() {
|
||||
let _ = fs::rename(&bak, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ pub fn load_config_raw(app: AppHandle) -> AppConfig {
|
|||
instance_launch_args: None,
|
||||
android_runner: None,
|
||||
android_audio_backend: None,
|
||||
goldmapper_enabled: Some(true),
|
||||
goldmapper_mappings: None,
|
||||
http_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use commands::dlc;
|
|||
use commands::download;
|
||||
use commands::file_dialogs;
|
||||
use commands::game;
|
||||
use commands::goldmapper;
|
||||
use commands::macos_setup;
|
||||
use commands::platform as platform_cmd;
|
||||
use commands::plugins;
|
||||
|
|
@ -149,6 +150,10 @@ pub fn run() {
|
|||
game::switch_proton,
|
||||
game::install_latest_driver,
|
||||
game::set_audio_backend,
|
||||
goldmapper::goldmapper_get_defaults,
|
||||
goldmapper::goldmapper_load_config,
|
||||
goldmapper::goldmapper_save_config,
|
||||
goldmapper::goldmapper_reset_config,
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ async fn run_host_relay(
|
|||
.map_err(|e| format!("Proxy connect failed: {}", e))?;
|
||||
|
||||
write_line(&mut host_conn, &format!("HOST {} 0", auth_token)).await?;
|
||||
let game_stream = tokio::time::timeout(
|
||||
let mut game_stream = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
async {
|
||||
loop {
|
||||
|
|
@ -59,41 +59,15 @@ async fn run_host_relay(
|
|||
.await
|
||||
.map_err(|e| format!("Proxy connect failed: {}", e))?;
|
||||
write_line(&mut accept_conn, &format!("ACCEPT {} 0 {}", auth_token, joiner_id)).await?;
|
||||
let (mut g_read, mut g_write) = game_stream.into_split();
|
||||
let (mut a_read, mut a_write) = accept_conn.into_split();
|
||||
let c1 = cancel.clone();
|
||||
let c2 = cancel.clone();
|
||||
let t1 = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 65536];
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = g_read.read(&mut buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => { if a_write.write_all(&buf[..n]).await.is_err() { break; } }
|
||||
}
|
||||
}
|
||||
_ = c1.cancelled() => break,
|
||||
tokio::select! {
|
||||
res = tokio::io::copy_bidirectional(&mut game_stream, &mut accept_conn) => {
|
||||
if let Err(e) = res {
|
||||
eprintln!("Host Relay error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
_ = cancel.cancelled() => {},
|
||||
}
|
||||
|
||||
let t2 = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 65536];
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = a_read.read(&mut buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => { if g_write.write_all(&buf[..n]).await.is_err() { break; } }
|
||||
}
|
||||
}
|
||||
_ = c2.cancelled() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = tokio::join!(t1, t2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -118,46 +92,20 @@ async fn run_relay_proxy(
|
|||
*port = Some(local_port);
|
||||
}
|
||||
|
||||
let (local_stream, _) = tokio::select! {
|
||||
let (mut local_stream, _) = tokio::select! {
|
||||
r = listener.accept() => r.map_err(|e| format!("Accept failed: {}", e))?,
|
||||
_ = cancel.cancelled() => return Err("Cancelled".into()),
|
||||
};
|
||||
|
||||
let (mut l_read, mut l_write) = local_stream.into_split();
|
||||
let (mut s_read, mut s_write) = stream.into_split();
|
||||
let c1 = cancel.clone();
|
||||
let c2 = cancel.clone();
|
||||
let t1 = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 65536];
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = l_read.read(&mut buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => { if s_write.write_all(&buf[..n]).await.is_err() { break; } }
|
||||
}
|
||||
}
|
||||
_ = c1.cancelled() => break,
|
||||
tokio::select! {
|
||||
res = tokio::io::copy_bidirectional(&mut local_stream, &mut stream) => {
|
||||
if let Err(e) = res {
|
||||
eprintln!("Relay Proxy error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
_ = cancel.cancelled() => {},
|
||||
}
|
||||
|
||||
let t2 = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 65536];
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = s_read.read(&mut buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => { if l_write.write_all(&buf[..n]).await.is_err() { break; } }
|
||||
}
|
||||
}
|
||||
_ = c2.cancelled() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = tokio::join!(t1, t2);
|
||||
Ok(local_port)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ pub struct InstanceLaunchArgs {
|
|||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GoldMapperMapping {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppConfig {
|
||||
|
|
@ -69,6 +75,9 @@ pub struct AppConfig {
|
|||
pub instance_launch_args: Option<std::collections::HashMap<String, InstanceLaunchArgs>>,
|
||||
pub android_runner: Option<String>,
|
||||
pub android_audio_backend: Option<String>,
|
||||
pub goldmapper_enabled: Option<bool>,
|
||||
pub goldmapper_mappings: Option<Vec<GoldMapperMapping>>,
|
||||
pub http_proxy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
|
|
|||
|
|
@ -44,6 +44,33 @@ pub fn copy_dir_all(src: impl AsRef<std::path::Path>, dst: impl AsRef<std::path:
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_http_client(proxy: Option<&str>) -> Result<reqwest::Client, String> {
|
||||
let mut builder = reqwest::Client::builder();
|
||||
builder = builder.user_agent("Emerald-Launcher");
|
||||
builder = builder
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(std::time::Duration::from_secs(500));
|
||||
if let Some(p) = proxy {
|
||||
let trimmed = p.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let has_scheme = trimmed.contains("://");
|
||||
let full = if has_scheme {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{}", trimmed)
|
||||
};
|
||||
let proxy = reqwest::Proxy::all(&full).map_err(|e| format!("Invalid proxy: {e}"))?;
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
}
|
||||
builder.build().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn build_http_client_from_app(app: &AppHandle) -> Result<reqwest::Client, String> {
|
||||
let config = config::load_config_raw(app.clone());
|
||||
build_http_client(config.http_proxy.as_deref())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn unix_path_to_wine_z_path(unix_path: &PathBuf) -> String {
|
||||
let p = unix_path.to_string_lossy();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
use once_cell::sync::Lazy;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
const REGISTRY_URL: &str = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main"; //neo: more hardcoding!
|
||||
static CLIENT: Lazy<reqwest::Client> = Lazy::new(|| reqwest::Client::new());
|
||||
pub struct Guard {
|
||||
cancel: Option<CancellationToken>,
|
||||
}
|
||||
|
|
@ -48,7 +46,8 @@ async fn serve(app: AppHandle, cancel: CancellationToken) {
|
|||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, _)) => {
|
||||
tokio::spawn(handle(stream));
|
||||
let handle_app = app.clone();
|
||||
tokio::spawn(handle(stream, handle_app));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app.emit("backend-error", format!("Workshop server accept error: {e}"));
|
||||
|
|
@ -61,7 +60,7 @@ async fn serve(app: AppHandle, cancel: CancellationToken) {
|
|||
}
|
||||
}
|
||||
|
||||
async fn handle(stream: tokio::net::TcpStream) {
|
||||
async fn handle(stream: tokio::net::TcpStream, app: AppHandle) {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
let mut request_line = String::new();
|
||||
|
|
@ -81,7 +80,7 @@ async fn handle(stream: tokio::net::TcpStream) {
|
|||
|
||||
if request_line.starts_with("GET /workshop/") && request_line.ends_with(" HTTP/1.1") {
|
||||
let path = &request_line["GET /workshop/".len()..request_line.len() - " HTTP/1.1".len()];
|
||||
match fetch_workshop_file(path).await {
|
||||
match fetch_workshop_file(&app, path).await {
|
||||
Ok(body) => {
|
||||
let body_bytes = &body;
|
||||
let content_type = if path.ends_with(".json") {
|
||||
|
|
@ -123,9 +122,10 @@ async fn handle(stream: tokio::net::TcpStream) {
|
|||
}
|
||||
}
|
||||
|
||||
async fn fetch_workshop_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
async fn fetch_workshop_file(app: &AppHandle, path: &str) -> Result<Vec<u8>, String> {
|
||||
let url = format!("{}/{}", REGISTRY_URL, path);
|
||||
let resp = CLIENT.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
let client = crate::util::build_http_client_from_app(app)?;
|
||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if resp.status().is_success() {
|
||||
resp.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"resources": ["resources/DLC"],
|
||||
"resources": ["resources/DLC", "resources/GoldMapperLib.dll", "resources/GoldMapperLauncher.exe", "resources/SDL2.dll"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAudio } from "../../context/LauncherContext";
|
||||
|
||||
|
|
@ -14,10 +15,12 @@ export function AchievementToast({
|
|||
message,
|
||||
onClose,
|
||||
onClick,
|
||||
title = "Error!",
|
||||
title,
|
||||
variant = "error",
|
||||
}: AchievementToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const { playSfx } = useAudio();
|
||||
const displayTitle = title ?? t("common.error");
|
||||
const prevMessage = useRef(message);
|
||||
useEffect(() => {
|
||||
const wasNull = !prevMessage.current;
|
||||
|
|
@ -47,19 +50,14 @@ export function AchievementToast({
|
|||
const getIcon = () => {
|
||||
if (variant === "update") {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#FFFF55"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="square"
|
||||
className="drop-shadow-md"
|
||||
>
|
||||
<path d="M12 5v14M5 12l7 7 7-7" />
|
||||
</svg>
|
||||
<img
|
||||
src="/images/Update_Icon.png"
|
||||
alt="Update"
|
||||
className="w-8 h-8 object-contain"
|
||||
style={{
|
||||
imageRendering: "pixelated"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (variant === "steam") {
|
||||
|
|
@ -109,25 +107,17 @@ export function AchievementToast({
|
|||
}
|
||||
: undefined
|
||||
}
|
||||
className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`}
|
||||
className={onClick ? "cursor-pointer" : ""}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 min-w-[300px] max-w-[450px]"
|
||||
style={{
|
||||
backgroundImage: "url('/images/notification.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<div className="w-12 h-12 flex-shrink-0 flex items-center justify-center bg-[#3F3F3F] border-2 border-[#1A1A1A]">
|
||||
<div className="flex gap-3 p-1 min-w-[380px] max-w-[380px] min-h-[80px] mc-options-bg">
|
||||
<div className="h-[54px] w-[54px] flex-shrink-0 flex items-center justify-center bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat self-center">
|
||||
{getIcon()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[#FFFF55] text-lg font-bold mc-text-shadow leading-tight">
|
||||
{title}
|
||||
<div className="flex flex-col mt-[6px]">
|
||||
<span className="text-[#333333] text-[20px] leading-tight font-normal">
|
||||
{displayTitle}
|
||||
</span>
|
||||
<span className="text-white text-base mc-text-shadow leading-tight break-words">
|
||||
<span className="text-[#333333] text-[13px] leading-tight break-words font-normal">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useUI } from "../../context/LauncherContext";
|
||||
|
||||
|
||||
interface PanoramaProps {
|
||||
profile: string;
|
||||
|
|
@ -7,7 +7,6 @@ interface PanoramaProps {
|
|||
}
|
||||
|
||||
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
|
||||
const { isWindowVisible } = useUI();
|
||||
const baseId = profile;
|
||||
const profileId = baseId ? baseId : "vanilla_tu19";
|
||||
const isCustomUrl = profile.startsWith("data:") || profile.startsWith("blob:");
|
||||
|
|
@ -54,18 +53,16 @@ const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
|
|||
ref={containerRef}
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500"
|
||||
>
|
||||
{isWindowVisible && (
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full will-change-transform"
|
||||
style={{
|
||||
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : "200vw",
|
||||
backgroundImage: `url("${currentPanorama}")`,
|
||||
backgroundSize: bgWidth ? `${bgWidth}px 100%` : "auto 100%",
|
||||
backgroundRepeat: "repeat-x",
|
||||
animation: bgWidth ? "panoramaLoop 140s linear infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full will-change-transform"
|
||||
style={{
|
||||
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : "200vw",
|
||||
backgroundImage: `url("${currentPanorama}")`,
|
||||
backgroundSize: bgWidth ? `${bgWidth}px 100%` : "auto 100%",
|
||||
backgroundRepeat: "repeat-x",
|
||||
animation: bgWidth ? "panoramaLoop 140s linear infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
|
||||
</>
|
||||
|
|
|
|||
614
src/components/common/SkinModel3D.tsx
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
import { useEffect, useRef, memo } from "react";
|
||||
import * as THREE from "three";
|
||||
type UVSet = Record<string, number[]>;
|
||||
interface SkinModel3DProps {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
capeUrl?: string | null;
|
||||
animate?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const clamp = (v: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, v));
|
||||
|
||||
let snapshotChain: Promise<void> = Promise.resolve();
|
||||
const snapshotCache = new Map<string, string>();
|
||||
function createFaceMaterial(
|
||||
tex: THREE.Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
flipX = false,
|
||||
flipY = false,
|
||||
) {
|
||||
const img = tex.image as { width: number; height: number } | undefined;
|
||||
const imgH = img && img.height ? img.height : 64;
|
||||
const matTex = tex.clone();
|
||||
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / imgH);
|
||||
matTex.offset.set((flipX ? x + w : x) / 64, 1 - (flipY ? y : y + h) / imgH);
|
||||
matTex.needsUpdate = true;
|
||||
return new THREE.MeshLambertMaterial({
|
||||
map: matTex,
|
||||
transparent: true,
|
||||
alphaTest: 0.5,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
}
|
||||
|
||||
function createPart(
|
||||
tex: THREE.Texture,
|
||||
w: number,
|
||||
h: number,
|
||||
d: number,
|
||||
uv: UVSet,
|
||||
overlayUv?: UVSet,
|
||||
swapMats = false,
|
||||
isLegacyMirror = false,
|
||||
) {
|
||||
const group = new THREE.Group();
|
||||
const geo = new THREE.BoxGeometry(w, h, d);
|
||||
const getMats = (uvSet: UVSet) => {
|
||||
const flipX = isLegacyMirror;
|
||||
return [
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
swapMats ? uvSet.right[0] : uvSet.left[0],
|
||||
uvSet.left[1],
|
||||
uvSet.left[2],
|
||||
uvSet.left[3],
|
||||
flipX,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
swapMats ? uvSet.left[0] : uvSet.right[0],
|
||||
uvSet.right[1],
|
||||
uvSet.right[2],
|
||||
uvSet.right[3],
|
||||
flipX,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.top[0],
|
||||
uvSet.top[1],
|
||||
uvSet.top[2],
|
||||
uvSet.top[3],
|
||||
flipX,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.bottom[0],
|
||||
uvSet.bottom[1],
|
||||
uvSet.bottom[2],
|
||||
uvSet.bottom[3],
|
||||
flipX,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.front[0],
|
||||
uvSet.front[1],
|
||||
uvSet.front[2],
|
||||
uvSet.front[3],
|
||||
flipX,
|
||||
),
|
||||
createFaceMaterial(
|
||||
tex,
|
||||
uvSet.back[0],
|
||||
uvSet.back[1],
|
||||
uvSet.back[2],
|
||||
uvSet.back[3],
|
||||
!flipX,
|
||||
),
|
||||
];
|
||||
};
|
||||
group.add(new THREE.Mesh(geo, getMats(uv)));
|
||||
if (overlayUv) {
|
||||
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
|
||||
group.add(new THREE.Mesh(oGeo, getMats(overlayUv)));
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
const limbUv = (x: number, y: number, w = 4): UVSet => ({
|
||||
top: [x + 4, y, w, 4],
|
||||
bottom: [x + 4 + w, y, w, 4],
|
||||
right: [x, y + 4, 4, 12],
|
||||
front: [x + 4, y + 4, w, 12],
|
||||
left: [x + 4 + w, y + 4, 4, 12],
|
||||
back: [x + 8 + w, y + 4, w, 12],
|
||||
});
|
||||
|
||||
function buildPlayer(
|
||||
playerGroup: THREE.Group,
|
||||
texture: THREE.Texture,
|
||||
slimOpt?: boolean,
|
||||
) {
|
||||
const img = texture.image as HTMLImageElement;
|
||||
const isLegacy = (img?.height || 64) === 32;
|
||||
const isSlim =
|
||||
slimOpt !== undefined
|
||||
? slimOpt
|
||||
: !isLegacy &&
|
||||
(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img?.width || 64;
|
||||
canvas.height = img?.height || 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return false;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
try {
|
||||
return ctx.getImageData(42, 48, 1, 1).data[3] === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const armW = isSlim ? 3 : 4;
|
||||
const headUv: UVSet = {
|
||||
top: [8, 0, 8, 8],
|
||||
bottom: [16, 0, 8, 8],
|
||||
right: [0, 8, 8, 8],
|
||||
left: [16, 8, 8, 8],
|
||||
front: [8, 8, 8, 8],
|
||||
back: [24, 8, 8, 8],
|
||||
};
|
||||
const hatUv: UVSet = {
|
||||
top: [40, 0, 8, 8],
|
||||
bottom: [48, 0, 8, 8],
|
||||
right: [32, 8, 8, 8],
|
||||
left: [48, 8, 8, 8],
|
||||
front: [40, 8, 8, 8],
|
||||
back: [56, 8, 8, 8],
|
||||
};
|
||||
const head = createPart(texture, 8, 8, 8, headUv, hatUv);
|
||||
head.position.y = 10;
|
||||
playerGroup.add(head);
|
||||
const bodyUv: UVSet = {
|
||||
top: [20, 16, 8, 4],
|
||||
bottom: [28, 16, 8, 4],
|
||||
right: [16, 20, 4, 12],
|
||||
left: [28, 20, 4, 12],
|
||||
front: [20, 20, 8, 12],
|
||||
back: [32, 20, 8, 12],
|
||||
};
|
||||
const jacketUv: UVSet = {
|
||||
top: [20, 32, 8, 4],
|
||||
bottom: [28, 32, 8, 4],
|
||||
right: [16, 36, 4, 12],
|
||||
left: [28, 36, 4, 12],
|
||||
front: [20, 36, 8, 12],
|
||||
back: [32, 36, 8, 12],
|
||||
};
|
||||
playerGroup.add(
|
||||
createPart(texture, 8, 12, 4, bodyUv, isLegacy ? undefined : jacketUv),
|
||||
);
|
||||
|
||||
const rightArm = createPart(
|
||||
texture,
|
||||
armW,
|
||||
12,
|
||||
4,
|
||||
limbUv(40, 16, armW),
|
||||
isLegacy ? undefined : limbUv(40, 32, armW),
|
||||
);
|
||||
rightArm.position.set(isSlim ? -5.5 : -6, 0, 0);
|
||||
playerGroup.add(rightArm);
|
||||
const leftArm = createPart(
|
||||
texture,
|
||||
armW,
|
||||
12,
|
||||
4,
|
||||
isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW),
|
||||
isLegacy ? undefined : limbUv(48, 48, armW),
|
||||
isLegacy,
|
||||
isLegacy,
|
||||
);
|
||||
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
|
||||
playerGroup.add(leftArm);
|
||||
const rightLeg = createPart(
|
||||
texture,
|
||||
4,
|
||||
12,
|
||||
4,
|
||||
limbUv(0, 16),
|
||||
isLegacy ? undefined : limbUv(0, 32),
|
||||
);
|
||||
rightLeg.position.set(-2, -12, 0);
|
||||
playerGroup.add(rightLeg);
|
||||
const leftLeg = createPart(
|
||||
texture,
|
||||
4,
|
||||
12,
|
||||
4,
|
||||
isLegacy ? limbUv(0, 16) : limbUv(16, 48),
|
||||
isLegacy ? undefined : limbUv(0, 48),
|
||||
isLegacy,
|
||||
isLegacy,
|
||||
);
|
||||
leftLeg.position.set(2, -12, 0);
|
||||
playerGroup.add(leftLeg);
|
||||
}
|
||||
|
||||
function addCape(playerGroup: THREE.Group, texture: THREE.Texture) {
|
||||
const capeUv: UVSet = {
|
||||
top: [1, 0, 10, 1],
|
||||
bottom: [11, 0, 10, 1],
|
||||
right: [0, 1, 1, 16],
|
||||
front: [1, 1, 10, 16],
|
||||
left: [11, 1, 1, 16],
|
||||
back: [12, 1, 10, 16],
|
||||
};
|
||||
const capeGroup = new THREE.Group();
|
||||
const capeGeo = new THREE.BoxGeometry(10, 16, 1);
|
||||
const capeMats = [
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.left[0],
|
||||
capeUv.left[1],
|
||||
capeUv.left[2],
|
||||
capeUv.left[3],
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.right[0],
|
||||
capeUv.right[1],
|
||||
capeUv.right[2],
|
||||
capeUv.right[3],
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.top[0],
|
||||
capeUv.top[1],
|
||||
capeUv.top[2],
|
||||
capeUv.top[3],
|
||||
false,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.bottom[0],
|
||||
capeUv.bottom[1],
|
||||
capeUv.bottom[2],
|
||||
capeUv.bottom[3],
|
||||
false,
|
||||
true,
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.back[0],
|
||||
capeUv.back[1],
|
||||
capeUv.back[2],
|
||||
capeUv.back[3],
|
||||
),
|
||||
createFaceMaterial(
|
||||
texture,
|
||||
capeUv.front[0],
|
||||
capeUv.front[1],
|
||||
capeUv.front[2],
|
||||
capeUv.front[3],
|
||||
),
|
||||
];
|
||||
const capeMesh = new THREE.Mesh(capeGeo, capeMats);
|
||||
capeMesh.position.set(0, -8, -0.5);
|
||||
capeGroup.add(capeMesh);
|
||||
capeGroup.position.set(0, 6, -2.35);
|
||||
capeGroup.rotation.x = 0.15;
|
||||
playerGroup.add(capeGroup);
|
||||
}
|
||||
|
||||
function fitCamera(camera: THREE.PerspectiveCamera, target: THREE.Object3D) {
|
||||
const box = new THREE.Box3().setFromObject(target);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
const vFov = THREE.MathUtils.degToRad(camera.fov);
|
||||
const distance = maxDim / (2 * Math.tan(vFov / 2) * 0.72);
|
||||
camera.position.set(center.x, center.y, center.z + distance);
|
||||
camera.lookAt(center);
|
||||
}
|
||||
|
||||
function addLights(scene: THREE.Scene) {
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dl.position.set(10, 20, 10);
|
||||
scene.add(dl);
|
||||
}
|
||||
|
||||
function disposeScene(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
extraTextures: THREE.Texture[],
|
||||
) {
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
if (object.geometry) object.geometry.dispose();
|
||||
const mats = Array.isArray(object.material)
|
||||
? object.material
|
||||
: [object.material];
|
||||
mats.forEach((m) => {
|
||||
if (m.map) m.map.dispose();
|
||||
m.dispose();
|
||||
});
|
||||
}
|
||||
});
|
||||
extraTextures.forEach((t) => t.dispose());
|
||||
renderer.dispose();
|
||||
}
|
||||
|
||||
function renderSkinSnapshot(opts: {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
capeUrl?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = opts.width;
|
||||
canvas.height = opts.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("no 2d context"));
|
||||
return;
|
||||
}
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
35,
|
||||
opts.width / opts.height,
|
||||
0.1,
|
||||
1000,
|
||||
);
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(opts.width, opts.height);
|
||||
addLights(scene);
|
||||
const playerGroup = new THREE.Group();
|
||||
scene.add(playerGroup);
|
||||
const extraTextures: THREE.Texture[] = [];
|
||||
let active = true;
|
||||
let skinDone = false;
|
||||
let capeDone = !opts.capeUrl;
|
||||
const finish = () => {
|
||||
if (!active || !skinDone || !capeDone) return;
|
||||
fitCamera(camera, playerGroup);
|
||||
renderer.render(scene, camera);
|
||||
ctx.drawImage(renderer.domElement, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
disposeScene(scene, renderer, extraTextures);
|
||||
active = false;
|
||||
resolve(dataUrl);
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
disposeScene(scene, renderer, extraTextures);
|
||||
reject(err);
|
||||
};
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.load(
|
||||
opts.src,
|
||||
(tex) => {
|
||||
if (!active) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
buildPlayer(playerGroup, tex, opts.slim);
|
||||
extraTextures.push(tex);
|
||||
skinDone = true;
|
||||
finish();
|
||||
},
|
||||
undefined,
|
||||
fail,
|
||||
);
|
||||
if (opts.capeUrl) {
|
||||
loader.load(
|
||||
opts.capeUrl,
|
||||
(tex) => {
|
||||
if (!active) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
addCape(playerGroup, tex);
|
||||
extraTextures.push(tex);
|
||||
capeDone = true;
|
||||
finish();
|
||||
},
|
||||
undefined,
|
||||
fail,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getSkinModelSnapshot(opts: {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
capeUrl?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Promise<string> {
|
||||
const key = `${opts.src}\u0000${opts.slim ? 1 : 0}\u0000${opts.capeUrl || ""}`;
|
||||
const hit = snapshotCache.get(key);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
const result = snapshotChain.then(() => renderSkinSnapshot(opts));
|
||||
snapshotChain = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
result.then((dataUrl) => snapshotCache.set(key, dataUrl)).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
const SkinModel3D = memo(function SkinModel3D({
|
||||
src,
|
||||
slim,
|
||||
capeUrl,
|
||||
animate,
|
||||
className,
|
||||
}: SkinModel3DProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mountRef.current;
|
||||
if (!el) return;
|
||||
const width = el.clientWidth || 240;
|
||||
const height = el.clientHeight || 320;
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(width, height);
|
||||
el.innerHTML = "";
|
||||
el.appendChild(renderer.domElement);
|
||||
addLights(scene);
|
||||
const playerGroup = new THREE.Group();
|
||||
scene.add(playerGroup);
|
||||
const extraTextures: THREE.Texture[] = [];
|
||||
let built = false;
|
||||
let disposed = false;
|
||||
let rotY = -0.35;
|
||||
let rotX = 0.06;
|
||||
let dragging = false;
|
||||
let raf = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
const render = () => {
|
||||
if (!disposed) renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
dragging = true;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
};
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging || disposed) return;
|
||||
rotY += (e.clientX - lastX) * 0.01;
|
||||
rotX = clamp(rotX + (e.clientY - lastY) * 0.01, -0.6, 0.6);
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
dragging = false;
|
||||
};
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
dragging = true;
|
||||
lastX = t.clientX;
|
||||
lastY = t.clientY;
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!dragging || disposed) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
rotY += (t.clientX - lastX) * 0.01;
|
||||
rotX = clamp(rotX + (t.clientY - lastY) * 0.01, -0.6, 0.6);
|
||||
lastX = t.clientX;
|
||||
lastY = t.clientY;
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
dragging = false;
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (animate && !dragging) rotY += 0.004;
|
||||
if (animate || dragging) {
|
||||
playerGroup.rotation.y = rotY;
|
||||
playerGroup.rotation.x = rotX;
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (!mountRef.current || disposed) return;
|
||||
const w = mountRef.current.clientWidth || width;
|
||||
const h = mountRef.current.clientHeight || height;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (built) fitCamera(camera, playerGroup);
|
||||
render();
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onTouchEnd);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
disposeScene(scene, renderer, extraTextures);
|
||||
};
|
||||
|
||||
const loader = new THREE.TextureLoader();
|
||||
let skinLoaded = false;
|
||||
let capeLoaded = !capeUrl;
|
||||
const tryFinish = () => {
|
||||
if (!skinLoaded || !capeLoaded || disposed) return;
|
||||
built = true;
|
||||
fitCamera(camera, playerGroup);
|
||||
render();
|
||||
loop();
|
||||
};
|
||||
loader.load(
|
||||
src,
|
||||
(tex) => {
|
||||
if (disposed) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
buildPlayer(playerGroup, tex, slim);
|
||||
extraTextures.push(tex);
|
||||
skinLoaded = true;
|
||||
tryFinish();
|
||||
},
|
||||
undefined,
|
||||
() => dispose(),
|
||||
);
|
||||
if (capeUrl) {
|
||||
loader.load(
|
||||
capeUrl,
|
||||
(tex) => {
|
||||
if (disposed) return;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
addCape(playerGroup, tex);
|
||||
extraTextures.push(tex);
|
||||
capeLoaded = true;
|
||||
tryFinish();
|
||||
},
|
||||
undefined,
|
||||
() => dispose(),
|
||||
);
|
||||
}
|
||||
|
||||
renderer.domElement.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
renderer.domElement.addEventListener("touchstart", onTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", onTouchEnd);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return dispose;
|
||||
}, [src, slim, capeUrl, animate]);
|
||||
|
||||
return <div ref={mountRef} className={className} />;
|
||||
});
|
||||
|
||||
export default SkinModel3D;
|
||||
|
|
@ -36,7 +36,7 @@ const SkinViewer = memo(function SkinViewer({
|
|||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const { legacyMode } = useConfig();
|
||||
const { legacyMode, animationsEnabled } = useConfig();
|
||||
const overlaysRef = useRef<THREE.Mesh[]>([]);
|
||||
const capeRef = useRef<THREE.Group | null>(null);
|
||||
const capeOrigRef = useRef<{ y: number; rx: number; meshY: number } | null>(
|
||||
|
|
@ -433,7 +433,9 @@ const SkinViewer = memo(function SkinViewer({
|
|||
renderer.domElement.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
renderer.domElement.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
renderer.domElement.addEventListener("touchstart", onTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", onTouchEnd);
|
||||
return () => {
|
||||
|
|
@ -523,6 +525,7 @@ const SkinViewer = memo(function SkinViewer({
|
|||
} else if (e.key === "ArrowUp") {
|
||||
setFocusIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex === 0) {
|
||||
(
|
||||
containerRef.current?.querySelector("input") as HTMLElement
|
||||
|
|
@ -678,10 +681,10 @@ const SkinViewer = memo(function SkinViewer({
|
|||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
|
||||
initial={{ opacity: animationsEnabled ? 0 : 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: animationsEnabled ? 0 : 1 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className={`absolute ${legacyMode ? "left-[calc(50vw-340px)]" : "left-16"} ${legacyMode ? "top-1/2" : "top-[40%]"} -translate-y-1/2 flex flex-col items-center gap-1 outline-none z-10`}
|
||||
style={style}
|
||||
>
|
||||
|
|
@ -691,20 +694,22 @@ const SkinViewer = memo(function SkinViewer({
|
|||
data-focus="0"
|
||||
tabIndex={0}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
maxLength={16}
|
||||
style={{ width: `${Math.max(username.length, 3) + 2}ch` }}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="bg-transparent text-white focus:text-[#FFFF55] outline-none border-none text-center font-['Mojangles'] mc-text-shadow tracking-widest text-xl cursor-text"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
maxLength={16}
|
||||
style={{ width: `${Math.max(username.length, 3) + 2}ch` }}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="text-white focus:text-[#FFFF55] outline-none text-center font-[var(--font-base)] tracking-widest text-xl cursor-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!legacyMode && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { motion } from "framer-motion";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
|
|
@ -9,7 +10,11 @@ interface AppHeaderProps {
|
|||
uiFade: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const AppHeader = memo(function AppHeader({ playPressSound, uiFade }: AppHeaderProps) {
|
||||
export const AppHeader = memo(function AppHeader({
|
||||
playPressSound,
|
||||
uiFade,
|
||||
}: AppHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<motion.div
|
||||
key="header"
|
||||
|
|
@ -26,8 +31,8 @@ export const AppHeader = memo(function AppHeader({ playPressSound, uiFade }: App
|
|||
className="w-4 h-4 object-contain block pointer-events-none"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<span className="text-xs text-gray-300 mc-text-shadow opacity-90 tracking-wide leading-none block pt-[1px] pointer-events-none">
|
||||
Emerald Legacy Launcher
|
||||
<span className="text-xs text-gray-30 mc-text-shadow block pt-[1px] pointer-events-none">
|
||||
{t("header.title")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScreenshotImage } from "../common/ScreenshotImage";
|
||||
import type { Edition } from "../../types/edition";
|
||||
|
||||
|
|
@ -9,7 +10,12 @@ interface DownloadOverlayProps {
|
|||
editions: Edition[];
|
||||
}
|
||||
|
||||
export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingIds, editions }: DownloadOverlayProps) {
|
||||
export const DownloadOverlay = memo(function DownloadOverlay({
|
||||
downloadProgress,
|
||||
downloadingIds,
|
||||
editions,
|
||||
}: DownloadOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
if (downloadingIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -18,76 +24,92 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress,
|
|||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-14 right-8 z-100 w-72 bg-[#1a1a1a] border-2 border-[#555] shadow-2xl"
|
||||
className="absolute top-14 right-8 z-100 w-100 mc-options-bg"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
<div className="px-3 pt-2.5 pb-2 border-b border-white/10">
|
||||
<span className="text-[13px] text-[#FFFF55] mc-text-shadow uppercase tracking-widest">
|
||||
Downloads
|
||||
</span>
|
||||
<div className="px-3 pb-1">
|
||||
<span className="text-xl text-[#333333]">{t("download.title")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2 max-h-[260px] overflow-y-auto custom-scrollbar">
|
||||
<div className="flex flex-col gap-1.5 py-2 max-h-[260px] overflow-y-auto custom-scrollbar mc-options-fg">
|
||||
{downloadingIds.map((id) => {
|
||||
const pct = downloadProgress[id] ?? 0;
|
||||
const edition = editions.find((e) => e.instanceId === id || e.id === id);
|
||||
const name = edition?.name || "Game Files";
|
||||
const edition = editions.find(
|
||||
(e) => e.instanceId === id || e.id === id,
|
||||
);
|
||||
const name =
|
||||
edition?.name ||
|
||||
(id.startsWith("runner_")
|
||||
? id.replace(/^runner_/, "")
|
||||
: t("download.gameFiles"));
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-2.5">
|
||||
{edition?.logo ? (
|
||||
edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? (
|
||||
<img
|
||||
src={edition.logo}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenshotImage
|
||||
path={edition.logo}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)
|
||||
) : edition?.titleImage ? (
|
||||
edition.titleImage.startsWith("http") || edition.titleImage.startsWith("/images") ? (
|
||||
<img
|
||||
src={edition.titleImage}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenshotImage
|
||||
path={edition.titleImage}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center border border-[#555] bg-black/40 shrink-0">
|
||||
<svg className="w-3 h-3 text-[#FFFF55]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div key={id} className="flex items-center mc-options-bg">
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<span className="text-[11px] text-white mc-text-shadow truncate leading-tight">
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex-1 flex flex-row justify-between">
|
||||
<div className="flex-1 flex flex-row">
|
||||
{edition?.logo ? (
|
||||
edition.logo.startsWith("http") ||
|
||||
edition.logo.startsWith("/images") ? (
|
||||
<img
|
||||
src={edition.logo}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenshotImage
|
||||
path={edition.logo}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)
|
||||
) : edition?.titleImage ? (
|
||||
edition.titleImage.startsWith("http") ||
|
||||
edition.titleImage.startsWith("/images") ? (
|
||||
<img
|
||||
src={edition.titleImage}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenshotImage
|
||||
path={edition.titleImage}
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat">
|
||||
<svg
|
||||
className="w-3 h-3 text-[#FFFF55]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-[#333333] truncate leading-tight shrink-0 self-center pl-1">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-[#333333] w-7 text-right shrink-0 pr-1.5">
|
||||
{Math.floor(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-2 border border-white/30 bg-black/60">
|
||||
<div className="flex-1 h-3 border border-[#666666] bg-[#666666]">
|
||||
<div
|
||||
className="h-full bg-[#FFFF55]"
|
||||
className="h-full bg-[#00ff61]"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 mc-text-shadow w-7 text-right shrink-0 leading-none">
|
||||
{Math.floor(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import { lceOnlineService } from "../../services/LceOnlineService";
|
||||
|
|
@ -30,6 +31,7 @@ export default function ChooseInstanceModal({
|
|||
installs: string[];
|
||||
invite: GameInvite | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedInstance, setSelectedInstance] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
|
@ -60,17 +62,17 @@ export default function ChooseInstanceModal({
|
|||
playPressSound();
|
||||
setIsJoining(true);
|
||||
setError("");
|
||||
setStatus("Launching game...");
|
||||
setStatus(t("modals.chooseInstance.launching"));
|
||||
try {
|
||||
const sessionId = invite.sessionId || "";
|
||||
if (sessionId) {
|
||||
setStatus("Connecting via relay...");
|
||||
setStatus(t("modals.chooseInstance.connectingRelay"));
|
||||
const accessToken = lceOnlineService.accessToken ?? "";
|
||||
const relayPromise = TauriService.startRelayProxy(
|
||||
accessToken,
|
||||
sessionId,
|
||||
);
|
||||
setStatus("Launching game...");
|
||||
setStatus(t("modals.chooseInstance.launching"));
|
||||
await TauriService.launchGame(
|
||||
selectedInstance,
|
||||
[
|
||||
|
|
@ -117,6 +119,7 @@ export default function ChooseInstanceModal({
|
|||
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
|
||||
setFocusIndex((prev) => (prev - 1 + max) % max);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex === 0 && validInstances.length > 0) {
|
||||
const currentIdx = validInstances.findIndex(
|
||||
(i: Edition) => i.instanceId === selectedInstance,
|
||||
|
|
@ -163,10 +166,12 @@ export default function ChooseInstanceModal({
|
|||
{!isJoining ? (
|
||||
<>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-2 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Join Game
|
||||
{t("modals.chooseInstance.title")}
|
||||
</h2>
|
||||
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
|
||||
Joining {hostName}'s game. Choose an instance:
|
||||
{t("modals.chooseInstance.joinPrompt", {
|
||||
name: hostName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{validInstances.length > 0 ? (
|
||||
|
|
@ -213,7 +218,7 @@ export default function ChooseInstanceModal({
|
|||
</div>
|
||||
) : (
|
||||
<p className="text-red-400 text-sm mc-text-shadow mb-4 text-center">
|
||||
No installed instances available. Install a version first.
|
||||
{t("modals.chooseInstance.noInstances")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -250,7 +255,7 @@ export default function ChooseInstanceModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("modals.chooseInstance.cancel")}
|
||||
</button>
|
||||
{validInstances.length > 0 && (
|
||||
<button
|
||||
|
|
@ -266,7 +271,7 @@ export default function ChooseInstanceModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Join
|
||||
{t("modals.chooseInstance.join")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -274,7 +279,7 @@ export default function ChooseInstanceModal({
|
|||
) : (
|
||||
<>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Joining Game
|
||||
{t("modals.chooseInstance.joiningTitle")}
|
||||
</h2>
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ModalButtonProps {
|
||||
label: string;
|
||||
|
|
@ -50,6 +51,7 @@ export default function CustomTUModal({
|
|||
editingEdition?: { name: string; desc: string; url: string; path?: string } | null;
|
||||
initialPath?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [desc, setDesc] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
|
|
@ -84,6 +86,7 @@ export default function CustomTUModal({
|
|||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex === 3) {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
|
|
@ -107,19 +110,19 @@ export default function CustomTUModal({
|
|||
|
||||
const handleImport = () => {
|
||||
if (!name) {
|
||||
setError("Name is required");
|
||||
setError(t("modals.customTu.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!url && !path) {
|
||||
setError("URL or Path is required");
|
||||
setError(t("modals.customTu.urlOrPathRequired"));
|
||||
return;
|
||||
}
|
||||
if (url && !url.startsWith("http")) {
|
||||
setError("Invalid URL");
|
||||
setError(t("modals.customTu.invalidUrl"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onImport({ name, desc: desc || "Custom imported TU", url, path: path || undefined });
|
||||
onImport({ name, desc: desc || t("modals.customTu.defaultDesc"), url, path: path || undefined });
|
||||
onClose();
|
||||
setName("");
|
||||
setDesc("");
|
||||
|
|
@ -133,68 +136,76 @@ export default function CustomTUModal({
|
|||
className="relative w-[400px] p-6 flex flex-col items-center mc-options-bg"
|
||||
>
|
||||
<h2 className="text-xl text-black mc-text-shadow mb-4 text-center">
|
||||
{editingEdition ? "Edit Custom TU" : "Import Custom TU"}
|
||||
{editingEdition ? t("modals.customTu.editTitle") : t("modals.customTu.importTitle")}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
TU Name
|
||||
{t("modals.customTu.tuName")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={() => setFocusIndex(0)}
|
||||
placeholder="e.g. My Awesome Mod"
|
||||
className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']"
|
||||
placeholder={t("modals.customTu.namePlaceholder")}
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
Description (Optional)
|
||||
{t("modals.customTu.description")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
onFocus={() => setFocusIndex(1)}
|
||||
placeholder="A brief description..."
|
||||
className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']"
|
||||
placeholder={t("modals.customTu.descPlaceholder")}
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
Download URL (.zip)
|
||||
{t("modals.customTu.downloadUrl")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onFocus={() => setFocusIndex(2)}
|
||||
placeholder="optional if path is set"
|
||||
className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']"
|
||||
placeholder={t("modals.customTu.urlPlaceholder")}
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{path && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
Local Path
|
||||
{t("modals.customTu.localPath")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={path}
|
||||
className="w-full h-10 px-3 bg-black/20 border-2 border-[#222] text-black text-xs outline-none font-['Mojangles'] cursor-not-allowed"
|
||||
className="mc-textinput w-full h-10 px-3 text-black text-xs outline-none font-[var(--font-base)] cursor-not-allowed"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -207,14 +218,14 @@ export default function CustomTUModal({
|
|||
|
||||
<div className="flex gap-4 mt-6 w-full">
|
||||
<ModalButton
|
||||
label="Cancel"
|
||||
label={t("modals.customTu.cancel")}
|
||||
onClick={() => {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<ModalButton
|
||||
label={editingEdition ? "Save" : "Import"}
|
||||
label={editingEdition ? t("modals.customTu.save") : t("modals.customTu.import")}
|
||||
onClick={() => {
|
||||
playPressSound("save_click.wav");
|
||||
handleImport();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ export default function CustomizeModal({
|
|||
currentPanorama?: string;
|
||||
onSave: (updates: { titleImage?: string; panorama?: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [titleImage, setTitleImage] = useState(currentTitleImage || "");
|
||||
const [panorama, setPanorama] = useState(currentPanorama || "");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
|
@ -56,6 +58,7 @@ export default function CustomizeModal({
|
|||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex === 4) {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
|
|
@ -90,7 +93,7 @@ export default function CustomizeModal({
|
|||
const handlePickFile = async (field: "titleImage" | "panorama") => {
|
||||
try {
|
||||
const path = await TauriService.pickFile(
|
||||
field === "titleImage" ? "Select Title Image" : "Select Panorama Background",
|
||||
field === "titleImage" ? t("modals.customize.selectTitleImage") : t("modals.customize.selectPanorama"),
|
||||
["png", "jpg", "jpeg", "bmp"],
|
||||
);
|
||||
if (path) {
|
||||
|
|
@ -119,7 +122,7 @@ export default function CustomizeModal({
|
|||
) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[#AAAAAA] text-xs mc-text-shadow uppercase tracking-widest">
|
||||
{field === "titleImage" ? "Title Image" : "Panorama Background"}
|
||||
{field === "titleImage" ? t("modals.customize.titleImage") : t("modals.customize.panoramaBackground")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
|
@ -135,7 +138,7 @@ export default function CustomizeModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{value ? "Change Image" : "Pick Image"}
|
||||
{value ? t("modals.customize.changeImage") : t("modals.customize.pickImage")}
|
||||
</button>
|
||||
{value && (
|
||||
<button
|
||||
|
|
@ -151,7 +154,7 @@ export default function CustomizeModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
{t("modals.customize.reset")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -159,7 +162,7 @@ export default function CustomizeModal({
|
|||
<div className="flex items-center gap-2 mt-1">
|
||||
<img
|
||||
src={value}
|
||||
alt="Preview"
|
||||
alt={t("modals.customize.preview")}
|
||||
className="w-10 h-6 object-contain border border-[#555]"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => {
|
||||
|
|
@ -190,7 +193,7 @@ export default function CustomizeModal({
|
|||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Customize
|
||||
{t("modals.customize.title")}
|
||||
</h2>
|
||||
<p className="text-white/70 text-xs mc-text-shadow mb-4 -mt-2 truncate max-w-full">
|
||||
{editionName}
|
||||
|
|
@ -220,7 +223,7 @@ export default function CustomizeModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("modals.customize.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(5)}
|
||||
|
|
@ -237,7 +240,7 @@ export default function CustomizeModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
{t("modals.customize.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
interface DlcEntry {
|
||||
|
|
@ -23,6 +24,7 @@ export default function DownloadDlcModal({
|
|||
instanceId: string;
|
||||
officialDLC: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dlcList, setDlcList] = useState<DlcEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -185,7 +187,11 @@ export default function DownloadDlcModal({
|
|||
succeeded.push(selected[i].name);
|
||||
} else {
|
||||
setError(
|
||||
`Failed to download ${selected[i].name}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`,
|
||||
t("modals.downloadDlc.failedToDownload", {
|
||||
name: selected[i].name,
|
||||
reason:
|
||||
r.reason instanceof Error ? r.reason.message : String(r.reason),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -211,7 +217,7 @@ export default function DownloadDlcModal({
|
|||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-1 w-full text-center uppercase">
|
||||
Download DLC
|
||||
{t("modals.downloadDlc.title")}
|
||||
</h2>
|
||||
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
|
||||
{editionName}
|
||||
|
|
@ -219,7 +225,7 @@ export default function DownloadDlcModal({
|
|||
|
||||
{loading && (
|
||||
<div className="text-white text-sm mc-text-shadow mb-4 py-8">
|
||||
Loading available DLCs...
|
||||
{t("modals.downloadDlc.loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -231,7 +237,7 @@ export default function DownloadDlcModal({
|
|||
|
||||
{!loading && !error && dlcList.length === 0 && !downloading && (
|
||||
<div className="text-white text-sm mc-text-shadow mb-4 py-8">
|
||||
No DLC folders found in the repository.
|
||||
{t("modals.downloadDlc.noneFound")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -239,7 +245,7 @@ export default function DownloadDlcModal({
|
|||
<>
|
||||
<div className="flex items-center justify-between w-full mb-2 gap-2">
|
||||
<span className="text-white text-[10px] mc-text-shadow uppercase tracking-widest">
|
||||
{dlcList.length} available
|
||||
{t("modals.downloadDlc.available", { count: dlcList.length })}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
|
|
@ -247,14 +253,14 @@ export default function DownloadDlcModal({
|
|||
disabled={downloading}
|
||||
className="text-[9px] px-1.5 py-0.5 border border-[#555] text-white bg-black/20 hover:border-[#FFFF55] hover:text-[#FFFF55] mc-text-shadow uppercase tracking-wider transition-colors disabled:opacity-40"
|
||||
>
|
||||
All
|
||||
{t("modals.downloadDlc.all")}
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
disabled={downloading}
|
||||
className="text-[9px] px-1.5 py-0.5 border border-[#555] text-white bg-black/20 hover:border-[#FFFF55] hover:text-[#FFFF55] mc-text-shadow uppercase tracking-wider transition-colors disabled:opacity-40"
|
||||
>
|
||||
None
|
||||
{t("modals.downloadDlc.none")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -327,7 +333,10 @@ export default function DownloadDlcModal({
|
|||
<div className="flex items-center justify-center gap-4 mt-4">
|
||||
{downloading && (
|
||||
<div className="text-[#FFFF55] text-sm mc-text-shadow">
|
||||
Downloaded {downloaded.length}/{selectedCount}
|
||||
{t("modals.downloadDlc.downloaded", {
|
||||
downloaded: downloaded.length,
|
||||
selected: selectedCount,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -343,7 +352,7 @@ export default function DownloadDlcModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Download ({selectedCount})
|
||||
{t("modals.downloadDlc.download", { count: selectedCount })}
|
||||
</button>
|
||||
)}
|
||||
{downloading && downloaded.length === selectedCount && (
|
||||
|
|
@ -359,7 +368,7 @@ export default function DownloadDlcModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t("modals.downloadDlc.done")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -375,7 +384,7 @@ export default function DownloadDlcModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t("modals.downloadDlc.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
const MAX_DISPLAY_LINES = 5000;
|
||||
|
|
@ -62,6 +63,7 @@ export default function GameLogModal({
|
|||
onClose: () => void;
|
||||
playBackSound: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const lines = useMemo(() => (log ?? "").split("\n"), [log]);
|
||||
|
|
@ -92,16 +94,16 @@ export default function GameLogModal({
|
|||
if (!log) return;
|
||||
try {
|
||||
const path = await TauriService.saveFileDialog(
|
||||
"Save Game Log",
|
||||
t("modals.gameLog.saveDialog"),
|
||||
"game-log.txt",
|
||||
["txt", "log"],
|
||||
);
|
||||
if (!path) return;
|
||||
await TauriService.writeBinaryFile(path, new TextEncoder().encode(log));
|
||||
setFeedback("Saved!");
|
||||
setFeedback(t("modals.gameLog.saved"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setFeedback("Failed to save.");
|
||||
setFeedback(t("modals.gameLog.failedToSave"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -109,10 +111,10 @@ export default function GameLogModal({
|
|||
if (!log) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(log);
|
||||
setFeedback("Copied to clipboard!");
|
||||
setFeedback(t("modals.gameLog.copied"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setFeedback("Failed to copy.");
|
||||
setFeedback(t("modals.gameLog.failedToCopy"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -133,15 +135,18 @@ export default function GameLogModal({
|
|||
exit={{ y: 20, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex flex-col w-[640px] max-w-[92vw] max-h-[85vh] p-5 font-['Mojangles'] mc-options-bg"
|
||||
className="flex flex-col w-[640px] max-w-[92vw] max-h-[85vh] p-5 font-[var(--font-base)] mc-options-bg"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-[#333333] mb-3 text-left w-full px-2 mc-text-shadow">
|
||||
Game Crash Log
|
||||
{t("modals.gameLog.title")}
|
||||
</h3>
|
||||
|
||||
{truncated && (
|
||||
<p className="text-[#666666] text-sm mb-3 text-left w-full px-2">
|
||||
Showing last {MAX_DISPLAY_LINES} lines of {lines.length}.
|
||||
{t("modals.gameLog.showingLast", {
|
||||
shown: MAX_DISPLAY_LINES,
|
||||
total: lines.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -161,10 +166,10 @@ export default function GameLogModal({
|
|||
|
||||
<div className="flex gap-3 w-full px-2 mb-2">
|
||||
<LogActionButton onClick={handleSave} className="flex-1">
|
||||
Save As File
|
||||
{t("modals.gameLog.saveAsFile")}
|
||||
</LogActionButton>
|
||||
<LogActionButton onClick={handleCopy} className="flex-1">
|
||||
Copy
|
||||
{t("modals.gameLog.copy")}
|
||||
</LogActionButton>
|
||||
<LogActionButton
|
||||
onClick={() => {
|
||||
|
|
@ -173,7 +178,7 @@ export default function GameLogModal({
|
|||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Close
|
||||
{t("modals.gameLog.close")}
|
||||
</LogActionButton>
|
||||
</div>
|
||||
{feedback && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
export default function ImportWorldModal({
|
||||
|
|
@ -16,6 +17,7 @@ export default function ImportWorldModal({
|
|||
targetInstanceId: string;
|
||||
targetInstanceName: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
|
@ -32,9 +34,9 @@ export default function ImportWorldModal({
|
|||
playPressSound();
|
||||
setIsImporting(true);
|
||||
setError("");
|
||||
setStatus("Selecting source...");
|
||||
setStatus(t("modals.importWorld.selectingSource"));
|
||||
try {
|
||||
setStatus("Selecting saveData.ms file...");
|
||||
setStatus(t("modals.importWorld.selectingMsFile"));
|
||||
const picked = await TauriService.pickFile("Select saveData.ms", [
|
||||
"*.ms",
|
||||
"*",
|
||||
|
|
@ -44,13 +46,15 @@ export default function ImportWorldModal({
|
|||
return;
|
||||
}
|
||||
const worldName = deriveWorldName(picked);
|
||||
setStatus("Copying LCE save...");
|
||||
setStatus(t("modals.importWorld.copyingLceSave"));
|
||||
|
||||
const instancePath = await TauriService.getInstancePath(targetInstanceId);
|
||||
const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`;
|
||||
await TauriService.importWorld(picked, `${saveDir}/saveData.ms`);
|
||||
|
||||
setStatus(`World imported into "${targetInstanceName}"!`);
|
||||
setStatus(
|
||||
t("modals.importWorld.worldImported", { name: targetInstanceName }),
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
|
|
@ -66,9 +70,9 @@ export default function ImportWorldModal({
|
|||
playPressSound();
|
||||
setIsImporting(true);
|
||||
setError("");
|
||||
setStatus("Selecting source...");
|
||||
setStatus(t("modals.importWorld.selectingSource"));
|
||||
try {
|
||||
setStatus("Selecting Xbox 360 save (.bin)...");
|
||||
setStatus(t("modals.importWorld.selectingXboxSave"));
|
||||
const picked = await TauriService.pickFile(
|
||||
"Select Xbox 360 Minecraft save",
|
||||
["*.bin", "*"],
|
||||
|
|
@ -77,11 +81,13 @@ export default function ImportWorldModal({
|
|||
setIsImporting(false);
|
||||
return;
|
||||
}
|
||||
setStatus("Converting Xbox 360 save...");
|
||||
setStatus(t("modals.importWorld.convertingXbox"));
|
||||
const instancePath = await TauriService.getInstancePath(targetInstanceId);
|
||||
const gameHdd = `${instancePath}/Windows64/GameHDD`;
|
||||
await TauriService.importLceSave(picked, gameHdd);
|
||||
setStatus(`Xbox 360 save converted into "${targetInstanceName}"!`);
|
||||
setStatus(
|
||||
t("modals.importWorld.xboxConverted", { name: targetInstanceName }),
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
|
|
@ -97,19 +103,21 @@ export default function ImportWorldModal({
|
|||
playPressSound();
|
||||
setIsImporting(true);
|
||||
setError("");
|
||||
setStatus("Selecting source...");
|
||||
setStatus(t("modals.importWorld.selectingSource"));
|
||||
try {
|
||||
setStatus("Selecting PS3 save folder...");
|
||||
setStatus(t("modals.importWorld.selectingPs3Folder"));
|
||||
const picked = await TauriService.pickFolder();
|
||||
if (!picked) {
|
||||
setIsImporting(false);
|
||||
return;
|
||||
}
|
||||
setStatus("Converting PS3 save...");
|
||||
setStatus(t("modals.importWorld.convertingPs3"));
|
||||
const instancePath = await TauriService.getInstancePath(targetInstanceId);
|
||||
const gameHdd = `${instancePath}/Windows64/GameHDD`;
|
||||
await TauriService.importLceSave(picked, gameHdd);
|
||||
setStatus(`PS3 save converted into "${targetInstanceName}"!`);
|
||||
setStatus(
|
||||
t("modals.importWorld.ps3Converted", { name: targetInstanceName }),
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
|
|
@ -125,20 +133,22 @@ export default function ImportWorldModal({
|
|||
playPressSound();
|
||||
setIsImporting(true);
|
||||
setError("");
|
||||
setStatus("Selecting source...");
|
||||
setStatus(t("modals.importWorld.selectingSource"));
|
||||
try {
|
||||
setStatus("Selecting Java world folder...");
|
||||
setStatus(t("modals.importWorld.selectingJavaFolder"));
|
||||
const picked = await TauriService.pickFolder();
|
||||
if (!picked) {
|
||||
setIsImporting(false);
|
||||
return;
|
||||
}
|
||||
const worldName = deriveWorldName(picked);
|
||||
setStatus("Converting Java world to LCE...");
|
||||
setStatus(t("modals.importWorld.convertingJava"));
|
||||
const instancePath = await TauriService.getInstancePath(targetInstanceId);
|
||||
const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`;
|
||||
await TauriService.javaToLce(picked, `${saveDir}/saveData.ms`);
|
||||
setStatus(`Java world converted into "${targetInstanceName}"!`);
|
||||
setStatus(
|
||||
t("modals.importWorld.javaConverted", { name: targetInstanceName }),
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
|
|
@ -154,9 +164,9 @@ export default function ImportWorldModal({
|
|||
playPressSound();
|
||||
setIsImporting(true);
|
||||
setError("");
|
||||
setStatus("Selecting source...");
|
||||
setStatus(t("modals.importWorld.selectingSource"));
|
||||
try {
|
||||
setStatus("Selecting saveData.ms file...");
|
||||
setStatus(t("modals.importWorld.selectingMsFile"));
|
||||
const picked = await TauriService.pickFile("Select saveData.ms", [
|
||||
"*.ms",
|
||||
"*",
|
||||
|
|
@ -165,15 +175,17 @@ export default function ImportWorldModal({
|
|||
setIsImporting(false);
|
||||
return;
|
||||
}
|
||||
setStatus("Selecting output folder for Java world...");
|
||||
setStatus(t("modals.importWorld.selectingOutputFolder"));
|
||||
const outputFolder = await TauriService.pickFolder();
|
||||
if (!outputFolder) {
|
||||
setIsImporting(false);
|
||||
return;
|
||||
}
|
||||
setStatus("Converting LCE save to Java world...");
|
||||
setStatus(t("modals.importWorld.convertingLceToJava"));
|
||||
await TauriService.lceToJava(picked, outputFolder);
|
||||
setStatus(`Java world exported to "${outputFolder}"!`);
|
||||
setStatus(
|
||||
t("modals.importWorld.javaExported", { path: outputFolder }),
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useMemo, useRef, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import {
|
||||
parseSchema,
|
||||
|
|
@ -33,6 +34,7 @@ export default function OptionsModal({
|
|||
args: string[],
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [schema, setSchema] = useState<ArgsSchema | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -56,13 +58,13 @@ export default function OptionsModal({
|
|||
.then((raw) => {
|
||||
if (cancelled) return;
|
||||
if (!raw) {
|
||||
setError("This instance does not provide a launch options schema.");
|
||||
setError(t("modals.options.noSchema"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const parsed = parseSchema(raw);
|
||||
if (!parsed) {
|
||||
setError("The launch options schema is invalid or unsupported.");
|
||||
setError(t("modals.options.invalidSchema"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -253,6 +255,7 @@ export default function OptionsModal({
|
|||
case "int":
|
||||
case "number":
|
||||
return (
|
||||
<div className="mc-textinput-outer w-24">
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
|
|
@ -271,14 +274,16 @@ export default function OptionsModal({
|
|||
}));
|
||||
}}
|
||||
onFocus={() => setFocusIndex(index)}
|
||||
className={`w-24 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none text-center font-['Mojangles'] focus:border-[#FFFF55] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
|
||||
className={`mc-textinput w-full h-10 px-3 text-white text-sm outline-none text-center font-[var(--font-base)] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
|
||||
disabled ? "opacity-40 cursor-not-allowed" : ""
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "string":
|
||||
return (
|
||||
<div className="mc-textinput-outer w-44">
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
|
|
@ -291,11 +296,12 @@ export default function OptionsModal({
|
|||
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
|
||||
}}
|
||||
onFocus={() => setFocusIndex(index)}
|
||||
className={`w-44 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
|
||||
className={`mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)] ${
|
||||
disabled ? "opacity-40 cursor-not-allowed" : ""
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "choice":
|
||||
return (
|
||||
|
|
@ -309,7 +315,7 @@ export default function OptionsModal({
|
|||
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
|
||||
}}
|
||||
onFocus={() => setFocusIndex(index)}
|
||||
className={`w-44 h-8 bg-white border-2 border-[#373737] text-black text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
|
||||
className={`w-44 h-8 bg-white border-2 border-[#373737] text-black text-sm px-2 outline-none font-[var(--font-base)] focus:border-[#FFFF55] ${
|
||||
disabled ? "opacity-40 cursor-not-allowed" : ""
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
|
|
@ -431,9 +437,9 @@ export default function OptionsModal({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative w-[620px] max-w-[95vw] max-h-[88vh] p-5 flex flex-col items-center font-['Mojangles'] mc-options-bg">
|
||||
<div className="relative w-[620px] max-w-[95vw] max-h-[88vh] p-5 flex flex-col items-center font-[var(--font-base)] mc-options-bg">
|
||||
<h2 className="text-xl text-black mc-text-shadow mb-1 text-center">
|
||||
Options
|
||||
{t("modals.options.title")}
|
||||
</h2>
|
||||
<p className="text-[#333333] text-sm mb-4 text-center truncate max-w-full">
|
||||
{instanceName}
|
||||
|
|
@ -443,7 +449,7 @@ export default function OptionsModal({
|
|||
<div className="flex flex-col items-center gap-4 py-10">
|
||||
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-black text-lg mc-text-shadow">
|
||||
Loading options...
|
||||
{t("modals.options.loading")}
|
||||
</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
|
|
@ -452,7 +458,7 @@ export default function OptionsModal({
|
|||
{error}
|
||||
</p>
|
||||
<div className="flex gap-4 mt-2 w-full">
|
||||
{actionButton(cancelRef, optionOrder.length + 1, "OK", () => {
|
||||
{actionButton(cancelRef, optionOrder.length + 1, t("modals.options.ok"), () => {
|
||||
playBackSound();
|
||||
onClose();
|
||||
})}
|
||||
|
|
@ -477,7 +483,7 @@ export default function OptionsModal({
|
|||
{sections.general.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h3 className="text-[#333333] mc-text-shadow uppercase tracking-widest text-sm px-3 pt-2 pb-1">
|
||||
General
|
||||
{t("modals.options.general")}
|
||||
</h3>
|
||||
{sections.general.map(renderOption)}
|
||||
</div>
|
||||
|
|
@ -488,18 +494,18 @@ export default function OptionsModal({
|
|||
{actionButton(
|
||||
resetRef,
|
||||
optionOrder.length,
|
||||
"Reset",
|
||||
t("modals.options.reset"),
|
||||
handleReset,
|
||||
true,
|
||||
)}
|
||||
{actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => {
|
||||
{actionButton(cancelRef, optionOrder.length + 1, t("modals.options.cancel"), () => {
|
||||
playBackSound();
|
||||
onClose();
|
||||
})}
|
||||
{actionButton(
|
||||
saveRef,
|
||||
optionOrder.length + 2,
|
||||
"Save",
|
||||
t("modals.options.save"),
|
||||
handleSave,
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
TauriService,
|
||||
|
|
@ -75,6 +76,7 @@ export default function PlaytimeModal({
|
|||
instanceName: string;
|
||||
playBackSound: (s?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [playtime, setPlaytime] = useState<PlaytimeResponse | null>(null);
|
||||
const [dailyData, setDailyData] = useState<PlaytimeDayEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -127,7 +129,7 @@ export default function PlaytimeModal({
|
|||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Playtime
|
||||
{t("modals.playtime.title")}
|
||||
</h2>
|
||||
|
||||
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
|
||||
|
|
@ -136,14 +138,14 @@ export default function PlaytimeModal({
|
|||
|
||||
{loading ? (
|
||||
<div className="text-gray-400 text-sm mc-text-shadow mb-4">
|
||||
Loading...
|
||||
{t("modals.playtime.loading")}
|
||||
</div>
|
||||
) : playtime ? (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-2 mb-4">
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
Total
|
||||
{t("modals.playtime.total")}
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.totalSeconds)}
|
||||
|
|
@ -151,7 +153,7 @@ export default function PlaytimeModal({
|
|||
</div>
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
This Week
|
||||
{t("modals.playtime.thisWeek")}
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.weekSeconds)}
|
||||
|
|
@ -159,7 +161,7 @@ export default function PlaytimeModal({
|
|||
</div>
|
||||
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
|
||||
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
|
||||
Today
|
||||
{t("modals.playtime.today")}
|
||||
</span>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold">
|
||||
{formatTime(playtime.daySeconds)}
|
||||
|
|
@ -171,7 +173,7 @@ export default function PlaytimeModal({
|
|||
<div className="w-full mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[#888] text-[10px] mc-text-shadow uppercase tracking-widest">
|
||||
Daily Breakdown
|
||||
{t("modals.playtime.dailyBreakdown")}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{[3, 7, 14].map((d) => (
|
||||
|
|
@ -197,7 +199,7 @@ export default function PlaytimeModal({
|
|||
</>
|
||||
) : (
|
||||
<div className="text-red-400 text-sm mc-text-shadow mb-4">
|
||||
Failed to load playtime data.
|
||||
{t("modals.playtime.failedToLoad")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -213,7 +215,7 @@ export default function PlaytimeModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t("modals.playtime.close")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import type { Edition } from "../../types/edition";
|
||||
|
|
@ -20,6 +21,7 @@ export default function SetUidModal({
|
|||
installedVersions: string[];
|
||||
targetInstanceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<"manual" | "copy">("manual");
|
||||
const [uid, setUid] = useState("0xFF02F0C87E8AC1F2");
|
||||
const [selectedInstance, setSelectedInstance] = useState("");
|
||||
|
|
@ -60,7 +62,7 @@ export default function SetUidModal({
|
|||
let finalUid = uid;
|
||||
if (mode === "copy") {
|
||||
if (!selectedInstance) {
|
||||
setError("Select an instance to copy from.");
|
||||
setError(t("modals.setUid.selectToCopy"));
|
||||
return;
|
||||
}
|
||||
const sourcePath = await TauriService.getInstancePath(selectedInstance);
|
||||
|
|
@ -68,13 +70,13 @@ export default function SetUidModal({
|
|||
const sourceData = await TauriService.readBinaryFile(`${sourcePath}/uid.dat`);
|
||||
finalUid = new TextDecoder().decode(sourceData);
|
||||
} catch (e) {
|
||||
setError("Source instance has no uid.dat or it could not be read.");
|
||||
setError(t("modals.setUid.sourceNoUid"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalUid) {
|
||||
setError("UID cannot be empty.");
|
||||
setError(t("modals.setUid.uidEmpty"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ export default function SetUidModal({
|
|||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev - 1 + 5) % 5);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex === 0) {
|
||||
playPressSound();
|
||||
setMode("manual");
|
||||
|
|
@ -140,7 +143,7 @@ export default function SetUidModal({
|
|||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Set UID
|
||||
{t("modals.setUid.title")}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-4 mb-5 w-full justify-center">
|
||||
|
|
@ -156,7 +159,7 @@ export default function SetUidModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Manual
|
||||
{t("modals.setUid.manual")}
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
|
|
@ -170,7 +173,7 @@ export default function SetUidModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
{t("modals.setUid.copy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -178,8 +181,9 @@ export default function SetUidModal({
|
|||
{mode === "manual" ? (
|
||||
<div className="flex flex-col gap-1 items-center w-full">
|
||||
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
|
||||
Enter UID
|
||||
{t("modals.setUid.enterUid")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer w-full">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
|
|
@ -187,30 +191,31 @@ export default function SetUidModal({
|
|||
onChange={(e) => setUid(e.target.value)}
|
||||
onFocus={() => setFocusIndex(2)}
|
||||
placeholder="0xFF02F0C87E8AC1F2"
|
||||
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} text-white text-base outline-none font-['Mojangles'] text-center`}
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] text-center"
|
||||
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 items-center w-full relative">
|
||||
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
|
||||
Select Installed Instance
|
||||
{t("modals.setUid.selectInstance")}
|
||||
</label>
|
||||
|
||||
<div
|
||||
onClick={() => { playPressSound(); setIsDropdownOpen(!isDropdownOpen); }}
|
||||
onFocus={() => { setFocusIndex(2); setIsDropdownOpen(false); }}
|
||||
tabIndex={0}
|
||||
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} flex items-center justify-between text-white text-base outline-none font-['Mojangles'] cursor-pointer`}
|
||||
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} flex items-center justify-between text-white text-base outline-none font-[var(--font-base)] cursor-pointer`}
|
||||
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedInstance
|
||||
? (() => {
|
||||
const i = validInstances.find((inst: Edition) => inst.instanceId === selectedInstance);
|
||||
return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : "-- Select an Instance --";
|
||||
return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : t("modals.setUid.selectPlaceholder");
|
||||
})()
|
||||
: "-- Select an Instance --"}
|
||||
: t("modals.setUid.selectPlaceholder")}
|
||||
</span>
|
||||
<span className="text-xs">▼</span>
|
||||
</div>
|
||||
|
|
@ -225,7 +230,7 @@ export default function SetUidModal({
|
|||
setSelectedInstance(i.instanceId);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="px-3 py-2 text-white text-sm cursor-pointer truncate font-['Mojangles']"
|
||||
className="px-3 py-2 text-white text-sm cursor-pointer truncate font-[var(--font-base)]"
|
||||
>
|
||||
{i.name} {i.selectedBranch ? `(${i.selectedBranch})` : ""}
|
||||
</div>
|
||||
|
|
@ -234,7 +239,7 @@ export default function SetUidModal({
|
|||
)}
|
||||
|
||||
{validInstances.length === 0 && (
|
||||
<p className="text-red-400 text-xs text-center mt-1">No other installed instances available.</p>
|
||||
<p className="text-red-400 text-xs text-center mt-1">{t("modals.setUid.noInstances")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -262,7 +267,7 @@ export default function SetUidModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("modals.setUid.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(4)}
|
||||
|
|
@ -276,7 +281,7 @@ export default function SetUidModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
{t("modals.setUid.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ArcService } from "../../services/ArcService";
|
||||
|
|
@ -6,6 +7,7 @@ import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc";
|
|||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
export const ArcEditorView: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -48,7 +50,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
|
||||
const handleFileLoad = async () => {
|
||||
try {
|
||||
const path = await TauriService.pickFile("Open ARC", ["arc"]);
|
||||
const path = await TauriService.pickFile(t("arcEditor.openArc"), ["arc"]);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
const bytes = await TauriService.readBinaryFile(path);
|
||||
|
|
@ -56,7 +58,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
parsed.name = path.split(/[\/\\]/).pop() || "archive.arc";
|
||||
setArc(parsed);
|
||||
setOpenedPath(path);
|
||||
|
||||
|
||||
const locEntry = parsed.entries.find(entry => entry.filename.toLowerCase() === "languages.loc");
|
||||
if (locEntry) {
|
||||
try {
|
||||
|
|
@ -70,11 +72,11 @@ export const ArcEditorView: React.FC = () => {
|
|||
setLoc(null);
|
||||
}
|
||||
setSelectedEntryIdx(null);
|
||||
showNotification(`Loaded ${parsed.name}`);
|
||||
showNotification(t("arcEditor.loaded", { name: parsed.name }));
|
||||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") {
|
||||
console.error("Failed to parse ARC", err);
|
||||
showNotification("Failed to parse ARC", "error");
|
||||
showNotification(t("arcEditor.failedToParse"), "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -88,29 +90,29 @@ export const ArcEditorView: React.FC = () => {
|
|||
try {
|
||||
let targetPath = openedPath;
|
||||
if (!targetPath) {
|
||||
targetPath = await TauriService.saveFileDialog("Save ARC", arc.name || "archive.arc", ["arc"]);
|
||||
targetPath = await TauriService.saveFileDialog(t("arcEditor.saveArcDialog"), arc.name || "archive.arc", ["arc"]);
|
||||
}
|
||||
|
||||
|
||||
if (targetPath) {
|
||||
await TauriService.writeBinaryFile(targetPath, data);
|
||||
setOpenedPath(targetPath);
|
||||
showNotification("ARC Saved Successfully");
|
||||
showNotification(t("arcEditor.arcSaved"));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") showNotification("Save failed", "error");
|
||||
if (err !== "CANCELED") showNotification(t("arcEditor.saveFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtractEntry = async (entry: ArcEntry) => {
|
||||
try {
|
||||
const fileName = entry.filename.split("/").pop() || "asset";
|
||||
const path = await TauriService.saveFileDialog("Export Asset", fileName, []);
|
||||
const path = await TauriService.saveFileDialog(t("arcEditor.exportAssetDialog"), fileName, []);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
await TauriService.writeBinaryFile(path, entry.data);
|
||||
showNotification(`Extracted: ${entry.filename}`);
|
||||
showNotification(t("arcEditor.extracted", { name: entry.filename }));
|
||||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") showNotification("Extraction failed", "error");
|
||||
if (err !== "CANCELED") showNotification(t("arcEditor.extractionFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -122,7 +124,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
newEntries.splice(idx, 1);
|
||||
setArc({ ...arc, entries: newEntries });
|
||||
setSelectedEntryIdx(null);
|
||||
showNotification(`Deleted: ${name}`);
|
||||
showNotification(t("arcEditor.deleted", { name }));
|
||||
};
|
||||
|
||||
const handleSaveLocToArc = () => {
|
||||
|
|
@ -133,10 +135,10 @@ export const ArcEditorView: React.FC = () => {
|
|||
const newEntries = [...arc.entries];
|
||||
if (locIdx >= 0) {
|
||||
newEntries[locIdx] = { ...newEntries[locIdx], data, size: data.length };
|
||||
showNotification("languages.loc updated in archive");
|
||||
showNotification(t("arcEditor.locUpdatedInArchive"));
|
||||
} else {
|
||||
newEntries.push({ filename: "languages.loc", ptr: 0, size: data.length, isCompressed: false, data });
|
||||
showNotification("languages.loc added to archive");
|
||||
showNotification(t("arcEditor.locAddedToArchive"));
|
||||
}
|
||||
setArc({ ...arc, entries: newEntries });
|
||||
};
|
||||
|
|
@ -157,7 +159,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
};
|
||||
setArc({ ...arc, entries: [...arc.entries, newEntry] });
|
||||
e.target.value = "";
|
||||
showNotification("Entry Added");
|
||||
showNotification(t("arcEditor.entryAdded"));
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
|
|
@ -172,7 +174,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], data, size: data.length };
|
||||
setArc({ ...arc, entries: newEntries });
|
||||
e.target.value = "";
|
||||
showNotification("Entry Replaced");
|
||||
showNotification(t("arcEditor.entryReplaced"));
|
||||
setIsReplaceModalOpen(false);
|
||||
};
|
||||
|
||||
|
|
@ -183,7 +185,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], filename: newPath, isCompressed };
|
||||
setArc({ ...arc, entries: newEntries });
|
||||
setIsRenameModalOpen(false);
|
||||
showNotification("Entry Renamed");
|
||||
showNotification(t("arcEditor.entryRenamed"));
|
||||
};
|
||||
|
||||
const handleLocStringEdit = (langIdx: number, strIdx: number, isNew: boolean, key: string, value: string) => {
|
||||
|
|
@ -199,7 +201,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
}
|
||||
setLoc(newLoc);
|
||||
setIsLocEditModalOpen(null);
|
||||
showNotification(isNew ? "String Added" : "String Updated");
|
||||
showNotification(isNew ? t("arcEditor.stringAdded") : t("arcEditor.stringUpdated"));
|
||||
};
|
||||
|
||||
const handleLocStringDelete = (langIdx: number, strIdx: number) => {
|
||||
|
|
@ -208,7 +210,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
const newLoc = { ...loc };
|
||||
newLoc.languages[langIdx].strings.splice(strIdx, 1);
|
||||
setLoc(newLoc);
|
||||
showNotification("String Deleted");
|
||||
showNotification(t("arcEditor.stringDeleted"));
|
||||
};
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
|
|
@ -254,7 +256,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
{node.filename.split("/").pop()}
|
||||
</span>
|
||||
{node.isCompressed && (
|
||||
<span className="ml-auto text-[8px] opacity-40 border border-white/20 px-1">ZLIB</span>
|
||||
<span className="ml-auto text-[8px] opacity-40 border border-white/20 px-1">{t("arcEditor.zlib")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -299,15 +301,15 @@ export const ArcEditorView: React.FC = () => {
|
|||
const baseFolder = await TauriService.pickFolder();
|
||||
if (!baseFolder) return;
|
||||
playPressSound();
|
||||
showNotification("Exporting all archive entries...");
|
||||
|
||||
showNotification(t("arcEditor.exportingAll"));
|
||||
|
||||
for (const entry of arc.entries) {
|
||||
const fileName = entry.filename.replace(/\//g, "_");
|
||||
await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, entry.data);
|
||||
}
|
||||
showNotification("All Entries Exported");
|
||||
showNotification(t("arcEditor.allEntriesExported"));
|
||||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
if (err !== "CANCELED") showNotification(t("arcEditor.exportFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -323,31 +325,31 @@ export const ArcEditorView: React.FC = () => {
|
|||
>
|
||||
<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">
|
||||
ARC Editor
|
||||
{t("arcEditor.title")}
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleFileLoad}
|
||||
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%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open ARC
|
||||
{t("arcEditor.openArc")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
disabled={!arc || arc.entries.length === 0}
|
||||
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${(!arc || arc.entries.length === 0) ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Export All
|
||||
{t("arcEditor.exportAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveArc}
|
||||
disabled={!arc}
|
||||
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${!arc ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save ARC
|
||||
{t("arcEditor.saveArc")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -359,7 +361,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
<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/arc.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 an ARC file to begin editing</h3>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">{t("arcEditor.openArcFile")}</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" }}>
|
||||
|
|
@ -369,14 +371,14 @@ export const ArcEditorView: React.FC = () => {
|
|||
className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "arc" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`}
|
||||
>
|
||||
<img src="/images/tools/arc.png" className={`w-5 h-5 object-contain ${activeTab === "arc" ? "" : "grayscale opacity-50"}`} style={{ imageRendering: "pixelated" }} />
|
||||
<span className="text-lg uppercase tracking-wider font-bold">Archive</span>
|
||||
<span className="text-lg uppercase tracking-wider font-bold">{t("arcEditor.archive")}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("loc"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "loc" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`}
|
||||
>
|
||||
<img src="/images/tools/loc.png" className={`w-5 h-5 object-contain ${activeTab === "loc" ? "" : "grayscale opacity-50"}`} style={{ imageRendering: "pixelated" }} />
|
||||
<span className="text-lg uppercase tracking-wider font-bold">Languages (LOC)</span>
|
||||
<span className="text-lg uppercase tracking-wider font-bold">{t("arcEditor.languagesLoc")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -385,19 +387,21 @@ export const ArcEditorView: React.FC = () => {
|
|||
<div className="flex h-full overflow-hidden">
|
||||
<div className="w-2/3 flex flex-col p-4 border-r-2 border-black/20">
|
||||
<div className="mb-4 flex gap-4">
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search entries..."
|
||||
placeholder={t("arcEditor.searchEntries")}
|
||||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add Entry
|
||||
{t("arcEditor.addEntry")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
|
|
@ -408,17 +412,17 @@ export const ArcEditorView: React.FC = () => {
|
|||
{selectedEntry ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="p-4 bg-black/40 border-2 border-[#373737]">
|
||||
<h4 className="text-[#FFFF55] mc-text-shadow font-bold text-sm uppercase tracking-widest mb-1">Entry Details</h4>
|
||||
<h4 className="text-[#FFFF55] mc-text-shadow font-bold text-sm uppercase tracking-widest mb-1">{t("arcEditor.entryDetails")}</h4>
|
||||
<p className="text-white text-xs break-all font-mono opacity-80">{selectedEntry.filename}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="bg-black/20 p-3">
|
||||
<span className="block text-[10px] text-white/40 uppercase tracking-tighter text-left">Size</span>
|
||||
<span className="block text-[10px] text-white/40 uppercase tracking-tighter text-left">{t("arcEditor.size")}</span>
|
||||
<span className="text-white text-sm">{(selectedEntry.size / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
<div className="bg-black/20 p-3">
|
||||
<span className="block text-[10px] text-white/40 uppercase tracking-tighter text-left">Format</span>
|
||||
<span className="text-white text-sm uppercase">{selectedEntry.isCompressed ? "Compressed" : "Raw"}</span>
|
||||
<span className="block text-[10px] text-white/40 uppercase tracking-tighter text-left">{t("arcEditor.format")}</span>
|
||||
<span className="text-white text-sm uppercase">{selectedEntry.isCompressed ? t("arcEditor.compressed") : t("arcEditor.raw")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -426,23 +430,23 @@ export const ArcEditorView: React.FC = () => {
|
|||
<button
|
||||
onClick={() => handleExtractEntry(selectedEntry)}
|
||||
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Export File
|
||||
{t("arcEditor.exportFile")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsReplaceModalOpen(true)}
|
||||
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Replace Data
|
||||
{t("arcEditor.replaceData")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsRenameModalOpen(true)}
|
||||
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Rename / Path
|
||||
{t("arcEditor.renamePath")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -450,12 +454,12 @@ export const ArcEditorView: React.FC = () => {
|
|||
const newEntries = [...arc.entries];
|
||||
newEntries[selectedEntryIdx!] = { ...selectedEntry, isCompressed: !selectedEntry.isCompressed };
|
||||
setArc({ ...arc, entries: newEntries });
|
||||
showNotification(`Compression ${!selectedEntry.isCompressed ? "Enabled" : "Disabled"}`);
|
||||
showNotification(!selectedEntry.isCompressed ? t("arcEditor.compressionEnabled") : t("arcEditor.compressionDisabled"));
|
||||
}}
|
||||
className={`w-full py-2 text-white mc-text-shadow text-sm transition-all ${selectedEntry.isCompressed ? "text-[#FFFF55]" : "opacity-60"}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
{selectedEntry.isCompressed ? "ZLIB Compressed" : "Uncompressed"}
|
||||
{selectedEntry.isCompressed ? t("arcEditor.zlibCompressed") : t("arcEditor.uncompressed")}
|
||||
</button>
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<button
|
||||
|
|
@ -463,7 +467,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
className="w-full py-2 text-red-500/80 mc-text-shadow text-sm hover:text-red-500"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Delete Entry
|
||||
{t("arcEditor.deleteEntry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -471,7 +475,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center opacity-20 italic">
|
||||
<img src="/images/tools/arc.png" className="w-16 h-16 mb-4 grayscale" style={{ imageRendering: "pixelated" }} />
|
||||
<p className="text-white">Select an entry to view details</p>
|
||||
<p className="text-white">{t("arcEditor.selectEntry")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -480,16 +484,16 @@ export const ArcEditorView: React.FC = () => {
|
|||
<div className="flex-1 flex flex-col p-4 overflow-hidden h-full">
|
||||
{!loc ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
|
||||
<h4 className="text-xl text-white/40 mc-text-shadow italic mb-4">No languages.loc found in archive</h4>
|
||||
<h4 className="text-xl text-white/40 mc-text-shadow italic mb-4">{t("arcEditor.noLocFound")}</h4>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoc({ version: 0, languages: [{ id: "en_US", version: 1, isStatic: false, langId: "en_US", strings: [] }] });
|
||||
showNotification("Created new locale structure");
|
||||
showNotification(t("arcEditor.createdNewLocaleStructure"));
|
||||
}}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Create languages.loc
|
||||
{t("arcEditor.createLoc")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -501,38 +505,40 @@ export const ArcEditorView: React.FC = () => {
|
|||
className="bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
>
|
||||
{loc.languages.map((lang, idx) => (
|
||||
<option key={idx} value={idx}>{lang.id} {lang.isStatic ? "[Static]" : "[Keyed]"}</option>
|
||||
<option key={idx} value={idx}>{lang.id} {lang.isStatic ? `[${t("arcEditor.static")}]` : `[${t("arcEditor.keyed")}]`}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search strings..."
|
||||
placeholder={t("arcEditor.searchStrings")}
|
||||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: -1, isNew: true })}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add String
|
||||
{t("arcEditor.addString")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveLocToArc}
|
||||
className="px-6 py-2 text-[#FFFF55] mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Write to ARC
|
||||
{t("arcEditor.writeToArc")}
|
||||
</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">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Key / Index</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Value</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Actions</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("arcEditor.keyOrIndex")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("arcEditor.value")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">{t("arcEditor.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -548,7 +554,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: str.originalIdx, isNew: false })}
|
||||
className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 uppercase"
|
||||
>
|
||||
Edit
|
||||
{t("arcEditor.edit")}
|
||||
</button>
|
||||
<button onClick={() => handleLocStringDelete(selectedLocLangIdx, str.originalIdx)} className="p-1 hover:text-red-500 opacity-60">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
|
|
@ -571,9 +577,9 @@ export const ArcEditorView: React.FC = () => {
|
|||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full shrink-0 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" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
{t("arcEditor.back")}
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
|
|
@ -594,17 +600,17 @@ export const ArcEditorView: React.FC = () => {
|
|||
{isAddModalOpen && (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6">Add File to Archive</h3>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6">{t("arcEditor.addFileToArchive")}</h3>
|
||||
<div className="flex flex-col gap-6">
|
||||
<button
|
||||
onClick={() => injectInputRef.current?.click()}
|
||||
className="w-full py-3 text-white mc-text-shadow"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Select Source File
|
||||
{t("arcEditor.selectSourceFile")}
|
||||
</button>
|
||||
<div className="flex justify-end gap-4 mt-4">
|
||||
<button onClick={() => setIsAddModalOpen(false)} className="px-6 py-2 text-white/60 hover:text-white transition-colors">Cancel</button>
|
||||
<button onClick={() => setIsAddModalOpen(false)} className="px-6 py-2 text-white/60 hover:text-white transition-colors">{t("arcEditor.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -614,7 +620,7 @@ export const ArcEditorView: React.FC = () => {
|
|||
{isReplaceModalOpen && selectedEntryIdx !== null && (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-4">Replace File Data</h3>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-4">{t("arcEditor.replaceFileData")}</h3>
|
||||
<p className="text-white/60 mb-6 truncate">{arc?.entries[selectedEntryIdx].filename}</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<button
|
||||
|
|
@ -622,10 +628,10 @@ export const ArcEditorView: React.FC = () => {
|
|||
className="w-full py-3 text-white mc-text-shadow"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Select New File
|
||||
{t("arcEditor.selectNewFile")}
|
||||
</button>
|
||||
<div className="flex justify-end gap-4 mt-4">
|
||||
<button onClick={() => setIsReplaceModalOpen(false)} className="px-6 py-2 text-white/60 hover:text-white transition-colors">Cancel</button>
|
||||
<button onClick={() => setIsReplaceModalOpen(false)} className="px-6 py-2 text-white/60 hover:text-white transition-colors">{t("arcEditor.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -653,34 +659,37 @@ export const ArcEditorView: React.FC = () => {
|
|||
}
|
||||
|
||||
function RenameModal({ initialName, initialCompressed, onClose, onConfirm }: { initialName: string, initialCompressed: boolean, onClose: () => void, onConfirm: (name: string, comp: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(initialName);
|
||||
const [comp, setComp] = useState(initialCompressed);
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">Rename Entry</h3>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">{t("arcEditor.renameEntry")}</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">New Archive Path</label>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">{t("arcEditor.newArchivePath")}</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<input type="checkbox" checked={comp} onChange={(e) => setComp(e.target.checked)} className="w-5 h-5 accent-[#FFFF55]" />
|
||||
<span className="text-white group-hover:text-[#FFFF55] transition-colors">Mark as compressed (zlib)</span>
|
||||
<span className="text-white group-hover:text-[#FFFF55] transition-colors">{t("arcEditor.markAsCompressed")}</span>
|
||||
</label>
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">Cancel</button>
|
||||
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">{t("arcEditor.cancel")}</button>
|
||||
<button
|
||||
onClick={() => onConfirm(name, comp)}
|
||||
className="px-8 py-2 text-white mc-text-shadow"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Rename
|
||||
{t("arcEditor.rename")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -690,28 +699,31 @@ function RenameModal({ initialName, initialCompressed, onClose, onConfirm }: { i
|
|||
}
|
||||
|
||||
function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: number, strIdx: number, isNew: boolean }, lang: LocLanguage, onClose: () => void, onConfirm: (langIdx: number, strIdx: number, isNew: boolean, key: string, val: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [key, setKey] = useState(!data.isNew ? (lang.strings[data.strIdx].key || "") : "");
|
||||
const [val, setVal] = useState(!data.isNew ? lang.strings[data.strIdx].value : "");
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">{data.isNew ? "Add" : "Edit"} String</h3>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">{data.isNew ? t("arcEditor.add") : t("arcEditor.edit")} {t("arcEditor.stringTitle")}</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{!lang.isStatic ? (
|
||||
<div>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">String Key</label>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">{t("arcEditor.stringKey")}</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors font-mono"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/40 italic mb-2">Static entry - Index: {data.isNew ? lang.strings.length : data.strIdx}</div>
|
||||
<div className="text-white/40 italic mb-2">{t("arcEditor.staticEntry", { index: data.isNew ? lang.strings.length : data.strIdx })}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">String Value</label>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">{t("arcEditor.stringValue")}</label>
|
||||
<textarea
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
|
|
@ -720,13 +732,13 @@ function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: num
|
|||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">Cancel</button>
|
||||
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">{t("arcEditor.cancel")}</button>
|
||||
<button
|
||||
onClick={() => onConfirm(data.langIdx, data.strIdx, data.isNew, key, val)}
|
||||
className="px-8 py-2 text-white mc-text-shadow"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
{data.isNew ? "Add" : "Save"}
|
||||
{data.isNew ? t("arcEditor.add") : t("arcEditor.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -734,4 +746,3 @@ function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: num
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ColService } from "../../services/ColService";
|
||||
|
|
@ -13,6 +14,7 @@ function hexToArgb(hex: string) {
|
|||
}
|
||||
|
||||
export default function ColEditorView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -46,10 +48,10 @@ export default function ColEditorView() {
|
|||
try {
|
||||
const parsedCol = ColService.readCOL(buffer);
|
||||
setCol(parsedCol);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
showNotification(t("colEditor.loaded", { name: file.name }));
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to parse COL", err);
|
||||
showNotification(err instanceof Error ? err.message : "Failed to parse COL", "error");
|
||||
showNotification(err instanceof Error ? err.message : t("colEditor.failedToParse"), "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
|
@ -66,10 +68,10 @@ export default function ColEditorView() {
|
|||
a.download = "colours.col";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("COL Saved Successfully");
|
||||
showNotification(t("colEditor.savedSuccessfully"));
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to save COL", err);
|
||||
showNotification(err instanceof Error ? err.message : "Failed to save COL", "error");
|
||||
showNotification(err instanceof Error ? err.message : t("colEditor.failedToSave"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -94,7 +96,7 @@ export default function ColEditorView() {
|
|||
...col,
|
||||
colors: [{ name: "NewColor", color: 0xFFFFFFFF }, ...col.colors]
|
||||
});
|
||||
showNotification("Color Added");
|
||||
showNotification(t("colEditor.colorAdded"));
|
||||
};
|
||||
|
||||
const handleAddWorldColor = () => {
|
||||
|
|
@ -104,7 +106,7 @@ export default function ColEditorView() {
|
|||
...col,
|
||||
worldColors: [{ name: "NewWorldColor", waterColor: 0xFFFFFFFF, underwaterColor: 0xFFFFFFFF, fogColor: 0xFFFFFFFF }, ...col.worldColors]
|
||||
});
|
||||
showNotification("World Color Added");
|
||||
showNotification(t("colEditor.worldColorAdded"));
|
||||
};
|
||||
|
||||
const handleDeleteColor = (idx: number) => {
|
||||
|
|
@ -134,24 +136,24 @@ export default function ColEditorView() {
|
|||
<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>}
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">{t("colEditor.title")}</h2>
|
||||
{col && <span className="text-white/40 mc-text-shadow italic">{t("colEditor.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%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open COL
|
||||
{t("colEditor.openCol")}
|
||||
</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%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save COL
|
||||
{t("colEditor.saveCol")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -159,7 +161,7 @@ export default function ColEditorView() {
|
|||
{!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>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">{t("colEditor.openToBegin")}</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" }}>
|
||||
|
|
@ -168,32 +170,34 @@ export default function ColEditorView() {
|
|||
onClick={() => { playPressSound(); setActiveTab("colors"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "colors" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`}
|
||||
>
|
||||
<span className="text-lg">Colors ({col.colors.length})</span>
|
||||
<span className="text-lg">{t("colEditor.colors", { count: col.colors.length })}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("worldColors"); }}
|
||||
disabled={col.version === 0}
|
||||
className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "worldColors" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"} ${col.version === 0 ? "opacity-20 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span className="text-lg">World Colors ({col.worldColors.length})</span>
|
||||
<span className="text-lg">{t("colEditor.worldColors", { count: col.worldColors.length })}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<div className="mb-4 flex gap-4">
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search colors..."
|
||||
placeholder={t("colEditor.searchColors")}
|
||||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
<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%" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add {activeTab === "colors" ? "Color" : "World Color"}
|
||||
{activeTab === "colors" ? t("colEditor.addColor") : t("colEditor.addWorldColor")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -201,28 +205,30 @@ export default function ColEditorView() {
|
|||
<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>}
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold w-1/4">{t("colEditor.name")}</th>
|
||||
{activeTab === "colors" && <th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("colEditor.color")}</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">{t("colEditor.water")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("colEditor.underwater")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("colEditor.fog")}</th>
|
||||
</>
|
||||
)}
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right w-16">Remove</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right w-16">{t("colEditor.remove")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeTab === "colors" && currentColors.map((c) => (
|
||||
<tr key={c.originalIdx} className="border-b border-[#373737]/30 group">
|
||||
<td className="p-2">
|
||||
<div className="mc-textinput-outer">
|
||||
<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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -237,19 +243,21 @@ export default function ColEditorView() {
|
|||
}}
|
||||
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 className="mc-textinput-outer w-24">
|
||||
<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="mc-textinput w-full h-10 px-3 text-white font-mono text-sm uppercase outline-none font-[var(--font-base)]"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
|
|
@ -263,12 +271,14 @@ export default function ColEditorView() {
|
|||
{activeTab === "worldColors" && currentWorldColors.map((w) => (
|
||||
<tr key={w.originalIdx} className="border-b border-[#373737]/30 group">
|
||||
<td className="p-2">
|
||||
<div className="mc-textinput-outer">
|
||||
<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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -282,6 +292,7 @@ export default function ColEditorView() {
|
|||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<div className="mc-textinput-outer w-20">
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.waterColor)}
|
||||
|
|
@ -292,9 +303,10 @@ export default function ColEditorView() {
|
|||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white font-mono text-xs uppercase outline-none font-[var(--font-base)]"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
|
|
@ -309,6 +321,7 @@ export default function ColEditorView() {
|
|||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<div className="mc-textinput-outer w-20">
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.underwaterColor)}
|
||||
|
|
@ -319,9 +332,10 @@ export default function ColEditorView() {
|
|||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white font-mono text-xs uppercase outline-none font-[var(--font-base)]"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
|
|
@ -336,6 +350,7 @@ export default function ColEditorView() {
|
|||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<div className="mc-textinput-outer w-20">
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.fogColor)}
|
||||
|
|
@ -346,9 +361,10 @@ export default function ColEditorView() {
|
|||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white font-mono text-xs uppercase outline-none font-[var(--font-base)]"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
|
|
@ -369,9 +385,9 @@ export default function ColEditorView() {
|
|||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full shrink-0 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" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
{t("colEditor.back")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, memo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { useUI, useAudio } from "../../context/LauncherContext";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
|
|
@ -30,6 +31,7 @@ interface CreditCategory {
|
|||
}
|
||||
|
||||
const CreditsView = memo(function CreditsView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound } = useAudio();
|
||||
const { isAndroid } = usePlatform();
|
||||
|
|
@ -50,7 +52,7 @@ const CreditsView = memo(function CreditsView() {
|
|||
"raymanroy",
|
||||
"fin",
|
||||
"liugu",
|
||||
"Zrox2013 (Zameras2013)",
|
||||
"Zrox2013 (Zamers2013)",
|
||||
"kierwa",
|
||||
"cartox",
|
||||
"necmi",
|
||||
|
|
@ -254,6 +256,19 @@ const CreditsView = memo(function CreditsView() {
|
|||
category: "SPECIAL THANKS",
|
||||
icon: "",
|
||||
subcategories: [
|
||||
{
|
||||
name: "Emerald Translations",
|
||||
icon: "",
|
||||
roles: [
|
||||
{
|
||||
role: "",
|
||||
members: [
|
||||
{ name: "dydymiku (French)", url: "#" },
|
||||
{ name: "DimsMaybe/Hvrket (Russian)", url: "#" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isAndroid
|
||||
? [
|
||||
{
|
||||
|
|
@ -295,7 +310,10 @@ const CreditsView = memo(function CreditsView() {
|
|||
roles: [
|
||||
{
|
||||
role: "",
|
||||
members: [{ name: "faisal508508", url: "#" }],
|
||||
members: [
|
||||
{ name: "faisal508508", url: "#" },
|
||||
{ name: "HingedxHooligan", url: "#" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -328,14 +346,14 @@ const CreditsView = memo(function CreditsView() {
|
|||
className="fixed bottom-8 left-8 z-50 h-10 flex items-center justify-center gap-2 px-4 text-xl mc-text-shadow outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: isHovered
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
? "url('/images/Layout_Button_Over.png')"
|
||||
: "url('/images/Layout_Button_Bmp.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
color: isHovered ? "#FFFF55" : "white",
|
||||
}}
|
||||
>
|
||||
Back to Menu
|
||||
{t("credits.backToMenu")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
|
||||
|
|
@ -21,12 +22,35 @@ const DEV_TOOLS: DevTool[] = [
|
|||
];
|
||||
|
||||
export default function DevtoolsView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [focusIndex, setFocusIndex] = useState<number>(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const BACK_BUTTON_INDEX = DEV_TOOLS.length;
|
||||
const toolName = (id: string): string => {
|
||||
switch (id) {
|
||||
case "guides":
|
||||
return t("devtools.tools.guides");
|
||||
case "pck":
|
||||
return t("devtools.tools.pck");
|
||||
case "arc":
|
||||
return t("devtools.tools.arc");
|
||||
case "loc":
|
||||
return t("devtools.tools.loc");
|
||||
case "grf":
|
||||
return t("devtools.tools.grf");
|
||||
case "col":
|
||||
return t("devtools.tools.col");
|
||||
case "options":
|
||||
return t("devtools.tools.options");
|
||||
case "model":
|
||||
return t("devtools.tools.model");
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
|
|
@ -48,6 +72,7 @@ export default function DevtoolsView() {
|
|||
setFocusIndex(0);
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex === BACK_BUTTON_INDEX) {
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
|
|
@ -81,7 +106,7 @@ export default function DevtoolsView() {
|
|||
className="flex flex-col items-center w-full max-w-3xl h-full 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">
|
||||
Developer Tools
|
||||
{t("home.developerTools")}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
|
|
@ -109,7 +134,7 @@ export default function DevtoolsView() {
|
|||
<div className="w-20 h-20 bg-black/40 border-2 border-[#373737] flex items-center justify-center relative shadow-inner">
|
||||
<img
|
||||
src={`/images/tools/${tool.id}.png`}
|
||||
alt={tool.name}
|
||||
alt={toolName(tool.id)}
|
||||
className="w-12 h-12 object-contain opacity-50 grayscale"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => {
|
||||
|
|
@ -119,7 +144,7 @@ export default function DevtoolsView() {
|
|||
if (parent && !parent.querySelector(".tool-fallback")) {
|
||||
const fallback = document.createElement("span");
|
||||
fallback.className = "tool-fallback text-2xl text-white/30 mc-text-shadow uppercase font-bold";
|
||||
fallback.textContent = tool.name.charAt(0);
|
||||
fallback.textContent = toolName(tool.id).charAt(0);
|
||||
parent.appendChild(fallback);
|
||||
}
|
||||
}}
|
||||
|
|
@ -127,7 +152,7 @@ export default function DevtoolsView() {
|
|||
{tool.comingSoon && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<span className="text-[10px] text-[#FFFF55] mc-text-shadow uppercase tracking-tighter text-center px-1">
|
||||
Coming Soon
|
||||
{t("devtools.comingSoon")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -136,7 +161,7 @@ export default function DevtoolsView() {
|
|||
className={`text-center text-lg mc-text-shadow transition-colors ${focusIndex === i ? "text-[#FFFF55]" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{tool.name}
|
||||
{toolName(tool.id)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -155,13 +180,13 @@ export default function DevtoolsView() {
|
|||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === BACK_BUTTON_INDEX
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
? "url('/images/Layout_Button_Over.png')"
|
||||
: "url('/images/Layout_Button_Bmp.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
|||
571
src/components/views/GoldMapperView.tsx
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TauriService,
|
||||
type GoldMapperMapping,
|
||||
} from "../../services/TauriService";
|
||||
import { useUI, useConfig, useAudio } from "../../context/LauncherContext";
|
||||
const KEY_FALLBACK = [
|
||||
"KEY_A",
|
||||
"KEY_D",
|
||||
"KEY_S",
|
||||
"KEY_W",
|
||||
"KEY_SPACE",
|
||||
"KEY_RETURN",
|
||||
"KEY_ESCAPE",
|
||||
"KEY_LSHIFT",
|
||||
"KEY_LCTRL",
|
||||
];
|
||||
|
||||
const MOUSE_IDS = ["MOUSE_LEFT", "MOUSE_MIDDLE", "MOUSE_RIGHT"];
|
||||
const CONTROLLER_FALLBACK = [
|
||||
"PAD_A",
|
||||
"PAD_B",
|
||||
"PAD_X",
|
||||
"PAD_Y",
|
||||
"PAD_LB",
|
||||
"PAD_RB",
|
||||
"PAD_BACK",
|
||||
"PAD_START",
|
||||
"PAD_LTHUMB",
|
||||
"PAD_RTHUMB",
|
||||
"PAD_DPAD_UP",
|
||||
"PAD_DPAD_DOWN",
|
||||
"PAD_DPAD_LEFT",
|
||||
"PAD_DPAD_RIGHT",
|
||||
];
|
||||
|
||||
const DINPUT_ROWS: GoldMapperMapping[] = CONTROLLER_FALLBACK.map(
|
||||
(target, i) => ({ from: `DINPUT_${i}`, to: target }),
|
||||
);
|
||||
|
||||
const displayName = (id: string) =>
|
||||
id.replace(/^(KEY|PAD)_/, "").replace(/_/g, " ");
|
||||
|
||||
type Row =
|
||||
| { kind: "reset"; key: string }
|
||||
| { kind: "enable"; key: string }
|
||||
| { kind: "header"; key: string; label: string }
|
||||
| { kind: "bind"; key: string; id: string; label: string };
|
||||
|
||||
const stoneButtonStyle = (highlighted: boolean) => ({
|
||||
backgroundImage: highlighted
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated" as const,
|
||||
});
|
||||
|
||||
const GoldMapperView = memo(function GoldMapperView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { goldmapperEnabled, setGoldmapperEnabled } = useConfig();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const [keyboardIds, setKeyboardIds] = useState<string[]>(KEY_FALLBACK);
|
||||
const [controllerIds, setControllerIds] =
|
||||
useState<string[]>(CONTROLLER_FALLBACK);
|
||||
const [binds, setBinds] = useState<Record<string, string>>({});
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [modalFocusIndex, setModalFocusIndex] = useState(0);
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [keyInputError, setKeyInputError] = useState<string | null>(null);
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
TauriService.goldMapperGetDefaults()
|
||||
.then((defaults) => {
|
||||
setKeyboardIds(
|
||||
defaults.filter((m) => m.from.startsWith("KEY_")).map((m) => m.from),
|
||||
);
|
||||
setControllerIds(
|
||||
Array.from(
|
||||
new Set(
|
||||
defaults
|
||||
.filter((m) => m.from.startsWith("DINPUT_"))
|
||||
.map((m) => m.to),
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
TauriService.goldMapperLoadConfig()
|
||||
.then((rows) => {
|
||||
const loaded: Record<string, string> = {};
|
||||
for (const m of rows) {
|
||||
if (/^(KEY|MOUSE|PAD)_/.test(m.from)) {
|
||||
loaded[m.from] = m.to;
|
||||
}
|
||||
}
|
||||
setBinds(loaded);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const getTo = useCallback((id: string) => binds[id] ?? id, [binds]);
|
||||
const mouseLabel = (id: string) => {
|
||||
if (id === "MOUSE_LEFT") return t("goldMapper.leftClick");
|
||||
if (id === "MOUSE_MIDDLE") return t("goldMapper.middleClick");
|
||||
return t("goldMapper.rightClick");
|
||||
};
|
||||
const buildPayload = useCallback(
|
||||
(nextBinds: Record<string, string>): GoldMapperMapping[] => {
|
||||
const rows: GoldMapperMapping[] = [];
|
||||
for (const id of [...keyboardIds, ...MOUSE_IDS, ...controllerIds]) {
|
||||
const to = nextBinds[id] ?? id;
|
||||
if (to !== id) {
|
||||
rows.push({ from: id, to });
|
||||
}
|
||||
}
|
||||
rows.push(...DINPUT_ROWS);
|
||||
return rows;
|
||||
},
|
||||
[keyboardIds, controllerIds],
|
||||
);
|
||||
|
||||
const handleResetToDefaults = useCallback(() => {
|
||||
playPressSound();
|
||||
setBinds({});
|
||||
console.log("[GoldMapper] reset to defaults");
|
||||
TauriService.goldMapperResetConfig().catch(console.error);
|
||||
}, [playPressSound]);
|
||||
|
||||
const handleToggleEnabled = useCallback(() => {
|
||||
playPressSound();
|
||||
setGoldmapperEnabled(!goldmapperEnabled);
|
||||
}, [playPressSound, goldmapperEnabled, setGoldmapperEnabled]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
playBackSound();
|
||||
setActiveView("settings");
|
||||
}, [playBackSound, setActiveView]);
|
||||
|
||||
const openBind = useCallback(
|
||||
(id: string) => {
|
||||
playPressSound();
|
||||
setEditing(id);
|
||||
setModalFocusIndex(0);
|
||||
setKeyInput(/^KEY_/.test(getTo(id)) ? displayName(getTo(id)) : "");
|
||||
setKeyInputError(null);
|
||||
},
|
||||
[playPressSound, getTo],
|
||||
);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
playBackSound();
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
setEditing(null);
|
||||
}, [playBackSound]);
|
||||
|
||||
const pickTarget = useCallback(
|
||||
(sourceId: string, targetId: string) => {
|
||||
playPressSound();
|
||||
const next = { ...binds, [sourceId]: targetId };
|
||||
setBinds(next);
|
||||
const payload = buildPayload(next);
|
||||
console.log(
|
||||
"[GoldMapper] saving config:",
|
||||
JSON.stringify({ mappings: payload }),
|
||||
);
|
||||
TauriService.goldMapperSaveConfig(payload).catch(console.error);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
setEditing(null);
|
||||
},
|
||||
[playPressSound, binds, buildPayload],
|
||||
);
|
||||
|
||||
const submitKeyInput = useCallback(() => {
|
||||
if (editing === null) return;
|
||||
const norm = keyInput
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/^KEY_/, "");
|
||||
if (!norm || !keyboardIds.includes(`KEY_${norm}`)) {
|
||||
setKeyInputError(t("goldMapper.unknownKeyName"));
|
||||
return;
|
||||
}
|
||||
pickTarget(editing, `KEY_${norm}`);
|
||||
}, [editing, keyInput, keyboardIds, pickTarget, t]);
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
const list: Row[] = [{ kind: "reset", key: "reset" }];
|
||||
list.push({ kind: "enable", key: "enable" });
|
||||
list.push({
|
||||
kind: "header",
|
||||
key: "controller_header",
|
||||
label: t("goldMapper.controller"),
|
||||
});
|
||||
for (const id of controllerIds) {
|
||||
list.push({
|
||||
kind: "bind",
|
||||
key: `controller_${id}`,
|
||||
id,
|
||||
label: displayName(id),
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
kind: "header",
|
||||
key: "mouse_header",
|
||||
label: t("goldMapper.mouse"),
|
||||
});
|
||||
for (const id of MOUSE_IDS) {
|
||||
list.push({
|
||||
kind: "bind",
|
||||
key: `mouse_${id}`,
|
||||
id,
|
||||
label: mouseLabel(id),
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
kind: "header",
|
||||
key: "keyboard_header",
|
||||
label: t("goldMapper.keyboard"),
|
||||
});
|
||||
for (const id of keyboardIds) {
|
||||
list.push({
|
||||
kind: "bind",
|
||||
key: `keyboard_${id}`,
|
||||
id,
|
||||
label: displayName(id),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [controllerIds, keyboardIds, t, mouseLabel]);
|
||||
|
||||
const focusableCount = rows.filter((r) => r.kind !== "header").length;
|
||||
const modalItemCount = MOUSE_IDS.length + controllerIds.length + 2;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (editing !== null) {
|
||||
const activeTag = document.activeElement?.tagName;
|
||||
if (activeTag === "INPUT") {
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setModalFocusIndex((prev) => (prev + 1) % modalItemCount);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setModalFocusIndex(
|
||||
(prev) => (prev - 1 + modalItemCount) % modalItemCount,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (modalFocusIndex < MOUSE_IDS.length + controllerIds.length) {
|
||||
const allTargets = [...MOUSE_IDS, ...controllerIds];
|
||||
pickTarget(editing, allTargets[modalFocusIndex]);
|
||||
} else if (
|
||||
modalFocusIndex ===
|
||||
MOUSE_IDS.length + controllerIds.length
|
||||
) {
|
||||
submitKeyInput();
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
handleBack();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) =>
|
||||
prev === null || prev >= focusableCount - 1 ? 0 : prev + 1,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) =>
|
||||
prev === null || prev <= 0 ? focusableCount - 1 : prev - 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
focusableCount,
|
||||
handleBack,
|
||||
editing,
|
||||
closeModal,
|
||||
modalItemCount,
|
||||
modalFocusIndex,
|
||||
controllerIds.length,
|
||||
pickTarget,
|
||||
submitKeyInput,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIndex === null || editing !== null) return;
|
||||
const el = containerRef.current?.querySelector(
|
||||
`[data-focus-index="${focusIndex}"]`,
|
||||
) as HTMLElement | null;
|
||||
el?.focus();
|
||||
}, [focusIndex, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing === null) return;
|
||||
const el = document.querySelector(
|
||||
`[data-modal-index="${modalFocusIndex}"]`,
|
||||
) as HTMLElement | null;
|
||||
el?.focus();
|
||||
}, [modalFocusIndex, editing]);
|
||||
|
||||
let focusCounter = 0;
|
||||
const nextFocusIndex = () => {
|
||||
const idx = focusCounter;
|
||||
focusCounter += 1;
|
||||
return idx;
|
||||
};
|
||||
|
||||
const renderRow = (row: Row) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<div key={row.key} className="w-full px-1 pt-3 pb-1">
|
||||
<span className="text-sm text-black tracking-widest">
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isReset = row.kind === "reset";
|
||||
const isEnable = row.kind === "enable";
|
||||
const focusIdx = nextFocusIndex();
|
||||
const focused = focusIndex === focusIdx;
|
||||
return (
|
||||
<button
|
||||
key={row.key}
|
||||
data-focus-index={focusIdx}
|
||||
onFocus={() => setFocusIndex(focusIdx)}
|
||||
onMouseEnter={() => setFocusIndex(focusIdx)}
|
||||
onClick={
|
||||
isReset
|
||||
? handleResetToDefaults
|
||||
: isEnable
|
||||
? handleToggleEnabled
|
||||
: () => openBind(row.id)
|
||||
}
|
||||
className={`w-full h-10 flex items-center ${!isEnable ? "pl-6" : "pl-2"} pr-4 outline-none border-none shrink-0 transition-colors ${
|
||||
focused
|
||||
? "text-[#ffff00]"
|
||||
: isEnable
|
||||
? "text-[#333333]"
|
||||
: "text-white"
|
||||
} ${isReset ? "justify-center hover:text-[#ffff00]" : ""}`}
|
||||
style={isEnable ? undefined : stoneButtonStyle(focused)}
|
||||
>
|
||||
{isEnable && (
|
||||
<div className="relative w-6 h-6 mr-3 shrink-0 flex items-center justify-center">
|
||||
<img
|
||||
src={
|
||||
focused
|
||||
? "/images/checkbox_highlighted.png"
|
||||
: "/images/checkbox.png"
|
||||
}
|
||||
alt="checkbox"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{goldmapperEnabled && (
|
||||
<img
|
||||
src="/images/check.png"
|
||||
alt="checked"
|
||||
className="relative z-10 w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`tracking-widest text-lg truncate ${
|
||||
isReset ? "" : "flex-1 text-left"
|
||||
}`}
|
||||
>
|
||||
{isReset
|
||||
? t("goldMapper.resetToDefaults")
|
||||
: isEnable
|
||||
? t("goldMapper.enableGoldMapper")
|
||||
: row.label}
|
||||
</span>
|
||||
{!isReset && !isEnable && row.kind === "bind" && (
|
||||
<span className="tracking-widest text-base opacity-70 ml-3 shrink-0">
|
||||
{displayName(getTo(row.id))}
|
||||
</span>
|
||||
)}
|
||||
{isEnable && (
|
||||
<img
|
||||
src="/images/goldmapper.png"
|
||||
alt=""
|
||||
className="w-6 h-6 object-contain shrink-0 ml-3"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full max-w-5xl">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-[720px] max-w-[92vw] h-[560px] max-h-[62vh] p-4 flex flex-col gap-2 overflow-y-auto settings-scrollbar mc-options-bg"
|
||||
>
|
||||
{rows.map(renderRow)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(null)}
|
||||
onClick={handleBack}
|
||||
className="w-40 h-10 flex items-center justify-center transition-colors text-xl outline-none border-none hover:text-[#ffff00] mt-4 text-white"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{t("goldMapper.back")}
|
||||
</button>
|
||||
|
||||
{editing !== null && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 outline-none border-none"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
<div className="relative w-[620px] max-w-[95vw] h-[580px] max-h-[88vh] p-5 flex flex-col font-[var(--font-base)] mc-options-bg">
|
||||
<h2 className="text-xl text-black mb-4 text-center">
|
||||
{t("goldMapper.assign", { name: displayName(editing) })}
|
||||
</h2>
|
||||
|
||||
<div className="w-full flex-1 min-h-0 overflow-y-auto custom-scrollbar mb-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-[#333333] tracking-widest text-sm px-3 pt-2 pb-1">
|
||||
{t("goldMapper.mouse")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2 p-1 content-start">
|
||||
{MOUSE_IDS.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
data-modal-index={i}
|
||||
onFocus={() => setModalFocusIndex(i)}
|
||||
onMouseEnter={() => setModalFocusIndex(i)}
|
||||
onClick={() => pickTarget(editing, id)}
|
||||
className={`h-10 px-2 flex items-center justify-center text-sm tracking-widest outline-none border-none cursor-pointer transition-colors ${
|
||||
getTo(editing) === id ? "text-[#ffff00]" : "text-white"
|
||||
}`}
|
||||
style={stoneButtonStyle(modalFocusIndex === i)}
|
||||
>
|
||||
<span className="truncate">{mouseLabel(id)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h3 className="text-[#333333] tracking-widest text-sm px-3 pt-2 pb-1">
|
||||
{t("goldMapper.controller")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2 p-1 content-start">
|
||||
{controllerIds.map((id, i) => {
|
||||
const idx = MOUSE_IDS.length + i;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
data-modal-index={idx}
|
||||
onFocus={() => setModalFocusIndex(idx)}
|
||||
onMouseEnter={() => setModalFocusIndex(idx)}
|
||||
onClick={() => pickTarget(editing, id)}
|
||||
className={`h-10 px-2 flex items-center justify-center text-sm tracking-widest outline-none border-none cursor-pointer transition-colors ${
|
||||
getTo(editing) === id
|
||||
? "text-[#ffff00]"
|
||||
: "text-white"
|
||||
}`}
|
||||
style={stoneButtonStyle(modalFocusIndex === idx)}
|
||||
>
|
||||
<span className="truncate">{displayName(id)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h3 className="text-[#333333] tracking-widest text-sm px-3 pt-2 pb-1">
|
||||
{t("goldMapper.keyboard")}
|
||||
</h3>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
data-modal-index={MOUSE_IDS.length + controllerIds.length}
|
||||
value={keyInput}
|
||||
onChange={(e) => {
|
||||
setKeyInput(e.target.value);
|
||||
setKeyInputError(null);
|
||||
}}
|
||||
onFocus={() =>
|
||||
setModalFocusIndex(MOUSE_IDS.length + controllerIds.length)
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
setModalFocusIndex(MOUSE_IDS.length + controllerIds.length)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitKeyInput();
|
||||
}}
|
||||
placeholder={t("goldMapper.typeKeyName")}
|
||||
className={`mc-textinput w-full h-10 px-3 text-white text-base outline-none text-center font-[var(--font-base)] ${
|
||||
keyInputError ? "" : ""
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
{keyInputError && (
|
||||
<p className="text-red-600 text-xs mt-1 px-3">
|
||||
{keyInputError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
data-modal-index={MOUSE_IDS.length + controllerIds.length + 1}
|
||||
onFocus={() =>
|
||||
setModalFocusIndex(MOUSE_IDS.length + controllerIds.length + 1)
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
setModalFocusIndex(MOUSE_IDS.length + controllerIds.length + 1)
|
||||
}
|
||||
onClick={closeModal}
|
||||
className={`w-full h-12 flex items-center justify-center text-xl transition-colors outline-none border-none cursor-pointer ${
|
||||
modalFocusIndex === MOUSE_IDS.length + controllerIds.length + 1
|
||||
? "text-[#ffff00]"
|
||||
: "text-white"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
modalFocusIndex ===
|
||||
MOUSE_IDS.length + controllerIds.length + 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{t("goldMapper.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GoldMapperView;
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { GrfService } from "../../services/GrfService";
|
||||
import { GrfFile, GrfNode, GrfFileEntry } from "../../types/grf";
|
||||
export default function GrfEditorView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -27,10 +29,10 @@ export default function GrfEditorView() {
|
|||
const parsedGrf = GrfService.readGRF(buffer);
|
||||
setGrf(parsedGrf);
|
||||
setFilename(file.name);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
showNotification(t("grfEditor.loaded", { name: file.name }));
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to parse GRF", err);
|
||||
showNotification(err instanceof Error ? err.message : "Failed to parse GRF", "error");
|
||||
showNotification(err instanceof Error ? err.message : t("grfEditor.failedToParse"), "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
|
@ -39,7 +41,7 @@ export default function GrfEditorView() {
|
|||
playPressSound();
|
||||
setGrf(GrfService.createDefaultGRF());
|
||||
setFilename("new_rules.grf");
|
||||
showNotification("New GRF Created");
|
||||
showNotification(t("grfEditor.newGrfCreated"));
|
||||
};
|
||||
|
||||
const handleAddFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -55,7 +57,7 @@ export default function GrfEditorView() {
|
|||
...grf,
|
||||
files: [...grf.files, newFile]
|
||||
});
|
||||
showNotification(`Added ${file.name}`);
|
||||
showNotification(t("grfEditor.added", { name: file.name }));
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
|
|
@ -65,7 +67,7 @@ export default function GrfEditorView() {
|
|||
const newFiles = [...grf.files];
|
||||
const removed = newFiles.splice(index, 1)[0];
|
||||
setGrf({ ...grf, files: newFiles });
|
||||
showNotification(`Removed ${removed.filename}`);
|
||||
showNotification(t("grfEditor.removed", { name: removed.filename }));
|
||||
};
|
||||
|
||||
const handleUpdateParameter = (nodePath: string[], paramIndex: number, value: string) => {
|
||||
|
|
@ -101,10 +103,10 @@ export default function GrfEditorView() {
|
|||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("GRF Saved Successfully");
|
||||
showNotification(t("grfEditor.savedSuccessfully"));
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to save GRF", err);
|
||||
showNotification(err instanceof Error ? err.message : "Failed to save GRF", "error");
|
||||
showNotification(err instanceof Error ? err.message : t("grfEditor.failedToSave"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -120,8 +122,8 @@ export default function GrfEditorView() {
|
|||
<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>}
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">{t("grfEditor.title")}</h2>
|
||||
{grf && <span className="text-white/40 mc-text-shadow italic">{t("grfEditor.editing")} <span className="text-[#FFFF55]">{filename}</span></span>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
|
|
@ -129,14 +131,14 @@ export default function GrfEditorView() {
|
|||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
New GRF
|
||||
{t("grfEditor.newGrf")}
|
||||
</button>
|
||||
<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
|
||||
{t("grfEditor.openGrf")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveGrf}
|
||||
|
|
@ -144,7 +146,7 @@ export default function GrfEditorView() {
|
|||
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
|
||||
{t("grfEditor.saveGrf")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -152,7 +154,7 @@ export default function GrfEditorView() {
|
|||
{!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>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">{t("grfEditor.openToBegin")}</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" }}>
|
||||
|
|
@ -161,13 +163,13 @@ export default function GrfEditorView() {
|
|||
onClick={() => { playPressSound(); setActiveTab("rules"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "rules" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`}
|
||||
>
|
||||
<span className="text-lg">Game Rules</span>
|
||||
<span className="text-lg">{t("grfEditor.gameRules")}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("files"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "files" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`}
|
||||
>
|
||||
<span className="text-lg">Files ({grf.files.length})</span>
|
||||
<span className="text-lg">{t("grfEditor.files", { count: grf.files.length })}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -177,7 +179,7 @@ export default function GrfEditorView() {
|
|||
{grf.root.children.map((node, i) => (
|
||||
<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>}
|
||||
{grf.root.children.length === 0 && <span className="text-white/40 italic">{t("grfEditor.noRulesFound")}</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
|
|
@ -187,19 +189,19 @@ export default function GrfEditorView() {
|
|||
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
|
||||
{t("grfEditor.addFile")}
|
||||
</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>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("grfEditor.filename")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">{t("grfEditor.size")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-center">{t("grfEditor.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grf.files.length === 0 && (
|
||||
<tr><td colSpan={3} className="p-4 text-center text-white/40">No files in GRF</td></tr>
|
||||
<tr><td colSpan={3} className="p-4 text-center text-white/40">{t("grfEditor.noFilesInGrf")}</td></tr>
|
||||
)}
|
||||
{grf.files.map((f, i) => (
|
||||
<tr key={i} className="border-b border-[#373737]/30">
|
||||
|
|
@ -210,7 +212,7 @@ export default function GrfEditorView() {
|
|||
onClick={() => handleDeleteFile(i)}
|
||||
className="text-[#FF5555] hover:text-[#FF8888] transition-colors"
|
||||
>
|
||||
Delete
|
||||
{t("grfEditor.delete")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -227,9 +229,9 @@ export default function GrfEditorView() {
|
|||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full shrink-0 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" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
{t("grfEditor.back")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -253,6 +255,7 @@ export default function GrfEditorView() {
|
|||
}
|
||||
|
||||
function GrfNodeView({ node, level, path, onUpdate }: { node: GrfNode, level: number, path: string[], onUpdate: (path: string[], paramIdx: number, val: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(level < 1);
|
||||
const currentPath = [...path, node.name];
|
||||
|
||||
|
|
@ -279,7 +282,7 @@ function GrfNodeView({ node, level, path, onUpdate }: { node: GrfNode, level: nu
|
|||
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>}
|
||||
{node.parameters.length > 0 && <span className="text-white/40 text-xs ml-2">{t("grfEditor.props", { count: node.parameters.length })}</span>}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="flex flex-col border-l-2 border-[#373737] ml-2 pl-2">
|
||||
|
|
@ -288,12 +291,14 @@ function GrfNodeView({ node, level, path, onUpdate }: { node: GrfNode, level: nu
|
|||
{node.parameters.map((p, i) => (
|
||||
<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>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={p.value}
|
||||
onChange={(e) => onUpdate(currentPath, 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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
|
@ -16,6 +17,7 @@ interface GuideEntry {
|
|||
}
|
||||
|
||||
export default function GuidesView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -98,6 +100,7 @@ export default function GuidesView() {
|
|||
} else if (e.key === "ArrowUp") {
|
||||
setFocusIndex((p) => (p <= 0 ? BACK_BUTTON_INDEX : p - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex === BACK_BUTTON_INDEX) {
|
||||
goBack();
|
||||
} else {
|
||||
|
|
@ -130,7 +133,7 @@ export default function GuidesView() {
|
|||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-white mc-text-shadow text-lg">
|
||||
Loading guide...
|
||||
{t("guides.loadingGuide")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -145,7 +148,7 @@ export default function GuidesView() {
|
|||
}}
|
||||
className="text-[#FFFF55] mc-text-shadow text-sm hover:underline mb-2 block"
|
||||
>
|
||||
← Back to guides
|
||||
{t("guides.backToGuides")}
|
||||
</button>
|
||||
<h2 className="text-2xl text-white mc-text-shadow font-bold border-b border-[#373737] pb-2">
|
||||
{selectedGuide.name.replace(/\.md$/i, "")}
|
||||
|
|
@ -189,7 +192,7 @@ export default function GuidesView() {
|
|||
<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">
|
||||
{selectedGuide
|
||||
? selectedGuide.name.replace(/\.md$/i, "")
|
||||
: "Community Guides"}
|
||||
: t("guides.communityGuides")}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
|
|
@ -205,13 +208,13 @@ export default function GuidesView() {
|
|||
) : loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-white mc-text-shadow text-lg">
|
||||
Loading guides...
|
||||
{t("guides.loadingGuides")}
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<span className="text-red-400 mc-text-shadow text-lg">
|
||||
Failed to load guides
|
||||
{t("guides.failedToLoadGuides")}
|
||||
</span>
|
||||
<span className="text-[#AAAAAA] mc-text-shadow text-sm text-center px-8">
|
||||
{error}
|
||||
|
|
@ -220,7 +223,7 @@ export default function GuidesView() {
|
|||
) : guides.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<span className="text-[#AAAAAA] mc-text-shadow text-lg">
|
||||
No guides found
|
||||
{t("guides.noGuidesFound")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -271,13 +274,13 @@ export default function GuidesView() {
|
|||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === BACK_BUTTON_INDEX
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
? "url('/images/Layout_Button_Over.png')"
|
||||
: "url('/images/Layout_Button_Bmp.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
useUI,
|
||||
|
|
@ -11,8 +12,9 @@ import { usePlatform } from "../../hooks/usePlatform";
|
|||
import type { Edition } from "../../types/edition";
|
||||
|
||||
const HomeView = memo(function HomeView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView, focusSection, onNavigateToSkin } = useUI();
|
||||
const { profile, legacyMode } = useConfig();
|
||||
const { profile, legacyMode, animationsEnabled } = useConfig();
|
||||
const { playPressSound } = useAudio();
|
||||
const {
|
||||
handleLaunch,
|
||||
|
|
@ -39,14 +41,14 @@ const HomeView = memo(function HomeView() {
|
|||
const buttonsVal = useMemo(() => {
|
||||
const mainBtn = {
|
||||
label: !hasAnyInstall
|
||||
? "Install a version"
|
||||
? t("home.installVersion")
|
||||
: isGameRunning
|
||||
? "Stop Game"
|
||||
? t("home.stopGame")
|
||||
: isDownloading
|
||||
? "Installation in progress..."
|
||||
? t("home.installing")
|
||||
: isInstalled
|
||||
? "Play Game"
|
||||
: `Download ${selectedVersionName}`,
|
||||
? t("home.playGame")
|
||||
: t("home.downloadVersion", { name: selectedVersionName }),
|
||||
action: !hasAnyInstall
|
||||
? () => setActiveView("versions")
|
||||
: isGameRunning
|
||||
|
|
@ -71,28 +73,28 @@ const HomeView = memo(function HomeView() {
|
|||
|
||||
const menuBtns = [
|
||||
{
|
||||
label: "Help & Options",
|
||||
label: t("home.helpAndOptions"),
|
||||
action: () => setActiveView("settings"),
|
||||
isDanger: false,
|
||||
disabled: false,
|
||||
id: "settings",
|
||||
},
|
||||
{
|
||||
label: "Versions",
|
||||
label: t("home.versions"),
|
||||
action: () => setActiveView("versions"),
|
||||
isDanger: false,
|
||||
disabled: false,
|
||||
id: "versions",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
label: t("home.workshop"),
|
||||
action: () => setActiveView("workshop"),
|
||||
isDanger: false,
|
||||
disabled: false,
|
||||
id: "workshop",
|
||||
},
|
||||
{
|
||||
label: "Developer Tools",
|
||||
label: t("home.developerTools"),
|
||||
action: () => setActiveView("devtools"),
|
||||
isDanger: false,
|
||||
disabled: false,
|
||||
|
|
@ -102,6 +104,7 @@ const HomeView = memo(function HomeView() {
|
|||
|
||||
return [mainBtn, ...pluginBtns, ...menuBtns];
|
||||
}, [
|
||||
t,
|
||||
isDownloading,
|
||||
hasAnyInstall,
|
||||
isInstalled,
|
||||
|
|
@ -132,8 +135,10 @@ const HomeView = memo(function HomeView() {
|
|||
prev === null ? buttonsVal.length - 1 : prev > 0 ? prev - 1 : prev,
|
||||
);
|
||||
if (e.key === "ArrowLeft") onNavigateToSkin();
|
||||
if (e.key === "Enter" && menuFocus !== null)
|
||||
if (e.key === "Enter" && menuFocus !== null) {
|
||||
e.preventDefault();
|
||||
buttonsVal[menuFocus].action();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
|
|
@ -142,14 +147,14 @@ const HomeView = memo(function HomeView() {
|
|||
return (
|
||||
<motion.div
|
||||
tabIndex={-1}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: animationsEnabled ? 0 : 1, y: animationsEnabled ? 10 : 0 }}
|
||||
animate={{ opacity: isFocusedSection ? 1 : 0.5, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
|
||||
exit={{ opacity: animationsEnabled ? 0 : 1, y: animationsEnabled ? 10 : 0 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="relative w-full max-w-[540px] flex flex-col space-y-3 outline-none"
|
||||
>
|
||||
{buttonsVal.map((btn, i) => (
|
||||
<div key={i} className="relative w-full group">
|
||||
<div key={i} className="relative w-full group flex justify-center">
|
||||
<button
|
||||
onMouseEnter={() =>
|
||||
isFocusedSection && !btn.disabled && setMenuFocus(i)
|
||||
|
|
@ -162,7 +167,7 @@ const HomeView = memo(function HomeView() {
|
|||
}
|
||||
}}
|
||||
disabled={btn.disabled}
|
||||
className={`w-full h-12 flex items-center justify-between px-6 text-2xl mc-text-shadow transition-colors outline-none border-none ${btn.disabled ? "text-gray-400 cursor-not-allowed" : menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
|
||||
className={`w-[90%] h-11 flex items-center justify-between px-6 text-xl mc-text-shadow transition-colors outline-none border-none ${btn.disabled ? "text-gray-400 cursor-not-allowed" : menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: btn.disabled
|
||||
? "url('/images/Button_Background.png')"
|
||||
|
|
@ -183,8 +188,6 @@ const HomeView = memo(function HomeView() {
|
|||
className="absolute right-4 w-6 h-6 object-contain"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
filter:
|
||||
"drop-shadow(0 0 2px rgba(255, 255, 0, 0.8)) sepia(100%) saturate(500%) hue-rotate(5deg) brightness(1.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
useUI,
|
||||
|
|
@ -26,6 +27,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
onClearAddFriendTarget,
|
||||
invites: invitesProp,
|
||||
}: LceOnlineViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView, setIsUiHidden } = useUI();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
|
|
@ -41,7 +43,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
const [incomingReqs, setIncomingReqs] = useState<SocialEntry[]>([]);
|
||||
const [outgoingReqs, setOutgoingReqs] = useState<SocialEntry[]>([]);
|
||||
const invites = invitesProp ?? [];
|
||||
const [isHosting, setIsHosting] = useState(false);
|
||||
const [isHosting, setIsHosting] = useState(lceOnlineService.isHosting);
|
||||
const [isAddingFriend, setIsAddingFriend] = useState(false);
|
||||
const [addFriendUsername, setAddFriendUsername] = useState("");
|
||||
const addFriendInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -74,6 +76,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
useEffect(() => {
|
||||
return lceOnlineService.onSessionChange(() => {
|
||||
setIsSignedIn(lceOnlineService.signedIn);
|
||||
setIsHosting(lceOnlineService.isHosting);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
@ -137,9 +140,9 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
const token = lceOnlineService.accessToken ?? "";
|
||||
if (!token) return;
|
||||
TauriService.startHostRelay(token, 25565).catch(() => {});
|
||||
setIsHosting(true);
|
||||
lceOnlineService.isHosting = true;
|
||||
} catch (e: unknown) {
|
||||
setErrorModal(e instanceof Error ? e.message : "Failed to start hosting");
|
||||
setErrorModal(e instanceof Error ? e.message : t("lceOnline.failedToStartHosting"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -150,7 +153,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
} catch (e: unknown) {
|
||||
console.warn("Stop hosting failed", e);
|
||||
}
|
||||
setIsHosting(false);
|
||||
lceOnlineService.isHosting = false;
|
||||
};
|
||||
|
||||
const handleAction = async (action: () => Promise<void>) => {
|
||||
|
|
@ -159,7 +162,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
await action();
|
||||
fetchSocialData();
|
||||
} catch (e: unknown) {
|
||||
setErrorModal(e instanceof Error ? e.message : "An error occurred");
|
||||
setErrorModal(e instanceof Error ? e.message : t("lceOnline.anErrorOccurred"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -178,21 +181,21 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
items.push({
|
||||
id: "host_game",
|
||||
type: "button",
|
||||
label: "Host Game",
|
||||
label: t("lceOnline.hostGame"),
|
||||
onClick: handleStartHosting,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
id: "stop_hosting",
|
||||
type: "button",
|
||||
label: "Stop Hosting",
|
||||
label: t("lceOnline.stopHosting"),
|
||||
onClick: handleStopHosting,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: "add_friend",
|
||||
type: "button",
|
||||
label: "Add Friend",
|
||||
label: t("lceOnline.addFriend"),
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setIsAddingFriend(true);
|
||||
|
|
@ -202,7 +205,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
items.push({
|
||||
id: "sign_out",
|
||||
type: "button",
|
||||
label: "Sign Out",
|
||||
label: t("lceOnline.signOut"),
|
||||
onClick: handleLogout,
|
||||
});
|
||||
friends.forEach((f) => {
|
||||
|
|
@ -271,6 +274,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
invites,
|
||||
playPressSound,
|
||||
isHosting,
|
||||
t,
|
||||
]);
|
||||
|
||||
const tabs: ("friends" | "requests" | "invites")[] = [
|
||||
|
|
@ -344,6 +348,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
prev === null || prev <= 0 ? itemCount - 1 : prev - 1,
|
||||
);
|
||||
} else if (e.key === "Enter" && focusIndex !== null) {
|
||||
e.preventDefault();
|
||||
menuItems[focusIndex]?.onClick();
|
||||
}
|
||||
}
|
||||
|
|
@ -510,7 +515,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
/>
|
||||
</h2>
|
||||
<p className="text-white text-lg mc-text-shadow mb-8 max-w-sm">
|
||||
Awaiting authentication...
|
||||
{t("lceOnline.awaitingAuthentication")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -551,10 +556,10 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
<div className="bg-black/10 px-4 py-3 text-[#2a2a2a] font-bold tracking-widest uppercase border-b-4 border-[#222] flex justify-between shadow-sm z-10">
|
||||
<span>
|
||||
{currentTab === "friends"
|
||||
? "Friends"
|
||||
? t("lceOnline.friends")
|
||||
: currentTab === "invites"
|
||||
? "Invites"
|
||||
: "Pending Requests"}
|
||||
? t("lceOnline.invites")
|
||||
: t("lceOnline.pendingRequests")}
|
||||
</span>
|
||||
<span className="text-[#111]">{listItems.length}</span>
|
||||
</div>
|
||||
|
|
@ -562,7 +567,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
<div ref={scrollRef} className="flex-1 overflow-y-auto w-full">
|
||||
{listItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[200px] text-[#555] font-bold">
|
||||
None available
|
||||
{t("lceOnline.noneAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col p-2 space-y-2">
|
||||
|
|
@ -590,7 +595,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
? incomingReqs.find((r) => `req_in_${r.username}` === item.id)?.username
|
||||
: item.type === "request_out"
|
||||
? outgoingReqs.find((r) => `req_out_${r.username}` === item.id)?.username
|
||||
: "Invite"}
|
||||
: t("lceOnline.invite")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -611,7 +616,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
INVITE
|
||||
{t("lceOnline.invite").toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -624,7 +629,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
REMOVE
|
||||
{t("lceOnline.remove").toUpperCase()}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -639,7 +644,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
CANCEL
|
||||
{t("lceOnline.cancel").toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
{item.type === "invite" && (
|
||||
|
|
@ -654,7 +659,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
ACCEPT
|
||||
{t("lceOnline.accept").toUpperCase()}
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
|
||||
|
|
@ -669,7 +674,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
DECLINE
|
||||
{t("lceOnline.decline").toUpperCase()}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -685,7 +690,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
ACCEPT
|
||||
{t("lceOnline.accept").toUpperCase()}
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
|
||||
|
|
@ -700,7 +705,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
DECLINE
|
||||
{t("lceOnline.decline").toUpperCase()}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -732,10 +737,10 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
className="flex z-10 space-x-2 px-12 relative w-full items-end"
|
||||
style={{ marginBottom: "-4px" }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`flex-1 font-bold text-xl outline-none uppercase transition-all duration-200 ease-in-out ${currentTab === t ? "text-[#2a2a2a] z-20 pb-6 pt-5 text-2xl drop-shadow-[5px_-5px_15px_rgba(0,0,0,0.3)] rounded-t border-4 border-[#222] border-b-0" : "text-[#555] mt-2 py-4 hover:bg-black/30 bg-black/10 hover:text-[#222] border-4 border-transparent border-b-0"}`}
|
||||
key={tab}
|
||||
className={`flex-1 font-bold text-xl outline-none uppercase transition-all duration-200 ease-in-out ${currentTab === tab ? "text-[#2a2a2a] z-20 pb-6 pt-5 text-2xl drop-shadow-[5px_-5px_15px_rgba(0,0,0,0.3)] rounded-t border-4 border-[#222] border-b-0" : "text-[#555] mt-2 py-4 hover:bg-black/30 bg-black/10 hover:text-[#222] border-4 border-transparent border-b-0"}`}
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
|
|
@ -744,23 +749,27 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
setCurrentTab(t);
|
||||
setCurrentTab(tab);
|
||||
setFocusIndex(0);
|
||||
playPressSound();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{t}
|
||||
{t === "requests" && incomingReqs.length > 0 && (
|
||||
{tab === "friends"
|
||||
? t("lceOnline.friends")
|
||||
: tab === "requests"
|
||||
? t("lceOnline.requests")
|
||||
: t("lceOnline.invites")}
|
||||
{tab === "requests" && incomingReqs.length > 0 && (
|
||||
<span
|
||||
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
|
||||
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === tab ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
|
||||
>
|
||||
{incomingReqs.length}
|
||||
</span>
|
||||
)}
|
||||
{t === "invites" && invites.length > 0 && (
|
||||
{tab === "invites" && invites.length > 0 && (
|
||||
<span
|
||||
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
|
||||
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === tab ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
|
||||
>
|
||||
{invites.length}
|
||||
</span>
|
||||
|
|
@ -810,16 +819,18 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-6 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
|
||||
Add Friend
|
||||
{t("lceOnline.addFriend")}
|
||||
</h2>
|
||||
<div className="mc-textinput-outer w-full mb-6">
|
||||
<input
|
||||
ref={addFriendInputRef}
|
||||
type="text"
|
||||
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-6 mc-text-shadow"
|
||||
placeholder="Username"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] text-2xl font-bold placeholder:text-[#888] mc-text-shadow"
|
||||
placeholder={t("lceOnline.username")}
|
||||
value={addFriendUsername}
|
||||
onChange={(e) => setAddFriendUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 w-full">
|
||||
<button
|
||||
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest hover:text-[#FFFF55] outline-none border-none"
|
||||
|
|
@ -837,25 +848,25 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
),
|
||||
);
|
||||
setIsAddingFriend(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest hover:text-[#FFFF55] outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsAddingFriend(false);
|
||||
playBackSound();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("lceOnline.send")}
|
||||
</button>
|
||||
<button
|
||||
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest hover:text-[#FFFF55] outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsAddingFriend(false);
|
||||
playBackSound();
|
||||
}}
|
||||
>
|
||||
{t("lceOnline.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -878,7 +889,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
|
||||
Error
|
||||
{t("lceOnline.error")}
|
||||
</h2>
|
||||
<p className="text-white text-lg mc-text-shadow text-center mb-6">
|
||||
{errorModal}
|
||||
|
|
@ -892,7 +903,7 @@ const LceOnlineView = memo(function LceOnlineView({
|
|||
}}
|
||||
onClick={() => setErrorModal(null)}
|
||||
>
|
||||
OK
|
||||
{t("lceOnline.ok")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ArcService } from "../../services/ArcService";
|
||||
import { LocFile, LocLanguage } from "../../types/arc";
|
||||
export default function LocEditorView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -38,10 +40,10 @@ export default function LocEditorView() {
|
|||
const parsedLoc = ArcService.parseLOC(new Uint8Array(buffer));
|
||||
setLoc(parsedLoc);
|
||||
setSelectedLocLangIdx(0);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
showNotification(t("locEditor.loaded", { name: file.name }));
|
||||
} catch (err) {
|
||||
console.error("Failed to parse LOC", err);
|
||||
showNotification("Failed to parse LOC", "error");
|
||||
showNotification(t("locEditor.failedToParse"), "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
|
@ -57,7 +59,7 @@ export default function LocEditorView() {
|
|||
a.download = "languages.loc";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("LOC Saved Successfully");
|
||||
showNotification(t("locEditor.savedSuccessfully"));
|
||||
};
|
||||
|
||||
const handleLocStringEdit = (langIdx: number, strIdx: number, isNew: boolean, key: string, value: string) => {
|
||||
|
|
@ -82,7 +84,7 @@ export default function LocEditorView() {
|
|||
})
|
||||
});
|
||||
setIsLocEditModalOpen(null);
|
||||
showNotification(isNew ? "String Added" : "String Updated");
|
||||
showNotification(isNew ? t("locEditor.stringAdded") : t("locEditor.stringUpdated"));
|
||||
};
|
||||
|
||||
const handleLocStringDelete = (langIdx: number, strIdx: number) => {
|
||||
|
|
@ -94,7 +96,7 @@ export default function LocEditorView() {
|
|||
lIdx !== langIdx ? lang : { ...lang, strings: lang.strings.filter((_, sIdx) => sIdx !== strIdx) }
|
||||
)
|
||||
});
|
||||
showNotification("String Deleted");
|
||||
showNotification(t("locEditor.stringDeleted"));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -108,8 +110,8 @@ export default function LocEditorView() {
|
|||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".loc" />
|
||||
<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">LOC Editor</h2>
|
||||
{loc && <span className="text-white/40 mc-text-shadow italic">editing loaded loc</span>}
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">{t("locEditor.title")}</h2>
|
||||
{loc && <span className="text-white/40 mc-text-shadow italic">{t("locEditor.editingLoadedLoc")}</span>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
|
|
@ -117,7 +119,7 @@ export default function LocEditorView() {
|
|||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open LOC
|
||||
{t("locEditor.openLoc")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveLoc}
|
||||
|
|
@ -125,16 +127,15 @@ export default function LocEditorView() {
|
|||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!loc ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save LOC
|
||||
{t("locEditor.saveLoc")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loc ? (
|
||||
<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/loc.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 LOC file to begin editing</h3>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">{t("locEditor.openToBegin")}</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" }}>
|
||||
|
|
@ -146,31 +147,33 @@ export default function LocEditorView() {
|
|||
className="bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
>
|
||||
{loc.languages.map((lang, idx) => (
|
||||
<option key={idx} value={idx}>{lang.id} {lang.isStatic ? "[Static]" : "[Keyed]"}</option>
|
||||
<option key={idx} value={idx}>{lang.id} {lang.isStatic ? `[${t("locEditor.static")}]` : `[${t("locEditor.keyed")}]`}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search strings..."
|
||||
placeholder={t("locEditor.searchStrings")}
|
||||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: -1, isNew: true })}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add String
|
||||
{t("locEditor.addString")}
|
||||
</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">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Key / Index</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Value</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Actions</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("locEditor.keyOrIndex")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">{t("locEditor.value")}</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">{t("locEditor.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -186,7 +189,7 @@ export default function LocEditorView() {
|
|||
onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: str.originalIdx, isNew: false })}
|
||||
className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 uppercase"
|
||||
>
|
||||
Edit
|
||||
{t("locEditor.edit")}
|
||||
</button>
|
||||
<button onClick={() => handleLocStringDelete(selectedLocLangIdx, str.originalIdx)} className="p-1 hover:text-red-500 opacity-60">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
|
|
@ -205,9 +208,9 @@ export default function LocEditorView() {
|
|||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full shrink-0 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" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
{t("locEditor.back")}
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
|
|
@ -238,28 +241,31 @@ export default function LocEditorView() {
|
|||
}
|
||||
|
||||
function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: number, strIdx: number, isNew: boolean }, lang: LocLanguage, onClose: () => void, onConfirm: (langIdx: number, strIdx: number, isNew: boolean, key: string, val: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [key, setKey] = useState(!data.isNew ? (lang.strings[data.strIdx].key || "") : "");
|
||||
const [val, setVal] = useState(!data.isNew ? lang.strings[data.strIdx].value : "");
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">{data.isNew ? "Add" : "Edit"} String</h3>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">{data.isNew ? t("locEditor.add") : t("locEditor.edit")} {t("locEditor.stringTitle")}</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{!lang.isStatic ? (
|
||||
<div>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">String Key</label>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">{t("locEditor.stringKey")}</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors font-mono"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/40 italic mb-2">Static entry - Index: {data.isNew ? lang.strings.length : data.strIdx}</div>
|
||||
<div className="text-white/40 italic mb-2">{t("locEditor.staticEntry", { index: data.isNew ? lang.strings.length : data.strIdx })}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">String Value</label>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">{t("locEditor.stringValue")}</label>
|
||||
<textarea
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
|
|
@ -268,13 +274,13 @@ function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: num
|
|||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">Cancel</button>
|
||||
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">{t("locEditor.cancel")}</button>
|
||||
<button
|
||||
onClick={() => onConfirm(data.langIdx, data.strIdx, data.isNew, key, val)}
|
||||
className="px-8 py-2 text-white mc-text-shadow"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
{data.isNew ? "Add" : "Save"}
|
||||
{data.isNew ? t("locEditor.add") : t("locEditor.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ModelFile, ModelPart, ModelBox, Vec3, Vec2 } from "../../types/model";
|
||||
|
|
@ -13,6 +14,7 @@ interface Selection {
|
|||
}
|
||||
|
||||
export default function ModelEditorView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -73,16 +75,16 @@ export default function ModelEditorView() {
|
|||
const idx = models.length;
|
||||
setExpandedModels((prev) => new Set(prev).add(idx));
|
||||
setSelection({ type: "model", modelIdx: idx });
|
||||
showNotify(`Imported: ${model.name}`);
|
||||
showNotify(t("modelEditor.imported", { name: model.name }));
|
||||
} else {
|
||||
const text = new TextDecoder("utf-8").decode(buffer);
|
||||
const imported = ModelService.importFromJSON(text);
|
||||
setModels((prev) => [...prev, ...imported]);
|
||||
showNotify(`Loaded ${imported.length} model(s)`);
|
||||
showNotify(t("modelEditor.loadedModels", { count: imported.length }));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showNotify(
|
||||
err instanceof Error ? err.message : "Failed to parse",
|
||||
err instanceof Error ? err.message : t("modelEditor.failedToParse"),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
|
|
@ -106,7 +108,7 @@ export default function ModelEditorView() {
|
|||
next[mi] = { ...model, textures };
|
||||
return next;
|
||||
});
|
||||
showNotify(`Added texture: ${name}`);
|
||||
showNotify(t("modelEditor.addedTexture", { name }));
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
|
|
@ -118,7 +120,7 @@ export default function ModelEditorView() {
|
|||
setModels((prev) => [...prev, model]);
|
||||
setExpandedModels((prev) => new Set(prev).add(idx));
|
||||
setSelection({ type: "model", modelIdx: idx });
|
||||
showNotify(`Created: ${name}`);
|
||||
showNotify(t("modelEditor.created", { name }));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
|
|
@ -133,9 +135,9 @@ export default function ModelEditorView() {
|
|||
a.download = `${activeModel.name}.bbmodel`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotify(`Exported: ${activeModel.name}.bbmodel`);
|
||||
showNotify(t("modelEditor.exported", { name: `${activeModel.name}.bbmodel` }));
|
||||
} catch {
|
||||
showNotify("Export failed", "error");
|
||||
showNotify(t("modelEditor.exportFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -148,7 +150,7 @@ export default function ModelEditorView() {
|
|||
playBackSound();
|
||||
setModels((prev) => prev.filter((_, i) => i !== idx));
|
||||
if (selection?.modelIdx === idx) setSelection(null);
|
||||
showNotify("Model deleted");
|
||||
showNotify(t("modelEditor.modelDeleted"));
|
||||
};
|
||||
|
||||
const handleDeletePart = (modelIdx: number, partIdx: number) => {
|
||||
|
|
@ -333,7 +335,7 @@ export default function ModelEditorView() {
|
|||
|
||||
<div className="flex items-center justify-between mb-4 px-4 shrink-0">
|
||||
<h2 className="text-2xl text-white mc-text-shadow tracking-widest uppercase font-bold">
|
||||
Model Editor
|
||||
{t("modelEditor.title")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
|
|
@ -344,7 +346,7 @@ export default function ModelEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Import
|
||||
{t("modelEditor.import")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
|
|
@ -355,7 +357,7 @@ export default function ModelEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Export
|
||||
{t("modelEditor.export")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
|
@ -366,7 +368,7 @@ export default function ModelEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
{t("modelEditor.save")}
|
||||
</button>
|
||||
<div className="w-px h-6 bg-[#373737]" />
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
|
|
@ -375,7 +377,7 @@ export default function ModelEditorView() {
|
|||
className={`w-4 h-4 border-2 transition-colors ${showBounds ? "bg-[#FFFF55] border-[#FFFF55]" : "bg-black/40 border-[#373737]"}`}
|
||||
/>
|
||||
<span className="text-white/50 text-[10px] uppercase tracking-widest">
|
||||
Bounds
|
||||
{t("modelEditor.bounds")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -391,7 +393,7 @@ export default function ModelEditorView() {
|
|||
}}
|
||||
>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">
|
||||
Import a .bbmodel or .json file to begin
|
||||
{t("modelEditor.importToBegin")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleNewModel}
|
||||
|
|
@ -401,7 +403,7 @@ export default function ModelEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Start with Default Model
|
||||
{t("modelEditor.startDefaultModel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -416,13 +418,13 @@ export default function ModelEditorView() {
|
|||
<div className="w-72 min-w-72 border-r-2 border-[#373737] flex flex-col overflow-hidden">
|
||||
<div className="p-3 pt-4 pl-5 border-b-2 border-[#373737] flex items-center justify-between">
|
||||
<span className="text-white/60 uppercase text-xs tracking-widest font-bold pl-1">
|
||||
Models ({models.length})
|
||||
{t("modelEditor.models", { count: models.length })}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNewModel}
|
||||
className="text-[#FFFF55] text-sm hover:opacity-80 pr-1"
|
||||
>
|
||||
+ Add
|
||||
{t("modelEditor.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-3 pl-5">
|
||||
|
|
@ -436,14 +438,14 @@ export default function ModelEditorView() {
|
|||
onContextMenu={(e) =>
|
||||
handleContextMenu(e, [
|
||||
{
|
||||
label: "Export",
|
||||
label: t("modelEditor.export"),
|
||||
action: () => {
|
||||
setSelection({ type: "model", modelIdx: mi });
|
||||
setTimeout(handleExport, 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Remove",
|
||||
label: t("modelEditor.remove"),
|
||||
action: () => handleDeleteModel(mi),
|
||||
},
|
||||
])
|
||||
|
|
@ -482,10 +484,10 @@ export default function ModelEditorView() {
|
|||
onClick={() => handleAddPart(mi)}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-widest text-white/40">
|
||||
Parts ({model.parts.length})
|
||||
{t("modelEditor.parts", { count: model.parts.length })}
|
||||
</span>
|
||||
<span className="text-[#FFFF55] text-xs cursor-pointer">
|
||||
+ Add
|
||||
{t("modelEditor.add")}
|
||||
</span>
|
||||
</div>
|
||||
{model.parts.map((part, pi) => {
|
||||
|
|
@ -505,7 +507,7 @@ export default function ModelEditorView() {
|
|||
onContextMenu={(e) =>
|
||||
handleContextMenu(e, [
|
||||
{
|
||||
label: "Edit",
|
||||
label: t("modelEditor.edit"),
|
||||
action: () =>
|
||||
setEditingPart({
|
||||
modelIdx: mi,
|
||||
|
|
@ -513,7 +515,7 @@ export default function ModelEditorView() {
|
|||
}),
|
||||
},
|
||||
{
|
||||
label: "Remove",
|
||||
label: t("modelEditor.remove"),
|
||||
action: () => handleDeletePart(mi, pi),
|
||||
},
|
||||
])
|
||||
|
|
@ -549,10 +551,10 @@ export default function ModelEditorView() {
|
|||
onClick={() => handleAddBox(mi, pi)}
|
||||
>
|
||||
<span className="text-[9px] uppercase tracking-widest text-white/40">
|
||||
Boxes ({part.boxes.length})
|
||||
{t("modelEditor.boxes", { count: part.boxes.length })}
|
||||
</span>
|
||||
<span className="text-[#FFFF55] text-[10px] cursor-pointer">
|
||||
+ Add
|
||||
{t("modelEditor.add")}
|
||||
</span>
|
||||
</div>
|
||||
{part.boxes.map((_box, bi) => (
|
||||
|
|
@ -569,7 +571,7 @@ export default function ModelEditorView() {
|
|||
onContextMenu={(e) =>
|
||||
handleContextMenu(e, [
|
||||
{
|
||||
label: "Edit",
|
||||
label: t("modelEditor.edit"),
|
||||
action: () =>
|
||||
setEditingBox({
|
||||
modelIdx: mi,
|
||||
|
|
@ -578,7 +580,7 @@ export default function ModelEditorView() {
|
|||
}),
|
||||
},
|
||||
{
|
||||
label: "Remove",
|
||||
label: t("modelEditor.remove"),
|
||||
action: () =>
|
||||
handleDeleteBox(mi, pi, bi),
|
||||
},
|
||||
|
|
@ -588,7 +590,7 @@ export default function ModelEditorView() {
|
|||
>
|
||||
<div className="w-2 h-2 rounded-sm border border-white/20 shrink-0" />
|
||||
<span className="truncate text-[10px] mc-text-shadow">
|
||||
Box {bi + 1}
|
||||
{t("modelEditor.box", { index: bi + 1 })}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -631,19 +633,19 @@ export default function ModelEditorView() {
|
|||
<div className="h-44 shrink-0 border-t-2 border-[#373737] bg-black/10 overflow-y-auto custom-scrollbar">
|
||||
<div className="p-2 border-b border-[#373737]/50 flex items-center justify-between">
|
||||
<span className="text-white/40 text-[10px] uppercase tracking-widest font-bold">
|
||||
Textures ({activeTextures.length})
|
||||
{t("modelEditor.textures", { count: activeTextures.length })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => textureInputRef.current?.click()}
|
||||
className="text-white/40 hover:text-white text-xs uppercase tracking-widest transition-colors pr-2"
|
||||
>
|
||||
Add
|
||||
{t("modelEditor.add")}
|
||||
</button>
|
||||
</div>
|
||||
{activeTextures.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-20">
|
||||
<span className="text-white/20 text-xs italic">
|
||||
No textures
|
||||
{t("modelEditor.noTextures")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -674,7 +676,7 @@ export default function ModelEditorView() {
|
|||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-white/20 mc-text-shadow text-lg italic">
|
||||
Select a model to preview
|
||||
{t("modelEditor.selectModel")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -690,12 +692,12 @@ export default function ModelEditorView() {
|
|||
}}
|
||||
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')",
|
||||
backgroundImage: "url('/images/Layout_Button_Bmp.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("modelEditor.back")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -830,6 +832,7 @@ function PartEditModal({
|
|||
onClose: () => void;
|
||||
onConfirm: (updated: { name: string; translation: Vec3 | undefined }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(part.name);
|
||||
const [tx, setTx] = useState(part.translation?.X ?? 0);
|
||||
const [ty, setTy] = useState(part.translation?.Y ?? 0);
|
||||
|
|
@ -855,24 +858,26 @@ function PartEditModal({
|
|||
}}
|
||||
>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">
|
||||
Edit Part
|
||||
{t("modelEditor.editPart")}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">
|
||||
Name
|
||||
{t("modelEditor.name")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">
|
||||
Translation
|
||||
{t("modelEditor.translation")}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["X", "Y", "Z"] as const).map((axis) => (
|
||||
|
|
@ -880,6 +885,7 @@ function PartEditModal({
|
|||
<span className="text-white/40 text-xs font-mono">
|
||||
{axis}
|
||||
</span>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="number"
|
||||
step={0.5}
|
||||
|
|
@ -890,8 +896,9 @@ function PartEditModal({
|
|||
else if (axis === "Y") setTy(v);
|
||||
else setTz(v);
|
||||
}}
|
||||
className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -902,7 +909,7 @@ function PartEditModal({
|
|||
onClick={onClose}
|
||||
className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest text-sm"
|
||||
>
|
||||
Cancel
|
||||
{t("modelEditor.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
|
|
@ -915,13 +922,13 @@ function PartEditModal({
|
|||
})
|
||||
}
|
||||
className="px-8 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%",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
{t("modelEditor.save")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -937,6 +944,7 @@ function BoxEditModal({
|
|||
onClose: () => void;
|
||||
onConfirm: (updated: ModelBox) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [posX, setPosX] = useState(box.pos.X);
|
||||
const [posY, setPosY] = useState(box.pos.Y);
|
||||
const [posZ, setPosZ] = useState(box.pos.Z);
|
||||
|
|
@ -968,12 +976,12 @@ function BoxEditModal({
|
|||
}}
|
||||
>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">
|
||||
Edit Box
|
||||
{t("modelEditor.editBox")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div className="col-span-2">
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">
|
||||
Position
|
||||
{t("modelEditor.position")}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["X", "Y", "Z"] as const).map((axis) => (
|
||||
|
|
@ -981,6 +989,7 @@ function BoxEditModal({
|
|||
<span className="text-white/40 text-xs font-mono">
|
||||
{axis}
|
||||
</span>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="number"
|
||||
step={0.5}
|
||||
|
|
@ -991,15 +1000,16 @@ function BoxEditModal({
|
|||
else if (axis === "Y") setPosY(v);
|
||||
else setPosZ(v);
|
||||
}}
|
||||
className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">
|
||||
Size
|
||||
{t("modelEditor.size")}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["X", "Y", "Z"] as const).map((axis) => (
|
||||
|
|
@ -1007,6 +1017,7 @@ function BoxEditModal({
|
|||
<span className="text-white/40 text-xs font-mono">
|
||||
{axis}
|
||||
</span>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="number"
|
||||
step={0.5}
|
||||
|
|
@ -1018,15 +1029,16 @@ function BoxEditModal({
|
|||
else if (axis === "Y") setSizeY(v);
|
||||
else setSizeZ(v);
|
||||
}}
|
||||
className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">
|
||||
UV Offset
|
||||
{t("modelEditor.uvOffset")}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["X", "Y"] as const).map((axis) => (
|
||||
|
|
@ -1034,6 +1046,7 @@ function BoxEditModal({
|
|||
<span className="text-white/40 text-xs font-mono">
|
||||
{axis}
|
||||
</span>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
|
|
@ -1043,27 +1056,30 @@ function BoxEditModal({
|
|||
if (axis === "X") setUvX(v);
|
||||
else setUvY(v);
|
||||
}}
|
||||
className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest">
|
||||
Inflate
|
||||
{t("modelEditor.inflate")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer w-20">
|
||||
<input
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={inflate}
|
||||
onChange={(e) => setInflate(parseFloat(e.target.value) || 0)}
|
||||
className="w-20 bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest">
|
||||
Mirror UV
|
||||
{t("modelEditor.mirrorUv")}
|
||||
</label>
|
||||
<div
|
||||
onClick={() => setMirror(!mirror)}
|
||||
|
|
@ -1076,7 +1092,7 @@ function BoxEditModal({
|
|||
onClick={onClose}
|
||||
className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest text-sm"
|
||||
>
|
||||
Cancel
|
||||
{t("modelEditor.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
|
|
@ -1094,7 +1110,7 @@ function BoxEditModal({
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
{t("modelEditor.save")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { OptionsService } from "../../services/OptionsService";
|
||||
|
|
@ -11,6 +12,7 @@ const BUTTONS: Record<number, string> = {
|
|||
};
|
||||
|
||||
export default function OptionsEditorView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -31,10 +33,10 @@ export default function OptionsEditorView() {
|
|||
try {
|
||||
const parsed = OptionsService.readOptions(buffer);
|
||||
setOpt(parsed);
|
||||
showNotification(`Loaded options.dat`);
|
||||
showNotification(t("optionsEditor.loaded"));
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to parse Options", err);
|
||||
showNotification(err instanceof Error ? err.message : "Failed to parse Options", "error");
|
||||
showNotification(err instanceof Error ? err.message : t("optionsEditor.failedToParse"), "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
|
@ -51,9 +53,9 @@ export default function OptionsEditorView() {
|
|||
a.download = "options.dat";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("Options Saved");
|
||||
showNotification(t("optionsEditor.saved"));
|
||||
} catch (err: unknown) {
|
||||
showNotification("Failed to save", "error");
|
||||
showNotification(t("optionsEditor.failedToSave"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -85,7 +87,7 @@ export default function OptionsEditorView() {
|
|||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".dat,.bin" />
|
||||
<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">Options Editor</h2>
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">{t("optionsEditor.title")}</h2>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
|
|
@ -93,7 +95,7 @@ export default function OptionsEditorView() {
|
|||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open settings.dat
|
||||
{t("optionsEditor.openSettings")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveOptions}
|
||||
|
|
@ -101,7 +103,7 @@ export default function OptionsEditorView() {
|
|||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!opt ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save settings.dat
|
||||
{t("optionsEditor.saveSettings")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -109,7 +111,7 @@ export default function OptionsEditorView() {
|
|||
{!opt ? (
|
||||
<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 settings.dat to begin</h3>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">{t("optionsEditor.openToBegin")}</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" }}>
|
||||
|
|
@ -120,7 +122,7 @@ export default function OptionsEditorView() {
|
|||
onClick={() => { playPressSound(); setActiveTab(tab); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === tab ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`}
|
||||
>
|
||||
<span className="text-lg capitalize">{tab}</span>
|
||||
<span className="text-lg capitalize">{tab === "settings" ? t("optionsEditor.tabSettings") : tab === "skins" ? t("optionsEditor.tabSkins") : t("optionsEditor.tabActions")}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -158,28 +160,34 @@ export default function OptionsEditorView() {
|
|||
{activeTab === "skins" && (
|
||||
<div className="flex flex-col gap-8 max-w-xl mx-auto">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-white/80 uppercase tracking-widest">Chosen Skin ID</span>
|
||||
<span className="text-white/80 uppercase tracking-widest">{t("optionsEditor.chosenSkinId")}</span>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="number" value={opt.chosenSkin} onChange={(e) => updateSetting("chosenSkin", parseInt(e.target.value) || 0)}
|
||||
className="bg-black/40 border border-[#373737] p-2 text-white outline-none focus:border-[#FFFF55] font-mono text-lg"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-lg outline-none font-[var(--font-base)] font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-white/80 uppercase tracking-widest">Player Cape ID</span>
|
||||
<span className="text-white/80 uppercase tracking-widest">{t("optionsEditor.playerCapeId")}</span>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="number" value={opt.playerCape} onChange={(e) => updateSetting("playerCape", parseInt(e.target.value) || 0)}
|
||||
className="bg-black/40 border border-[#373737] p-2 text-white outline-none focus:border-[#FFFF55] font-mono text-lg"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-lg outline-none font-[var(--font-base)] font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-white/80 uppercase tracking-widest">Favorite Skins</span>
|
||||
<span className="text-white/80 uppercase tracking-widest">{t("optionsEditor.favoriteSkins")}</span>
|
||||
{opt.favoriteSkins.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<span className="text-white/40 w-8">{i + 1}.</span>
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="number" value={s} onChange={(e) => updateFavSkin(i, parseInt(e.target.value) || 0)}
|
||||
className="bg-black/40 flex-1 border border-[#373737] p-2 text-white outline-none focus:border-[#FFFF55] font-mono"
|
||||
className="mc-textinput w-full h-10 px-3 text-white outline-none font-[var(--font-base)] font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -215,9 +223,9 @@ export default function OptionsEditorView() {
|
|||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full shrink-0 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" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
{t("optionsEditor.back")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { PckService } from "../../services/PckService";
|
||||
|
|
@ -6,6 +7,7 @@ import { PCKFile, PCKAsset, PCKAssetType } from "../../types/pck";
|
|||
import SkinPreview3D from "../common/SkinPreview3D";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
export default function PckEditorView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
|
|
@ -201,7 +203,7 @@ export default function PckEditorView() {
|
|||
|
||||
const handleFileLoad = async () => {
|
||||
try {
|
||||
const path = await TauriService.pickFile("Open PCK", ["pck"]);
|
||||
const path = await TauriService.pickFile(t("pckEditor.openPck"), ["pck"]);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
const bytes = await TauriService.readBinaryFile(path);
|
||||
|
|
@ -213,7 +215,7 @@ export default function PckEditorView() {
|
|||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") {
|
||||
console.error("Failed to parse PCK", err);
|
||||
showNotification("Failed to parse PCK", "error");
|
||||
showNotification(t("pckEditor.failedToParse"), "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -231,7 +233,7 @@ export default function PckEditorView() {
|
|||
setOpenedPath(null);
|
||||
setSelectedAssetId(null);
|
||||
setExpandedFolders(new Set());
|
||||
showNotification("New PCK Created");
|
||||
showNotification(t("pckEditor.newPckCreated"));
|
||||
};
|
||||
|
||||
const showNotification = (
|
||||
|
|
@ -246,16 +248,17 @@ export default function PckEditorView() {
|
|||
try {
|
||||
const fileName = asset.path.split("/").pop() || "asset";
|
||||
const path = await TauriService.saveFileDialog(
|
||||
"Export Asset",
|
||||
t("pckEditor.exportAssetDialog"),
|
||||
fileName,
|
||||
[],
|
||||
);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
await TauriService.writeBinaryFile(path, asset.data);
|
||||
showNotification(`Exported: ${fileName}`);
|
||||
showNotification(t("pckEditor.exported", { name: fileName }));
|
||||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
if (err !== "CANCELED")
|
||||
showNotification(t("pckEditor.exportFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -266,7 +269,9 @@ export default function PckEditorView() {
|
|||
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()}`);
|
||||
showNotification(
|
||||
t("pckEditor.deleted", { name: assetPath?.split("/").pop() }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleReplaceAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -283,7 +288,7 @@ export default function PckEditorView() {
|
|||
);
|
||||
setPck({ ...pck, files: newFiles });
|
||||
e.target.value = "";
|
||||
showNotification("Asset Replaced");
|
||||
showNotification(t("pckEditor.assetReplaced"));
|
||||
};
|
||||
|
||||
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -316,7 +321,7 @@ export default function PckEditorView() {
|
|||
setPck({ ...pck, files: [...pck.files, newAsset] });
|
||||
setSelectedAssetId(newAsset.id);
|
||||
setShowTypeModal(null);
|
||||
showNotification("Asset Added");
|
||||
showNotification(t("pckEditor.assetAdded"));
|
||||
};
|
||||
|
||||
const handlePropertyEdit = (idx: number, newVal: string, isKey = false) => {
|
||||
|
|
@ -402,7 +407,7 @@ export default function PckEditorView() {
|
|||
return f;
|
||||
});
|
||||
setPck({ ...pck, files: newFiles });
|
||||
showNotification("Asset Renamed");
|
||||
showNotification(t("pckEditor.assetRenamed"));
|
||||
};
|
||||
|
||||
const handleExportAll = async () => {
|
||||
|
|
@ -411,7 +416,7 @@ export default function PckEditorView() {
|
|||
const baseFolder = await TauriService.pickFolder();
|
||||
if (!baseFolder) return;
|
||||
playPressSound();
|
||||
showNotification("Exporting all assets...");
|
||||
showNotification(t("pckEditor.exportingAllAssets"));
|
||||
for (const asset of pck.files) {
|
||||
const parts = asset.path.split("/");
|
||||
const fileName = parts.join("_");
|
||||
|
|
@ -420,9 +425,10 @@ export default function PckEditorView() {
|
|||
asset.data,
|
||||
);
|
||||
}
|
||||
showNotification("All Assets Exported");
|
||||
showNotification(t("pckEditor.allAssetsExported"));
|
||||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
if (err !== "CANCELED")
|
||||
showNotification(t("pckEditor.exportFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -435,7 +441,7 @@ export default function PckEditorView() {
|
|||
let targetPath = openedPath;
|
||||
if (!targetPath) {
|
||||
targetPath = await TauriService.saveFileDialog(
|
||||
"Save PCK",
|
||||
t("pckEditor.savePckDialog"),
|
||||
pck.files.length > 0 ? "new.pck" : "empty.pck",
|
||||
["pck"],
|
||||
);
|
||||
|
|
@ -444,10 +450,11 @@ export default function PckEditorView() {
|
|||
if (targetPath) {
|
||||
await TauriService.writeBinaryFile(targetPath, data);
|
||||
setOpenedPath(targetPath);
|
||||
showNotification("PCK Saved Successfully");
|
||||
showNotification(t("pckEditor.pckSaved"));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
if (err !== "CANCELED")
|
||||
showNotification(t("pckEditor.exportFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -504,7 +511,7 @@ export default function PckEditorView() {
|
|||
>
|
||||
<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
|
||||
{t("pckEditor.title")}
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
|
|
@ -515,7 +522,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Open PCK
|
||||
{t("pckEditor.openPck")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNewPCK}
|
||||
|
|
@ -525,7 +532,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
New PCK
|
||||
{t("pckEditor.newPck")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
|
|
@ -536,7 +543,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Export All
|
||||
{t("pckEditor.exportAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePCK}
|
||||
|
|
@ -547,7 +554,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Save PCK
|
||||
{t("pckEditor.savePck")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -557,7 +564,7 @@ export default function PckEditorView() {
|
|||
<div className="flex gap-6 bg-black/40 border-2 border-[#373737] p-3 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">
|
||||
Endianness:
|
||||
{t("pckEditor.endianness")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -574,7 +581,7 @@ export default function PckEditorView() {
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">
|
||||
XML Support:
|
||||
{t("pckEditor.xmlSupport")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -583,12 +590,14 @@ export default function PckEditorView() {
|
|||
}}
|
||||
className={`${pck.xmlSupport ? "text-[#FFFF55]" : "text-white/20"} text-sm uppercase hover:underline`}
|
||||
>
|
||||
{pck.xmlSupport ? "Enabled" : "Disabled"}
|
||||
{pck.xmlSupport
|
||||
? t("pckEditor.enabled")
|
||||
: t("pckEditor.disabled")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">
|
||||
Version:
|
||||
{t("pckEditor.version")}
|
||||
</span>
|
||||
<span className="text-white text-sm">{pck.version}</span>
|
||||
</div>
|
||||
|
|
@ -623,7 +632,7 @@ export default function PckEditorView() {
|
|||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">
|
||||
Open a PCK file to begin editing
|
||||
{t("pckEditor.openToBegin")}
|
||||
</h3>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -637,13 +646,15 @@ export default function PckEditorView() {
|
|||
}}
|
||||
>
|
||||
<div className="mb-4 flex gap-4">
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
placeholder={t("pckEditor.searchAssets")}
|
||||
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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addAssetInputRef.current?.click()}
|
||||
className="px-4 py-2 text-white mc-text-shadow text-sm shrink-0"
|
||||
|
|
@ -652,7 +663,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Add Asset
|
||||
{t("pckEditor.addAsset")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
|
|
@ -675,7 +686,7 @@ export default function PckEditorView() {
|
|||
className="w-16 h-16 opacity-10 grayscale"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<span>Select an asset to view details</span>
|
||||
<span>{t("pckEditor.selectAsset")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
|
|
@ -811,10 +822,10 @@ export default function PckEditorView() {
|
|||
)}
|
||||
<div className="absolute top-2 right-2 bg-black/60 px-2 py-1 rounded text-[10px] text-white/60 pointer-events-none uppercase tracking-widest">
|
||||
{selectedAsset.type === PCKAssetType.SKIN
|
||||
? "3D Skin View"
|
||||
? t("pckEditor.view3dSkin")
|
||||
: selectedAsset.type === PCKAssetType.CAPE
|
||||
? "3D Cape View"
|
||||
: "Texture Preview"}
|
||||
? t("pckEditor.view3dCape")
|
||||
: t("pckEditor.texturePreview")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -823,13 +834,13 @@ export default function PckEditorView() {
|
|||
<div>
|
||||
<div className="flex justify-between items-end mb-2 px-1">
|
||||
<div className="text-white/40 text-[10px] uppercase tracking-widest text-[#FFFF55]/60">
|
||||
Metadata Properties
|
||||
{t("pckEditor.metadataProperties")}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddProperty}
|
||||
className="text-[#FFFF55] text-[10px] uppercase hover:underline"
|
||||
>
|
||||
+ Add Property
|
||||
+ {t("pckEditor.addProperty")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
|
|
@ -839,30 +850,34 @@ export default function PckEditorView() {
|
|||
className="flex flex-col gap-1 group/prop"
|
||||
>
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<div className="mc-textinput-outer w-2/3">
|
||||
<input
|
||||
type="text"
|
||||
value={prop.key}
|
||||
onChange={(e) =>
|
||||
handlePropertyEdit(idx, e.target.value, true)
|
||||
}
|
||||
className="bg-transparent text-white/40 text-[10px] outline-none hover:text-white/60 focus:text-[#FFFF55] w-2/3"
|
||||
className="mc-textinput w-full h-8 px-2 text-white/60 text-[10px] outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveProperty(idx)}
|
||||
className="text-red-500/0 group-hover/prop:text-red-500/40 hover:text-red-500 transition-colors text-[10px] uppercase"
|
||||
>
|
||||
Remove
|
||||
{t("pckEditor.remove")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mc-textinput-outer">
|
||||
<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"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prop.key === "ANIM" && (
|
||||
|
|
@ -961,7 +976,7 @@ export default function PckEditorView() {
|
|||
))}
|
||||
{selectedAsset.properties.length === 0 && (
|
||||
<div className="text-white/20 italic text-sm px-1 py-4 border-2 border-dashed border-[#373737] text-center">
|
||||
No metadata properties
|
||||
{t("pckEditor.noMetadataProperties")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -978,7 +993,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Export
|
||||
{t("pckEditor.export")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => replaceInputRef.current?.click()}
|
||||
|
|
@ -989,7 +1004,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Replace
|
||||
{t("pckEditor.replace")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -1001,7 +1016,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Rename Asset (Path)
|
||||
{t("pckEditor.renameAsset")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAsset(selectedAsset.id)}
|
||||
|
|
@ -1012,7 +1027,7 @@ export default function PckEditorView() {
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Delete This Asset
|
||||
{t("pckEditor.deleteAsset")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1030,12 +1045,12 @@ export default function PckEditorView() {
|
|||
}}
|
||||
className="w-72 h-14 shrink-0 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: "url('/images/Button_Background.png')",
|
||||
backgroundImage: "url('/images/Layout_Button_Bmp.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("pckEditor.back")}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
@ -1080,7 +1095,7 @@ export default function PckEditorView() {
|
|||
}}
|
||||
>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">
|
||||
Select Asset Type
|
||||
{t("pckEditor.selectAssetType")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
{Object.keys(PCKAssetType)
|
||||
|
|
@ -1103,7 +1118,7 @@ export default function PckEditorView() {
|
|||
onClick={() => setShowTypeModal(null)}
|
||||
className="mt-8 px-8 py-2 text-white/60 mc-text-shadow text-sm hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("pckEditor.cancel")}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -1136,6 +1151,7 @@ function RenameAssetModal({
|
|||
onClose: () => void;
|
||||
onConfirm: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState(initialPath);
|
||||
return (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
|
||||
|
|
@ -1158,27 +1174,29 @@ function RenameAssetModal({
|
|||
}}
|
||||
>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">
|
||||
Rename Asset
|
||||
{t("pckEditor.renameTitle")}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">
|
||||
New Asset Path
|
||||
{t("pckEditor.newAssetPath")}
|
||||
</label>
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest text-sm"
|
||||
>
|
||||
Cancel
|
||||
{t("pckEditor.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(path)}
|
||||
|
|
@ -1188,7 +1206,7 @@ function RenameAssetModal({
|
|||
backgroundSize: "100% 100%",
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
{t("pckEditor.rename")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
import {
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
} from "../../services/ScreenshotService";
|
||||
import { ScreenshotImage } from "../common/ScreenshotImage";
|
||||
const ScreenshotsView = memo(function ScreenshotsView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { isAndroid } = usePlatform();
|
||||
|
|
@ -71,6 +73,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
playPressSound();
|
||||
setDeleteConfirmFocusIndex((prev) => (prev === 0 ? 1 : 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (deleteConfirmFocusIndex === 1) confirmDelete();
|
||||
else {
|
||||
playBackSound();
|
||||
|
|
@ -92,6 +95,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
playPressSound();
|
||||
setModalFocusIndex((prev) => (prev < 2 ? prev + 1 : 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (modalFocusIndex === 0) handleOpenFolder(selectedScreenshot);
|
||||
else if (modalFocusIndex === 1) {
|
||||
playPressSound();
|
||||
|
|
@ -122,6 +126,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
prev <= screenshots.length - 1 - cols ? prev + cols : prev,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (screenshots[gridFocusIndex]) {
|
||||
playPressSound();
|
||||
setModalFocusIndex(2);
|
||||
|
|
@ -160,10 +165,10 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
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 h-full max-w-6xl relative font-['Mojangles'] text-white select-none outline-none focus:outline-none"
|
||||
className="flex flex-col items-center w-full h-full max-w-6xl relative font-[var(--font-base)] text-white select-none outline-none focus:outline-none"
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-4 mb-6 border-b-2 border-[#373737] pb-2 w-[30%] max-w-[250px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
|
||||
Screenshots
|
||||
{t("screenshots.title")}
|
||||
</h2>
|
||||
|
||||
<div className="w-[98%] flex-1 relative overflow-hidden">
|
||||
|
|
@ -171,16 +176,16 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<span className="text-3xl text-[#FFFF55] mc-text-shadow tracking-widest animate-pulse uppercase">
|
||||
Scanning Archives...
|
||||
{t("screenshots.scanning")}
|
||||
</span>
|
||||
</div>
|
||||
) : screenshots.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4 opacity-40">
|
||||
<span className="text-2xl mc-text-shadow uppercase tracking-widest">
|
||||
No screenshots found
|
||||
{t("screenshots.noScreenshots")}
|
||||
</span>
|
||||
<span className="text-sm mc-text-shadow tracking-widest italic">
|
||||
Take some in-game with F2
|
||||
{t("screenshots.takeInGameWithF2")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -263,7 +268,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
"url('/images/Button_Background.png')";
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -302,10 +307,11 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
{selectedScreenshot.name}
|
||||
</span>
|
||||
<span className="text-sm text-[#FFFF55] mc-text-shadow uppercase tracking-widest opacity-90">
|
||||
Captured on{" "}
|
||||
{new Date(
|
||||
selectedScreenshot.date * 1000,
|
||||
).toLocaleString()}
|
||||
{t("screenshots.capturedOn", {
|
||||
date: new Date(
|
||||
selectedScreenshot.date * 1000,
|
||||
).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{getEditionLogo(selectedScreenshot.instanceId) && (
|
||||
|
|
@ -335,9 +341,8 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
OPEN FOLDER
|
||||
</button>
|
||||
<button
|
||||
{t("screenshots.openFolder")}
|
||||
</button> <button
|
||||
onMouseEnter={() => setModalFocusIndex(1)}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
|
|
@ -357,7 +362,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
DELETE
|
||||
{t("screenshots.delete")}
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setModalFocusIndex(2)}
|
||||
|
|
@ -375,7 +380,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
CLOSE
|
||||
{t("screenshots.close")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -404,7 +409,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-2xl text-white mc-text-shadow text-center mb-6 px-4">
|
||||
Are you sure you want to delete this screenshot?
|
||||
{t("screenshots.deleteConfirm")}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-4 w-full">
|
||||
|
|
@ -427,7 +432,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
CANCEL
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setDeleteConfirmFocusIndex(1)}
|
||||
|
|
@ -445,7 +450,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
DELETE
|
||||
{t("screenshots.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TauriService, Runner } from "../../services/TauriService";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
import { useConfig, useAudio } from "../../context/LauncherContext";
|
||||
|
|
@ -7,6 +8,7 @@ interface SetupViewProps {
|
|||
}
|
||||
|
||||
const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const { isLinux, isMac, isAndroid } = usePlatform();
|
||||
const {
|
||||
username,
|
||||
|
|
@ -125,6 +127,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev - 1 + count) % count);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (currentStep === 0) {
|
||||
if (focusIndex === 0) handleNext();
|
||||
else if (focusIndex === 1) handleNext();
|
||||
|
|
@ -181,14 +184,14 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
setIsSettingUpRuntime(true);
|
||||
setSetupProgress({
|
||||
stage: "preparing",
|
||||
message: "Preparing macOS runtime setup...",
|
||||
message: t("setup.macosPreparing"),
|
||||
percent: 0,
|
||||
});
|
||||
try {
|
||||
await TauriService.setupMacosRuntime();
|
||||
setSetupProgress({
|
||||
stage: "completed",
|
||||
message: "Setup completed successfully!",
|
||||
message: t("setup.macosCompleted"),
|
||||
percent: 100,
|
||||
});
|
||||
localStorage.setItem("lce-macos-runtime-installed", "true");
|
||||
|
|
@ -201,7 +204,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
} catch (e) {
|
||||
setSetupProgress({
|
||||
stage: "error",
|
||||
message: `Setup failed: ${e}`,
|
||||
message: t("setup.macosFailed", { error: e }),
|
||||
percent: 0,
|
||||
});
|
||||
setIsSettingUpRuntime(false);
|
||||
|
|
@ -243,17 +246,17 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
>
|
||||
{currentStep === 0 && (
|
||||
<p className="text-white text-sm tracking-widest text-center uppercase mb-4">
|
||||
Let's configure your launcher
|
||||
{t("setup.letsConfigure")}
|
||||
</p>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<p className="text-white text-xs tracking-widest text-center uppercase mb-4">
|
||||
Compatibility Runtime
|
||||
{t("setup.compatibilityRuntime")}
|
||||
</p>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<p className="text-white text-xs tracking-widest text-center uppercase mb-4">
|
||||
Choose your preferred options and behaviors
|
||||
{t("setup.choosePreferredOptions")}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -267,9 +270,10 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
<div className="p-5 flex flex-col gap-4 mc-options-bg">
|
||||
<label className="block relative">
|
||||
<span className="text-black font-bold uppercase tracking-widest text-sm block mb-2">
|
||||
Username
|
||||
{t("setup.username")}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
ref={(el) => {
|
||||
|
|
@ -290,7 +294,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
const caret =
|
||||
document.getElementById("custom-caret");
|
||||
if (caret) {
|
||||
caret.style.left = `${cursorPosition + 16}px`;
|
||||
caret.style.left = `${cursorPosition + 14}px`;
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
@ -318,29 +322,28 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
const caret =
|
||||
document.getElementById("custom-caret");
|
||||
if (caret) {
|
||||
caret.style.left = `${cursorPosition + 16}px`;
|
||||
caret.style.left = `${cursorPosition + 14}px`;
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`w-full px-4 py-2 focus:outline-none transition-colors text-white tracking-widest
|
||||
${focusIndex === 0 ? "border-4 border-[#FFFF55] text-[#FFFF55]" : "border-4 border-[#323232]"}`}
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base tracking-widest outline-none font-[var(--font-base)]"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
fontFamily: "'Mojangles', monospace",
|
||||
backgroundColor: "#646464",
|
||||
caretColor: "transparent",
|
||||
}}
|
||||
placeholder="Enter your username"
|
||||
placeholder={t("setup.enterUsername")}
|
||||
maxLength={16}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{focusIndex === 0 && (
|
||||
<span
|
||||
id="custom-caret"
|
||||
className="absolute top-1/2 -translate-y-1/2 text-white blink-caret"
|
||||
style={{
|
||||
fontFamily: "'Mojangles', monospace",
|
||||
left: "16px",
|
||||
left: "14px",
|
||||
}}
|
||||
>
|
||||
_
|
||||
|
|
@ -350,7 +353,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
</label>
|
||||
{tempUsername.trim().length === 0 && (
|
||||
<p className="text-gray-500 text-xs text-center uppercase tracking-widest">
|
||||
A username is required to continue
|
||||
{t("setup.usernameRequired")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -376,13 +379,13 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
className={`font-bold text-sm uppercase tracking-widest ${runtimeAlreadyInstalled ? "text-green-700" : "text-yellow-700"}`}
|
||||
>
|
||||
{runtimeAlreadyInstalled
|
||||
? "Runtime Detected"
|
||||
: "Runtime Not Detected"}
|
||||
? t("setup.runtimeDetected")
|
||||
: t("setup.runtimeNotDetected")}
|
||||
</p>
|
||||
<p className="text-gray-700 text-xs mt-0.5">
|
||||
{runtimeAlreadyInstalled
|
||||
? "Game Porting Toolkit 3 and Wine installed"
|
||||
: "You must install the runtime before proceeding."}
|
||||
? t("setup.gptInstalled")
|
||||
: t("setup.mustInstallRuntime")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -417,10 +420,10 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
>
|
||||
<span className="tracking-widest uppercase text-lg">
|
||||
{isSettingUpRuntime
|
||||
? "Installing..."
|
||||
? t("setup.installing")
|
||||
: runtimeAlreadyInstalled
|
||||
? "Reinstall Runtime"
|
||||
: "Install Runtime"}
|
||||
? t("setup.reinstallRuntime")
|
||||
: t("setup.installRuntime")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -432,8 +435,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
{runners.length === 0 ? (
|
||||
<div className="p-3 border-2 border-yellow-400/50 bg-yellow-50">
|
||||
<p className="text-yellow-600 text-sm text-center">
|
||||
No compatible runners found. Please install Wine or
|
||||
Proton.
|
||||
{t("setup.noCompatibleRunners")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -459,7 +461,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 text-center uppercase tracking-widest mt-1">
|
||||
You can change this later in settings
|
||||
{t("setup.changeLaterInSettings")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -470,11 +472,10 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
<span className="text-green-400 text-xl">✓</span>
|
||||
<div>
|
||||
<p className="text-green-600 font-bold text-sm uppercase tracking-widest">
|
||||
Windows Native Support
|
||||
{t("setup.windowsNativeSupport")}
|
||||
</p>
|
||||
<p className="text-gray-600 text-xs mt-0.5">
|
||||
Emerald Legacy runs natively on Windows without
|
||||
additional requirements.
|
||||
{t("setup.windowsNativeDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -487,12 +488,10 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
<span className="text-green-400 text-xl">✓</span>
|
||||
<div>
|
||||
<p className="text-green-600 font-bold text-sm uppercase tracking-widest">
|
||||
DiamondRuntime Support
|
||||
{t("setup.diamondRuntimeSupport")}
|
||||
</p>
|
||||
<p className="text-gray-600 text-xs mt-0.5">
|
||||
Emerald Launcher runs natively on Android without
|
||||
additional requirements. Powered by our own
|
||||
DiamondRuntime.
|
||||
{t("setup.diamondRuntimeDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -514,7 +513,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
<span
|
||||
className={`tracking-widest uppercase text-lg ${focusIndex === 0 ? "text-[#FFFF55]" : "text-gray-800"}`}
|
||||
>
|
||||
Discord RPC
|
||||
{t("setup.discordRpc")}
|
||||
</span>
|
||||
<div className="relative w-6 h-6 shrink-0">
|
||||
<img
|
||||
|
|
@ -549,7 +548,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
<span
|
||||
className={`tracking-widest uppercase text-lg ${focusIndex === (isAndroid ? 0 : 1) ? "text-[#FFFF55]" : "text-gray-800"}`}
|
||||
>
|
||||
Animations
|
||||
{t("setup.animations")}
|
||||
</span>
|
||||
<div className="relative w-6 h-6 shrink-0">
|
||||
<img
|
||||
|
|
@ -574,7 +573,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
</button>
|
||||
|
||||
<p className="text-xs text-gray-500 text-center uppercase tracking-widest mt-2">
|
||||
You can change these later in settings
|
||||
{t("setup.changeTheseLater")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -582,13 +581,13 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
{currentStep === 3 && (
|
||||
<div className="p-5 flex flex-col gap-3 mc-options-bg">
|
||||
<p className="text-gray-700 text-xs tracking-widest text-center uppercase">
|
||||
Emerald Launcher is now configured and ready to use!
|
||||
{t("setup.setupComplete")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<div className="flex items-center justify-between px-4 h-10 border-b border-white/10">
|
||||
<span className="text-gray-600 text-sm uppercase tracking-widest">
|
||||
Username
|
||||
{t("setup.username")}
|
||||
</span>
|
||||
<span className="text-[#FFFF55] font-bold mc-text-shadow">
|
||||
{tempUsername}
|
||||
|
|
@ -597,15 +596,15 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
{isMac && (
|
||||
<div className="flex items-center justify-between px-4 h-10 border-b border-white/10">
|
||||
<span className="text-gray-600 text-sm uppercase tracking-widest">
|
||||
Runtime
|
||||
{t("setup.runtime")}
|
||||
</span>
|
||||
<span className="text-green-400 font-bold">Ready</span>
|
||||
<span className="text-green-400 font-bold">{t("setup.ready")}</span>
|
||||
</div>
|
||||
)}
|
||||
{isLinux && selectedRunner && (
|
||||
<div className="flex items-center justify-between px-4 h-10 border-b border-white/10">
|
||||
<span className="text-gray-600 text-sm uppercase tracking-widest">
|
||||
Runner
|
||||
{t("setup.runner")}
|
||||
</span>
|
||||
<span className="text-green-400 font-bold">
|
||||
{runners.find((r) => r.id === selectedRunner)?.name}
|
||||
|
|
@ -614,7 +613,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
)}
|
||||
<div className="flex items-center justify-between px-4 h-10 border-b border-white/10">
|
||||
<span className="text-gray-600 text-sm uppercase tracking-widest">
|
||||
Animations
|
||||
{t("setup.animations")}
|
||||
</span>
|
||||
<div className="relative w-5 h-5">
|
||||
<img
|
||||
|
|
@ -636,7 +635,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
{!isAndroid && (
|
||||
<div className="flex items-center justify-between px-4 h-10">
|
||||
<span className="text-gray-600 text-sm uppercase tracking-widest">
|
||||
Discord RPC
|
||||
{t("setup.discordRpc")}
|
||||
</span>
|
||||
<div className="relative w-5 h-5">
|
||||
<img
|
||||
|
|
@ -695,7 +694,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
)}
|
||||
>
|
||||
<span className="tracking-widest uppercase text-xl">
|
||||
Back
|
||||
{t("common.back")}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -737,7 +736,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
|||
)}
|
||||
>
|
||||
<span className="tracking-widest uppercase text-xl">
|
||||
{currentStep === totalSteps - 1 ? "Finish" : "Next"}
|
||||
{currentStep === totalSteps - 1 ? t("common.finish") : t("common.next")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig, useSkin } from "../../context/LauncherContext";
|
||||
import { useLocalStorage } from "../../hooks/useLocalStorage";
|
||||
|
|
@ -110,6 +111,7 @@ function fillRegion(data: Uint8ClampedArray, w: number, h: number, x: number, y:
|
|||
}
|
||||
|
||||
const SkinEditorView = memo(function SkinEditorView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled, username } = useConfig();
|
||||
|
|
@ -392,7 +394,7 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
if (existing) {
|
||||
setStoredSkins(storedSkins.map((s) => (s.id === existing.id ? { ...s, url: dataUrl, isSlim: slim } : s)));
|
||||
} else {
|
||||
setStoredSkins([...storedSkins, { id: Date.now().toString(), name: "Custom Skin", url: dataUrl, isSlim: slim }]);
|
||||
setStoredSkins([...storedSkins, { id: Date.now().toString(), name: t("skinEditor.customSkin"), url: dataUrl, isSlim: slim }]);
|
||||
}
|
||||
setSkinUrl(dataUrl);
|
||||
setSkinIsSlim(slim);
|
||||
|
|
@ -406,7 +408,7 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
try {
|
||||
const safeName = (username || "skin").replace(/[^a-zA-Z0-9_-]/g, "") || "skin";
|
||||
const fileName = `${safeName}.png`;
|
||||
const path = await TauriService.saveFileDialog("Export Skin", fileName, []);
|
||||
const path = await TauriService.saveFileDialog(t("skinEditor.exportSkin"), fileName, []);
|
||||
if (!path) return;
|
||||
const outPath = path.toLowerCase().endsWith(".png") ? path : `${path}.png`;
|
||||
const res = await fetch(cvs.toDataURL("image/png"));
|
||||
|
|
@ -444,10 +446,10 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
}, [playBackSound, setActiveView, handleUndo, showPicker]);
|
||||
|
||||
const tools: { id: Tool; label: string }[] = [
|
||||
{ id: "pencil", label: "Pencil" },
|
||||
{ id: "eraser", label: "Eraser" },
|
||||
{ id: "eyedropper", label: "Pick" },
|
||||
{ id: "fill", label: "Fill" },
|
||||
{ id: "pencil", label: t("skinEditor.pencil") },
|
||||
{ id: "eraser", label: t("skinEditor.eraser") },
|
||||
{ id: "eyedropper", label: t("skinEditor.pick") },
|
||||
{ id: "fill", label: t("skinEditor.fill") },
|
||||
];
|
||||
|
||||
const gridScale = 8;
|
||||
|
|
@ -471,7 +473,7 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
inset: 3,
|
||||
};
|
||||
const getBtnStyle = (active: boolean) =>
|
||||
fillBg(active ? "/images/button_highlighted.png" : "/images/Button_Background.png");
|
||||
fillBg(active ? "/images/Layout_Button_Over.png" : "/images/Layout_Button_Bmp.png");
|
||||
const sliderBg = fillBg("/images/Button_Background2.png");
|
||||
const btnClass = (id: string) =>
|
||||
`text-2xl mc-text-shadow ${focusBtn === id ? "text-[#FFFF55]" : "text-white"}`;
|
||||
|
|
@ -516,9 +518,9 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-5xl h-full"
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[60%]
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[60%]
|
||||
text-center tracking-widest uppercase opacity-80 font-bold border-b-2 border-[#373737]">
|
||||
Skin Editor
|
||||
{t("skinEditor.title")}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-1 min-h-0 w-full gap-4 px-4 overflow-hidden">
|
||||
|
|
@ -533,7 +535,7 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
<div className="relative" ref={pickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
title="Color"
|
||||
title={t("skinEditor.color")}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setShowPicker((open) => !open);
|
||||
|
|
@ -639,6 +641,7 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
style={{ boxShadow: "inset 2px 2px 0 #000, inset -1px -1px 0 #555" }}
|
||||
>
|
||||
<span className="pl-2 text-white/50 text-sm mc-text-shadow">#</span>
|
||||
<div className="mc-textinput-outer w-full h-8">
|
||||
<input
|
||||
type="text"
|
||||
value={hexValue}
|
||||
|
|
@ -649,13 +652,14 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
setHexValue(hex.toUpperCase());
|
||||
if (hex.length === 6) setColorFromHex(`#${hex}`);
|
||||
}}
|
||||
className="w-full h-full px-1 text-sm text-white tracking-widest uppercase mc-text-shadow bg-transparent"
|
||||
className="mc-textinput w-full h-full px-1 text-sm text-white tracking-widest uppercase outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-10 flex items-center justify-center" style={sliderBg}>
|
||||
<span className="absolute z-10 text-sm text-white mc-text-shadow tracking-widest pointer-events-none">
|
||||
Alpha {Math.round(alpha * 100)}%
|
||||
{t("skinEditor.alpha")} {Math.round(alpha * 100)}%
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
|
|
@ -668,9 +672,9 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
className="mc-slider-custom w-[calc(100%+8px)] h-full z-20"
|
||||
/>
|
||||
</div>
|
||||
{renderToolBtn("slim", slim ? "Alex" : "Steve", slim, () => { playPressSound(); setSlim((v) => !v); })}
|
||||
{renderToolBtn("grid", "Grid", showGrid, () => { playPressSound(); setShowGrid((v) => !v); })}
|
||||
{renderToolBtn("undo", "Undo", false, () => { playPressSound(); handleUndo(); })}
|
||||
{renderToolBtn("slim", slim ? t("skinEditor.alex") : t("skinEditor.steve"), slim, () => { playPressSound(); setSlim((v) => !v); })}
|
||||
{renderToolBtn("grid", t("skinEditor.grid"), showGrid, () => { playPressSound(); setShowGrid((v) => !v); })}
|
||||
{renderToolBtn("undo", t("skinEditor.undo"), false, () => { playPressSound(); handleUndo(); })}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center min-w-0 min-h-0 mt-[-200px]">
|
||||
|
|
@ -698,9 +702,9 @@ const SkinEditorView = memo(function SkinEditorView() {
|
|||
|
||||
<div className="flex gap-4 mt-3 mb-2">
|
||||
{[
|
||||
{ id: "save", label: "Save", onClick: handleSave },
|
||||
{ id: "export", label: "Export", onClick: handleExport },
|
||||
{ id: "back", label: "Back", onClick: () => { playBackSound(); setActiveView("skins"); } },
|
||||
{ id: "save", label: t("skinEditor.save"), onClick: handleSave },
|
||||
{ id: "export", label: t("skinEditor.export"), onClick: handleExport },
|
||||
{ id: "back", label: t("skinEditor.back"), onClick: () => { playBackSound(); setActiveView("skins"); } },
|
||||
].map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { useLocalStorage } from "../../hooks/useLocalStorage";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
|
@ -8,10 +9,9 @@ import {
|
|||
useSkin,
|
||||
useConfig,
|
||||
} from "../../context/LauncherContext";
|
||||
import SkinViewer from "../common/SkinViewer";
|
||||
import { getSkinModelSnapshot } from "../common/SkinModel3D";
|
||||
import CapePreview from "../common/CapePreview";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
|
||||
interface SavedSkin {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -70,7 +70,7 @@ const DEFAULT_SKINS: SavedSkin[] = [
|
|||
url: "/Skins/PrismaChunk0.png",
|
||||
isSlim: false,
|
||||
},
|
||||
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true }, //neo: she's the best btw
|
||||
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true },
|
||||
{ id: "avalilac", name: "AvaLilac", url: "/Skins/ava.png", isSlim: true },
|
||||
{ id: "huckle", name: "Huckle", url: "/Skins/huckle.png", isSlim: true },
|
||||
{
|
||||
|
|
@ -85,6 +85,18 @@ const DEFAULT_SKINS: SavedSkin[] = [
|
|||
url: "/Skins/tranq.png",
|
||||
isSlim: true,
|
||||
},
|
||||
{
|
||||
id: "individul",
|
||||
name: "individul",
|
||||
url: "/Skins/individul.png",
|
||||
isSlim: false,
|
||||
},
|
||||
{
|
||||
id: "ency",
|
||||
name: "ency_pt",
|
||||
url: "/Skins/ency.png",
|
||||
isSlim: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_CAPES: SavedCape[] = [
|
||||
|
|
@ -92,56 +104,63 @@ const DEFAULT_CAPES: SavedCape[] = [
|
|||
{ id: "unused2", name: "Unused Cape 2", url: "/Capes/Unused_Cape_2.png" },
|
||||
];
|
||||
|
||||
const HeadPreview = memo(function HeadPreview({ src }: { src: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const SkinCardModel = memo(function SkinCardModel({
|
||||
src,
|
||||
slim,
|
||||
}: {
|
||||
src: string;
|
||||
slim?: boolean;
|
||||
}) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [snapshot, setSnapshot] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const cvs = canvasRef.current;
|
||||
if (!cvs) return;
|
||||
const ctx = cvs.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, cvs.width, cvs.height);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, cvs.width, cvs.height);
|
||||
if (img.height !== 32) {
|
||||
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, cvs.width, cvs.height);
|
||||
}
|
||||
let active = true;
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
const w = Math.max(48, Math.round(el.clientWidth * 2));
|
||||
const h = Math.max(64, Math.round(el.clientHeight * 2));
|
||||
getSkinModelSnapshot({ src, slim, width: w, height: h })
|
||||
.then((dataUrl) => {
|
||||
if (active) setSnapshot(dataUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
img.src = src;
|
||||
}, [src]);
|
||||
}, [src, slim]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={64}
|
||||
height={64}
|
||||
className="absolute w-full h-full"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div ref={boxRef} className="absolute inset-0">
|
||||
{snapshot ? (
|
||||
<img
|
||||
src={snapshot}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 w-full h-full object-contain drop-shadow-[0_6px_4px_rgba(0,0,0,0.6)] select-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-[#555] text-xs mc-text-shadow uppercase tracking-widest animate-pulse select-none">
|
||||
...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const SkinsView = memo(function SkinsView() {
|
||||
const { setActiveView, setIsUiHidden } = useUI();
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { isAndroid } = usePlatform();
|
||||
const {
|
||||
skinUrl,
|
||||
setSkinUrl,
|
||||
skinIsSlim,
|
||||
setSkinIsSlim,
|
||||
capeUrl,
|
||||
setCapeUrl,
|
||||
} = useSkin();
|
||||
|
||||
const config = useConfig();
|
||||
const { skinUrl, setSkinUrl, setSkinIsSlim, capeUrl, setCapeUrl } = useSkin();
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"skin" | "cape">("skin");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const capeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>(
|
||||
"lce-custom-skins",
|
||||
[],
|
||||
|
|
@ -250,7 +269,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
? e.message
|
||||
: typeof e === "string"
|
||||
? e
|
||||
: "Failed to fetch",
|
||||
: t("skins.failedToFetch"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
|
|
@ -364,6 +383,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
setFocusIndex(next < SKINS_START_INDEX ? 0 : next);
|
||||
}
|
||||
} else if (e.key === "Enter" && focusIndex !== null) {
|
||||
e.preventDefault();
|
||||
if (focusIndex === 0) {
|
||||
if (viewMode === "skin") handleImportClick();
|
||||
else capeFileInputRef.current?.click();
|
||||
|
|
@ -528,101 +548,105 @@ const SkinsView = memo(function SkinsView() {
|
|||
<motion.div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-3xl h-full outline-none"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: config.animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-5xl h-full 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">
|
||||
{viewMode === "skin" ? "Skin Library" : "Cape Library"}
|
||||
<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 opacity-80 font-bold">
|
||||
{viewMode === "skin" ? t("skins.skinLibrary") : t("skins.capeLibrary")}
|
||||
</h2>
|
||||
|
||||
<div className="w-full max-w-160 flex-1 min-h-0 mb-4 p-5 flex flex-col relative overflow-hidden">
|
||||
<div className="w-full flex items-center gap-4 ml-5 pb-4 mb-4 min-h-10">
|
||||
<button
|
||||
data-index="0"
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
if (viewMode === "skin") handleImportClick();
|
||||
else capeFileInputRef.current?.click();
|
||||
}}
|
||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 0
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{viewMode === "skin" ? "Import Skin" : "Import Cape"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-index="1"
|
||||
onMouseEnter={() => {
|
||||
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
|
||||
else if (viewMode === "cape" && !isCapeDeleteDisabled)
|
||||
setFocusIndex(1);
|
||||
}}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
if (viewMode === "skin") handleDeleteActive();
|
||||
else handleDeleteActiveCape();
|
||||
}}
|
||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
|
||||
(viewMode === "skin" && isActiveDefault) ||
|
||||
(viewMode === "cape" && isCapeDeleteDisabled)
|
||||
? "text-gray-400 opacity-80 cursor-not-allowed"
|
||||
: focusIndex === 1
|
||||
? "text-[#FFFF55]"
|
||||
: "text-white"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(viewMode === "skin" && isActiveDefault) ||
|
||||
(viewMode === "cape" && isCapeDeleteDisabled)
|
||||
? "url('/images/Button_Background2.png')"
|
||||
: focusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{viewMode === "skin" ? "Delete Skin" : "Delete Cape"}
|
||||
</button>
|
||||
|
||||
{viewMode === "skin" && (
|
||||
<div className="w-full max-w-5xl flex-1 min-h-0 mb-4 flex gap-4">
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
data-index="2"
|
||||
onMouseEnter={() => setFocusIndex(2)}
|
||||
data-index="0"
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setActiveView("skin-editor");
|
||||
if (viewMode === "skin") handleImportClick();
|
||||
else capeFileInputRef.current?.click();
|
||||
}}
|
||||
className={`w-40 h-10 flex items-center
|
||||
justify-center transition-colors text-2xl
|
||||
mc-text-shadow outline-none border-none hover:text-[#FFFF55]
|
||||
${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
className={`w-44 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 2
|
||||
focusIndex === 0
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Edit Skin
|
||||
{viewMode === "skin"
|
||||
? t("skins.importSkin")
|
||||
: t("skins.importCape")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
<div className="flex justify-end z-10">
|
||||
<button
|
||||
data-index="1"
|
||||
onMouseEnter={() => {
|
||||
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
|
||||
else if (viewMode === "cape" && !isCapeDeleteDisabled)
|
||||
setFocusIndex(1);
|
||||
}}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
if (viewMode === "skin") handleDeleteActive();
|
||||
else handleDeleteActiveCape();
|
||||
}}
|
||||
className={`w-44 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
|
||||
(viewMode === "skin" && isActiveDefault) ||
|
||||
(viewMode === "cape" && isCapeDeleteDisabled)
|
||||
? "text-gray-400 opacity-80 cursor-not-allowed"
|
||||
: focusIndex === 1
|
||||
? "text-[#FFFF55]"
|
||||
: "text-white"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(viewMode === "skin" && isActiveDefault) ||
|
||||
(viewMode === "cape" && isCapeDeleteDisabled)
|
||||
? "url('/images/Button_Background2.png')"
|
||||
: focusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{viewMode === "skin"
|
||||
? t("skins.deleteSkin")
|
||||
: t("skins.deleteCape")}
|
||||
</button>
|
||||
|
||||
{viewMode === "skin" && (
|
||||
<button
|
||||
data-index="2"
|
||||
onMouseEnter={() => setFocusIndex(2)}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setActiveView("skin-editor");
|
||||
}}
|
||||
className={`w-44 h-10 flex items-center
|
||||
justify-center transition-colors text-2xl
|
||||
mc-text-shadow outline-none border-none hover:text-[#FFFF55]
|
||||
${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 2
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{t("skins.editSkin")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-4" />
|
||||
<button
|
||||
data-index={viewMode === "skin" ? 3 : 2}
|
||||
onMouseEnter={() => setFocusIndex(viewMode === "skin" ? 3 : 2)}
|
||||
|
|
@ -630,7 +654,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
playPressSound();
|
||||
setViewMode(viewMode === "skin" ? "cape" : "skin");
|
||||
}}
|
||||
className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
|
||||
className="mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all"
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === (viewMode === "skin" ? 3 : 2)
|
||||
|
|
@ -642,174 +666,161 @@ const SkinsView = memo(function SkinsView() {
|
|||
>
|
||||
<img
|
||||
src="/images/Update_Icon.png"
|
||||
alt={viewMode === "skin" ? "Switch to Cape" : "Switch to Skin"}
|
||||
alt={
|
||||
viewMode === "skin"
|
||||
? t("skins.switchToCape")
|
||||
: t("skins.switchToSkin")
|
||||
}
|
||||
className="w-8 h-8 object-contain pointer-events-none drop-shadow-md"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".png"
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={capeFileInputRef}
|
||||
onChange={handleCapeFileChange}
|
||||
accept=".png"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".png"
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={capeFileInputRef}
|
||||
onChange={handleCapeFileChange}
|
||||
accept=".png"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 flex flex-wrap gap-x-4 gap-y-6 items-start content-start justify-center hidden-scrollbar">
|
||||
{viewMode === "skin" ? (
|
||||
savedSkins.map((skin, i) => {
|
||||
const idx = SKINS_START_INDEX + i;
|
||||
const isActive = activeSkinId
|
||||
? activeSkinId === skin.id
|
||||
: skinUrl === skin.url;
|
||||
const isFocused = focusIndex === idx;
|
||||
return (
|
||||
<div
|
||||
key={skin.id}
|
||||
data-index={idx}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className="flex flex-col items-center gap-1 w-32 outline-none"
|
||||
>
|
||||
<div className="h-4 flex items-center justify-center gap-1">
|
||||
{isActive && (
|
||||
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{skin.isSlim && (
|
||||
<span className="bg-purple-500/50 border border-purple-500/80 text-white px-1 text-[10px] uppercase rounded">
|
||||
Slim
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mc-options-bg flex-1 min-h-0 overflow-y-auto p-4 hidden-scrollbar">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{viewMode === "skin" ? (
|
||||
savedSkins.map((skin, i) => {
|
||||
const idx = SKINS_START_INDEX + i;
|
||||
const isActive = activeSkinId
|
||||
? activeSkinId === skin.id
|
||||
: skinUrl === skin.url;
|
||||
const isFocused = focusIndex === idx;
|
||||
const isHighlight = isActive || isFocused;
|
||||
return (
|
||||
<div
|
||||
key={skin.id}
|
||||
data-index={idx}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className="flex flex-col items-center gap-1.5 outline-none"
|
||||
>
|
||||
<div
|
||||
onClick={() => handleSkinSelect(skin)}
|
||||
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer flex items-center justify-center ${isHighlight ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
style={{ aspectRatio: "3 / 4" }}
|
||||
>
|
||||
<SkinCardModel
|
||||
src={skin.url}
|
||||
slim={Boolean(skin.isSlim)}
|
||||
/>
|
||||
{isHighlight && (
|
||||
<div className="pointer-events-none absolute" />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={skin.name}
|
||||
maxLength={16}
|
||||
onChange={(e) =>
|
||||
handleNameChange(skin.id, e.target.value)
|
||||
}
|
||||
className={`mc-textinput w-full h-9 px-2 text-sm text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isHighlight ? "text-[#FFFF55]" : "text-white"} ${isDefaultSkin(skin.id) ? "pointer-events-none" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
spellCheck={false}
|
||||
readOnly={isDefaultSkin(skin.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
onClick={() => handleSkinSelect(skin)}
|
||||
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
>
|
||||
<HeadPreview src={skin.url} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={skin.name}
|
||||
maxLength={16}
|
||||
onChange={(e) => handleNameChange(skin.id, e.target.value)}
|
||||
className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors relative z-10 ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultSkin(skin.id) ? "pointer-events-none" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
spellCheck={false}
|
||||
readOnly={isDefaultSkin(skin.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
data-index={SKINS_START_INDEX}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)}
|
||||
className="flex flex-col items-center gap-1 w-32 outline-none"
|
||||
>
|
||||
<div className="h-4 flex items-center justify-center gap-1">
|
||||
{isActiveCapeDefault && (
|
||||
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setCapeUrl(null);
|
||||
setActiveCapeId(null);
|
||||
}}
|
||||
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none flex items-center justify-center ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
>
|
||||
<span className="text-gray-500 text-2xl">X</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "text-[#FFFF55]" : "text-white"}`}
|
||||
>
|
||||
No Cape
|
||||
</span>
|
||||
</div>
|
||||
{savedCapes.map((cape, i) => {
|
||||
const idx = SKINS_START_INDEX + 1 + i;
|
||||
const isActive = activeCapeId
|
||||
? activeCapeId === cape.id
|
||||
: capeUrl === cape.url;
|
||||
const isFocused = focusIndex === idx;
|
||||
return (
|
||||
<div
|
||||
key={cape.id}
|
||||
data-index={idx}
|
||||
data-index={SKINS_START_INDEX}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className="flex flex-col items-center gap-1 w-32 outline-none"
|
||||
onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)}
|
||||
className="flex flex-col items-center gap-1.5 outline-none"
|
||||
>
|
||||
<div className="h-4 flex items-center justify-center gap-1">
|
||||
{isActive && (
|
||||
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
|
||||
Active
|
||||
</span>
|
||||
<div
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setCapeUrl(null);
|
||||
setActiveCapeId(null);
|
||||
}}
|
||||
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer flex items-center justify-center ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
style={{ aspectRatio: "3 / 4" }}
|
||||
>
|
||||
<span className="text-gray-500 text-4xl select-none">
|
||||
X
|
||||
</span>
|
||||
{(isActiveCapeDefault ||
|
||||
focusIndex === SKINS_START_INDEX) && (
|
||||
<div className="pointer-events-none absolute" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleCapeSelect(cape)}
|
||||
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
<span
|
||||
className={`text-center outline-none border-none text-sm mc-text-shadow w-full truncate transition-colors ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "text-[#FFFF55]" : "text-white"}`}
|
||||
>
|
||||
<CapePreview src={cape.url} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={cape.name}
|
||||
maxLength={16}
|
||||
onChange={(e) =>
|
||||
handleCapeNameChange(cape.id, e.target.value)
|
||||
}
|
||||
className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors relative z-10 ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
spellCheck={false}
|
||||
readOnly={isDefaultCape(cape.id)}
|
||||
/>
|
||||
{t("skins.noCape")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{savedCapes.map((cape, i) => {
|
||||
const idx = SKINS_START_INDEX + 1 + i;
|
||||
const isActive = activeCapeId
|
||||
? activeCapeId === cape.id
|
||||
: capeUrl === cape.url;
|
||||
const isFocused = focusIndex === idx;
|
||||
const isHighlight = isActive || isFocused;
|
||||
return (
|
||||
<div
|
||||
key={cape.id}
|
||||
data-index={idx}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className="flex flex-col items-center gap-1.5 outline-none"
|
||||
>
|
||||
<div
|
||||
onClick={() => handleCapeSelect(cape)}
|
||||
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer ${isHighlight ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
|
||||
style={{ aspectRatio: "3 / 4" }}
|
||||
>
|
||||
<CapePreview src={cape.url} />
|
||||
{isHighlight && (
|
||||
<div className="pointer-events-none absolute inset-0 shadow-[inset_0_0_16px_rgba(255,255,85,0.4)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
value={cape.name}
|
||||
maxLength={16}
|
||||
onChange={(e) =>
|
||||
handleCapeNameChange(cape.id, e.target.value)
|
||||
}
|
||||
className={`mc-textinput w-full h-9 px-2 text-sm text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isHighlight ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
spellCheck={false}
|
||||
readOnly={isDefaultCape(cape.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-160 shadow-2xl flex flex-col items-center">
|
||||
<SkinViewer
|
||||
username=""
|
||||
setUsername={() => {}}
|
||||
playPressSound={playPressSound}
|
||||
skinUrl={skinUrl}
|
||||
setSkinUrl={setSkinUrl}
|
||||
capeUrl={capeUrl}
|
||||
setActiveView={setActiveView}
|
||||
setIsUiHidden={setIsUiHidden}
|
||||
isFocusedSection={false}
|
||||
onNavigateRight={() => {}}
|
||||
hideControls
|
||||
style={{ top: "45%" }}
|
||||
slim={skinIsSlim}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isAndroid && (
|
||||
<button
|
||||
data-index={BACK_BUTTON_INDEX}
|
||||
|
|
@ -822,13 +833,13 @@ const SkinsView = memo(function SkinsView() {
|
|||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === BACK_BUTTON_INDEX
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
? "url('/images/Layout_Button_Over.png')"
|
||||
: "url('/images/Layout_Button_Bmp.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("common.back")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -844,7 +855,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
}}
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mb-6 tracking-widest uppercase font-bold text-center">
|
||||
Import Skin
|
||||
{t("skins.importSkin")}
|
||||
</h2>
|
||||
|
||||
{!importMode ? (
|
||||
|
|
@ -865,7 +876,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
From File
|
||||
{t("skins.fromFile")}
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setModalFocusIndex(1)}
|
||||
|
|
@ -884,21 +895,23 @@ const SkinsView = memo(function SkinsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
From Username
|
||||
{t("skins.fromUsername")}
|
||||
</button>
|
||||
</div>
|
||||
) : importMode === "username" ? (
|
||||
<div className="flex flex-col gap-4 w-full px-4 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Minecraft Username"
|
||||
value={importUsername}
|
||||
onChange={(e) => setImportUsername(e.target.value)}
|
||||
onFocus={() => setModalFocusIndex(0)}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
className={`w-full h-12 bg-black/50 border-2 text-white px-4 text-xl outline-none transition-colors relative z-10 ${modalFocusIndex === 0 ? "border-[#FFFF55]" : "border-[#373737]"}`}
|
||||
/>
|
||||
<div className="mc-textinput-outer w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("skins.minecraftUsername")}
|
||||
value={importUsername}
|
||||
onChange={(e) => setImportUsername(e.target.value)}
|
||||
onFocus={() => setModalFocusIndex(0)}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-xl outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{importError && (
|
||||
<span className="text-red-400 text-sm text-center mc-text-shadow">
|
||||
|
|
@ -921,14 +934,16 @@ const SkinsView = memo(function SkinsView() {
|
|||
}}
|
||||
>
|
||||
{isImporting
|
||||
? "Fetching..."
|
||||
: `Fetch ${viewMode === "skin" ? "Skin" : "Cape"}`}
|
||||
? t("skins.fetching")
|
||||
: t("skins.fetch", {
|
||||
type: viewMode === "skin" ? "Skin" : "Cape",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : importMode === "model" ? (
|
||||
<div className="flex flex-col gap-4 w-full px-4 mb-2">
|
||||
<span className="text-white/80 text-sm text-center mb-2 mc-text-shadow">
|
||||
Choose the player model type for this skin:
|
||||
{t("skins.chooseModelType")}:
|
||||
</span>
|
||||
<button
|
||||
onMouseEnter={() => setModalFocusIndex(0)}
|
||||
|
|
@ -946,7 +961,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Normal (Wide Arms)
|
||||
{t("skins.normalWideArms")}
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setModalFocusIndex(1)}
|
||||
|
|
@ -964,7 +979,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Slim (Thin Arms)
|
||||
{t("skins.slimThinArms")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -989,7 +1004,7 @@ const SkinsView = memo(function SkinsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useConfig, useAudio, useUI } from "../../context/LauncherContext";
|
||||
import { SwfImage, SwfService, SwfTag } from "../../services/SwfService";
|
||||
export default function SwfView() {
|
||||
const { t } = useTranslation();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const { playBackSound, playPressSound } = useAudio();
|
||||
const { setActiveView } = useUI();
|
||||
|
|
@ -79,10 +81,10 @@ export default function SwfView() {
|
|||
if (extracted.length > 0) {
|
||||
setSelectedImageId(extracted[0].id);
|
||||
}
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
showNotification(t("swfEditor.loaded", { name: file.name }));
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
showNotification("Failed to process SWF", "error");
|
||||
showNotification(t("swfEditor.failedToProcess"), "error");
|
||||
setImages([]);
|
||||
setSwfData(null);
|
||||
}
|
||||
|
|
@ -128,7 +130,7 @@ export default function SwfView() {
|
|||
a.download = `${img.name || `image_${img.id}`}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification(`Exported: ${img.name || img.id}`);
|
||||
showNotification(t("swfEditor.exported", { name: img.name || img.id }));
|
||||
};
|
||||
|
||||
const handleReplace = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -154,7 +156,7 @@ export default function SwfView() {
|
|||
return next;
|
||||
});
|
||||
|
||||
showNotification("Image Replaced", "success");
|
||||
showNotification(t("swfEditor.imageReplaced"), "success");
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
|
|
@ -169,7 +171,7 @@ export default function SwfView() {
|
|||
a.download = fileName || "output.swf";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("SWF Saved Successfully");
|
||||
showNotification(t("swfEditor.savedSuccessfully"));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -182,7 +184,7 @@ export default function SwfView() {
|
|||
>
|
||||
<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">
|
||||
SWF Editor
|
||||
{t("swfEditor.title")}
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
|
|
@ -190,7 +192,7 @@ export default function SwfView() {
|
|||
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 SWF
|
||||
{t("swfEditor.openSwf")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveSwf}
|
||||
|
|
@ -198,7 +200,7 @@ export default function SwfView() {
|
|||
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${!swfData ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save SWF
|
||||
{t("swfEditor.saveSwf")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -210,19 +212,21 @@ export default function SwfView() {
|
|||
<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/arc.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 an SWF file to begin editing</h3>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">{t("swfEditor.openToBegin")}</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex gap-4 overflow-hidden">
|
||||
<div className="w-1/3 flex flex-col p-4" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="mb-4">
|
||||
<div className="mc-textinput-outer">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
placeholder={t("swfEditor.searchAssets")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar space-y-1">
|
||||
{filteredImages.map(img => (
|
||||
|
|
@ -248,7 +252,7 @@ export default function SwfView() {
|
|||
<AnimatePresence mode="wait">
|
||||
{!selectedImage ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-white/20 italic gap-4">
|
||||
<span>Select an image to view details</span>
|
||||
<span>{t("swfEditor.selectImageViewDetails")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
|
|
@ -263,9 +267,9 @@ export default function SwfView() {
|
|||
{selectedImage.name || `Bitmap ${selectedImage.id}`}
|
||||
</h3>
|
||||
<div className="flex gap-4 text-xs uppercase tracking-widest text-white/40">
|
||||
<span>Character ID: <span className="text-white/80">{selectedImage.id}</span></span>
|
||||
<span>Type: <span className="text-white/80">{selectedImage.type}</span></span>
|
||||
{selectedImage.width && <span>Size: <span className="text-white/80">{selectedImage.width}x{selectedImage.height}</span></span>}
|
||||
<span>{t("swfEditor.characterId")} <span className="text-white/80">{selectedImage.id}</span></span>
|
||||
<span>{t("swfEditor.type")} <span className="text-white/80">{selectedImage.type}</span></span>
|
||||
{selectedImage.width && <span>{t("swfEditor.size")} <span className="text-white/80">{selectedImage.width}x{selectedImage.height}</span></span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -279,7 +283,7 @@ export default function SwfView() {
|
|||
alt={`Bitmap ${selectedImage.id}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-white/20 italic">Loading Preview...</div>
|
||||
<div className="text-white/20 italic">{t("swfEditor.loadingPreview")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -289,14 +293,14 @@ export default function SwfView() {
|
|||
className="flex-1 py-3 text-white mc-text-shadow text-lg transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Export Bitmap
|
||||
{t("swfEditor.exportBitmap")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => replaceInputRef.current?.click()}
|
||||
className="flex-1 py-3 text-white mc-text-shadow text-lg transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Replace Bitmap
|
||||
{t("swfEditor.replaceBitmap")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -309,9 +313,9 @@ export default function SwfView() {
|
|||
<button
|
||||
onClick={handleBack}
|
||||
className="w-72 h-14 shrink-0 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: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
style={{ backgroundImage: "url('/images/Layout_Button_Bmp.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
{t("swfEditor.back")}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
@ -333,7 +337,7 @@ export default function SwfView() {
|
|||
<style>{`
|
||||
.pattern-checkerboard {
|
||||
background-color: #2a2a2a;
|
||||
background-image: linear-gradient(45deg, #1a1a1a 25%, transparent 25%, transparent 75%, #1a1a1a 75%, #1a1a1a),
|
||||
background-image: linear-gradient(45deg, #1a1a1a 25%, transparent 25%, transparent 75%, #1a1a1a 75%, #1a1a1a),
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%, transparent 75%, #1a1a1a 75%, #1a1a1a);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { useState, useEffect, useRef, memo } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService, PlaytimeResponse } from "../../services/TauriService";
|
||||
import CustomTUModal from "../modals/CustomTUModal";
|
||||
|
|
@ -53,18 +61,12 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
|
|||
);
|
||||
});
|
||||
|
||||
function formatPlaytime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m`;
|
||||
return seconds > 0 ? `${seconds}s` : "";
|
||||
}
|
||||
|
||||
const ROW_ESTIMATE = 52;
|
||||
const ROW_GAP = 4;
|
||||
const VersionsView = memo(function VersionsView() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const {
|
||||
profile: selectedProfile,
|
||||
setProfile: setSelectedProfile,
|
||||
animationsEnabled,
|
||||
instanceLaunchArgs,
|
||||
|
|
@ -81,7 +83,6 @@ const VersionsView = memo(function VersionsView() {
|
|||
addCustomEdition: onAddEdition,
|
||||
updateCustomEdition: onUpdateEdition,
|
||||
downloadingIds,
|
||||
downloadProgress,
|
||||
updatesAvailable,
|
||||
addToSteam,
|
||||
cycleBranch,
|
||||
|
|
@ -93,8 +94,7 @@ const VersionsView = memo(function VersionsView() {
|
|||
const { isAndroid } = usePlatform();
|
||||
const visibleEditions = editions.filter(
|
||||
(e) =>
|
||||
e.url !== HIDDEN_INSTANCE_URL ||
|
||||
installedVersions.includes(e.instanceId),
|
||||
e.url !== HIDDEN_INSTANCE_URL || installedVersions.includes(e.instanceId),
|
||||
);
|
||||
const [focusIndex, setFocusIndex] = useState<number>(0);
|
||||
const [focusBtn, setFocusBtn] = useState<number>(0);
|
||||
|
|
@ -114,11 +114,11 @@ const VersionsView = memo(function VersionsView() {
|
|||
} | null>(null);
|
||||
const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false);
|
||||
const [customizeTarget, setCustomizeTarget] = useState<Edition | null>(null);
|
||||
const [playtimeMap, setPlaytimeMap] = useState<
|
||||
const [_playtimeMap, setPlaytimeMap] = useState<
|
||||
Record<string, PlaytimeResponse>
|
||||
>({});
|
||||
const [initialPath, setInitialPath] = useState<string>("");
|
||||
const [hoveredBtn, setHoveredBtn] = useState<{
|
||||
const [_hoveredBtn, setHoveredBtn] = useState<{
|
||||
row: number;
|
||||
btn: string;
|
||||
} | null>(null);
|
||||
|
|
@ -135,9 +135,168 @@ const VersionsView = memo(function VersionsView() {
|
|||
name: string;
|
||||
} | null>(null);
|
||||
const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({});
|
||||
const [thumb, setThumb] = useState({ height: 0, top: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const ITEM_COUNT = visibleEditions.length + 3;
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
const dragOffsetRef = useRef(0);
|
||||
const draggingRef = useRef(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [menuDir, setMenuDir] = useState<"down" | "up">("down");
|
||||
const ITEM_COUNT = visibleEditions.length;
|
||||
const [visibleRange, setVisibleRange] = useState<{
|
||||
start: number;
|
||||
end: number;
|
||||
}>({
|
||||
start: 0,
|
||||
end: visibleEditions.length ? visibleEditions.length - 1 : 0,
|
||||
});
|
||||
const itemHeightsRef = useRef<number[]>([]);
|
||||
const observersRef = useRef<Map<number, ResizeObserver>>(new Map());
|
||||
const visibleEditionsRef = useRef(visibleEditions);
|
||||
visibleEditionsRef.current = visibleEditions;
|
||||
const focusIndexRef = useRef(focusIndex);
|
||||
const openMenuIdRef = useRef(openMenuId);
|
||||
const updateThumb = useCallback(() => {
|
||||
const container = listRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!container || !track) return;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
const scrollable = scrollHeight - clientHeight;
|
||||
if (scrollable <= 0) {
|
||||
setThumb((prev) =>
|
||||
prev.height === 0 && prev.top === 0 ? prev : { height: 0, top: 0 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const trackHeight = track.clientHeight;
|
||||
const HANDLE = 40;
|
||||
const STEP = HANDLE + 4;
|
||||
const maxTop = trackHeight - HANDLE;
|
||||
const maxIndex = Math.max(1, Math.floor(maxTop / STEP));
|
||||
const index = Math.min(
|
||||
maxIndex,
|
||||
Math.round((container.scrollTop / scrollable) * maxIndex),
|
||||
);
|
||||
const top = index * STEP;
|
||||
setThumb((prev) =>
|
||||
prev.top === top && prev.height === HANDLE
|
||||
? prev
|
||||
: { height: HANDLE, top },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleThumbPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
dragOffsetRef.current =
|
||||
e.clientY - e.currentTarget.getBoundingClientRect().top;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handleThumbPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingRef.current) return;
|
||||
const container = listRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!container || !track) return;
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const trackTop = trackRect.top + track.clientTop;
|
||||
const y = e.clientY - trackTop - dragOffsetRef.current;
|
||||
const trackHeight = track.clientHeight;
|
||||
const maxTop = Math.max(0, trackHeight - thumb.height);
|
||||
const scrollable = container.scrollHeight - container.clientHeight;
|
||||
if (scrollable <= 0) return;
|
||||
const clampedY = Math.min(Math.max(y, 0), maxTop);
|
||||
container.scrollTop = (clampedY / maxTop) * scrollable;
|
||||
};
|
||||
|
||||
const handleThumbPointerUp = () => {
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
const updateVisibleRange = useCallback(() => {
|
||||
const container = listRef.current;
|
||||
const editions = visibleEditionsRef.current;
|
||||
if (!container) return;
|
||||
const scrollTop = container.scrollTop;
|
||||
const viewportHeight = container.clientHeight;
|
||||
const heights = itemHeightsRef.current;
|
||||
const n = editions.length;
|
||||
let acc = 0;
|
||||
let start = n;
|
||||
let end = -1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const itemTop = acc;
|
||||
const itemNeo = acc + (heights[i] ?? ROW_ESTIMATE); //neo: me fr fr :3
|
||||
if (itemTop >= scrollTop && itemNeo <= scrollTop + viewportHeight) {
|
||||
if (start === n) start = i;
|
||||
end = i;
|
||||
}
|
||||
acc += (heights[i] ?? ROW_ESTIMATE) + ROW_GAP;
|
||||
}
|
||||
if (start === n) start = 0;
|
||||
if (end < 0) end = n - 1;
|
||||
const fi = focusIndexRef.current;
|
||||
if (fi >= 0 && fi < n) {
|
||||
start = Math.min(start, fi);
|
||||
end = Math.max(end, fi);
|
||||
}
|
||||
const menuId = openMenuIdRef.current;
|
||||
if (menuId) {
|
||||
const mi = editions.findIndex((e) => e.id === menuId);
|
||||
if (mi >= 0) {
|
||||
start = Math.min(start, mi);
|
||||
end = Math.max(end, mi);
|
||||
}
|
||||
}
|
||||
setVisibleRange((prev) =>
|
||||
prev.start === start && prev.end === end ? prev : { start, end },
|
||||
);
|
||||
updateThumb();
|
||||
}, [updateThumb]);
|
||||
|
||||
const setRowRef = (index: number) => (el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
let ro = observersRef.current.get(index);
|
||||
if (!ro) {
|
||||
ro = new ResizeObserver(() => {
|
||||
itemHeightsRef.current[index] = el.offsetHeight;
|
||||
updateVisibleRange();
|
||||
});
|
||||
observersRef.current.set(index, ro);
|
||||
}
|
||||
ro.observe(el);
|
||||
} else {
|
||||
const ro = observersRef.current.get(index);
|
||||
if (ro) {
|
||||
ro.disconnect();
|
||||
observersRef.current.delete(index);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
focusIndexRef.current = focusIndex;
|
||||
}, [focusIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
openMenuIdRef.current = openMenuId;
|
||||
}, [openMenuId]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = listRef.current;
|
||||
if (!container) return;
|
||||
const ro = new ResizeObserver(() => updateVisibleRange());
|
||||
ro.observe(container);
|
||||
updateVisibleRange();
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
observersRef.current.forEach((obs) => obs.disconnect());
|
||||
observersRef.current.clear();
|
||||
};
|
||||
}, [updateVisibleRange]);
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.activeElement?.tagName === "INPUT") return;
|
||||
|
|
@ -172,18 +331,16 @@ const VersionsView = memo(function VersionsView() {
|
|||
e.preventDefault();
|
||||
if (focusIndex < visibleEditions.length) {
|
||||
const edition = visibleEditions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
const maxBtn = isInstalled ? 1 : 2;
|
||||
setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1));
|
||||
}
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
if (focusIndex < visibleEditions.length) {
|
||||
const edition = visibleEditions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
const maxBtn = isInstalled ? 1 : 2;
|
||||
setFocusBtn((prev) => (prev >= maxBtn ? 0 : prev + 1));
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
|
|
@ -193,23 +350,19 @@ const VersionsView = memo(function VersionsView() {
|
|||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
const isDownloading = downloadingIds.includes(edition.instanceId);
|
||||
if (focusBtn === 0) {
|
||||
if (isInstalled) {
|
||||
playPressSound();
|
||||
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
|
||||
} else {
|
||||
if (!isDownloading) {
|
||||
playPressSound();
|
||||
toggleInstall(edition.instanceId);
|
||||
} else {
|
||||
handleCancelDownload(edition.instanceId);
|
||||
}
|
||||
}
|
||||
} else if (focusBtn === 1 && !isInstalled) {
|
||||
playPressSound();
|
||||
setOpenMenuId(null);
|
||||
setSelectedProfile(edition.instanceId);
|
||||
} else if (focusBtn === 2 || (focusBtn === 1 && isInstalled)) {
|
||||
playPressSound();
|
||||
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
|
||||
} else if (focusBtn === 2) {
|
||||
} else if (focusBtn === 1 && !isInstalled) {
|
||||
playPressSound();
|
||||
cycleBranch(edition.id);
|
||||
if (isDownloading) {
|
||||
handleCancelDownload(edition.instanceId);
|
||||
} else {
|
||||
toggleInstall(edition.instanceId);
|
||||
}
|
||||
}
|
||||
} else if (focusIndex === visibleEditions.length) {
|
||||
playPressSound();
|
||||
|
|
@ -262,6 +415,23 @@ const VersionsView = memo(function VersionsView() {
|
|||
}
|
||||
}, [focusIndex]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!openMenuId) return;
|
||||
const container = listRef.current;
|
||||
const menu = menuRef.current;
|
||||
if (!container || !menu) return;
|
||||
const row = menu.closest("[data-index]") as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const menuHeight = menu.offsetHeight;
|
||||
const spaceBelow = containerRect.bottom - rowRect.bottom;
|
||||
const spaceAbove = rowRect.top - containerRect.top;
|
||||
setMenuDir(
|
||||
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "up" : "down",
|
||||
);
|
||||
}, [openMenuId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlaytimes = async () => {
|
||||
const map: Record<string, PlaytimeResponse> = {};
|
||||
|
|
@ -303,11 +473,8 @@ const VersionsView = memo(function VersionsView() {
|
|||
}, [installedVersions]);
|
||||
|
||||
const handleEditionClick = (edition: Edition, index: number) => {
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
if (isInstalled) {
|
||||
playPressSound();
|
||||
setSelectedProfile(edition.instanceId);
|
||||
}
|
||||
playPressSound();
|
||||
setSelectedProfile(edition.instanceId);
|
||||
setFocusIndex(index);
|
||||
};
|
||||
|
||||
|
|
@ -324,7 +491,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
};
|
||||
|
||||
const handleImportWorld = (instanceId: string) => {
|
||||
const edition = visibleEditions.find((e: Edition) => e.instanceId === instanceId);
|
||||
const edition = visibleEditions.find(
|
||||
(e: Edition) => e.instanceId === instanceId,
|
||||
);
|
||||
setImportWorldTarget({ id: instanceId, name: edition?.name ?? instanceId });
|
||||
setIsImportWorldModalOpen(true);
|
||||
};
|
||||
|
|
@ -338,58 +507,64 @@ const VersionsView = memo(function VersionsView() {
|
|||
transition={{ duration: animationsEnabled ? 0.25 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-5xl outline-none"
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase font-bold">
|
||||
Versions
|
||||
</h2>
|
||||
|
||||
<div className="w-full min-w-[480px] p-6 mb-4 mc-options-bg">
|
||||
<div className="w-full min-w-120 p-1 mb-3 mc-options-bg relative flex items-stretch">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="w-full max-h-[45vh] overflow-y-auto py-2 custom-scrollbar"
|
||||
onScroll={updateVisibleRange}
|
||||
className="flex-1 min-w-0 h-85 max-h-85 overflow-y-auto snap-y snap-mandatory mc-versionrecess hidden-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 hidden-scrollbar">
|
||||
{visibleEditions.map((edition: Edition, i: number) => {
|
||||
if (i < visibleRange.start || i > visibleRange.end) {
|
||||
return (
|
||||
<div
|
||||
key={edition.id}
|
||||
data-index={i}
|
||||
style={{
|
||||
height: itemHeightsRef.current[i] ?? ROW_ESTIMATE,
|
||||
}}
|
||||
className="w-[calc(100%-20px)] shrink-0 snap-start"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isInstalled = installedVersions.includes(
|
||||
edition.instanceId,
|
||||
);
|
||||
const hasAnyInstall = installedVersions.length > 0;
|
||||
const isSelected =
|
||||
hasAnyInstall && selectedProfile === edition.instanceId;
|
||||
const isFocused = focusIndex === i;
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const isDownloading = downloadingIds.includes(edition.instanceId);
|
||||
const isComingSoon = edition.comingSoon;
|
||||
|
||||
const isDownloadFocused =
|
||||
!isInstalled && isFocused && focusBtn === 1;
|
||||
const isGearFocused =
|
||||
isFocused && focusBtn === (isInstalled ? 1 : 2);
|
||||
return (
|
||||
<div
|
||||
key={edition.id}
|
||||
data-index={i}
|
||||
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${
|
||||
isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
|
||||
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${
|
||||
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
|
||||
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
|
||||
ref={setRowRef(i)}
|
||||
className={`w-[calc(100%-20px)] snap-start flex items-center gap-3 ${!isFocused ? "mc-button-ninesliced" : "mc-button-ninesliced-selected"} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
|
||||
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
|
||||
onMouseLeave={() => setFocusIndex(-1)} //neo: you see this, smartcmd?
|
||||
onClick={() =>
|
||||
!isComingSoon && handleEditionClick(edition, i)
|
||||
}
|
||||
>
|
||||
<div className="w-6 flex items-center justify-center flex-shrink-0">
|
||||
{isDownloading ? (
|
||||
<span className="text-xs text-gray-400 font-bold">
|
||||
{Math.floor(downloadProgress[edition.instanceId] || 0)}%
|
||||
</span>
|
||||
) : edition.logo ? (
|
||||
<div className="w-12 h-12 flex items-center justify-center shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat">
|
||||
{edition.logo ? (
|
||||
edition.logo.startsWith("http") ||
|
||||
edition.logo.startsWith("/images") ? (
|
||||
<img
|
||||
src={edition.logo}
|
||||
alt=""
|
||||
className={`w-6 h-6 object-contain ${isComingSoon ? "opacity-40 grayscale" : ""}`}
|
||||
className={`${!edition.transparentLogo ? "w-12 h-12" : "w-10 h-10"} object-contain ${isComingSoon ? "opacity-40 grayscale" : ""}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenshotImage
|
||||
path={edition.logo}
|
||||
alt=""
|
||||
className={`w-6 h-6 object-contain ${isComingSoon ? "opacity-40 grayscale" : ""}`}
|
||||
className={`${!edition.transparentLogo ? "w-12 h-12" : "w-10 h-10"} object-contain ${isComingSoon ? "opacity-40 grayscale" : ""}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)
|
||||
|
|
@ -399,61 +574,14 @@ const VersionsView = memo(function VersionsView() {
|
|||
</div>
|
||||
|
||||
<div
|
||||
onClick={() =>
|
||||
!isComingSoon && handleEditionClick(edition, i)
|
||||
}
|
||||
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${
|
||||
focusIndex === i && focusBtn === 0 && !isComingSoon
|
||||
? "ring-2 ring-white"
|
||||
: ""
|
||||
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
|
||||
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${isComingSoon ? "cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xl tracking-wide truncate ${
|
||||
isSelected ? "text-white" : "text-black"
|
||||
}`}
|
||||
style={{ textShadow: "none" }}
|
||||
className={`text-xl tracking-wide truncate mc-text-shadow ${!isFocused ? "text-white" : "text-[#ffff00]"}`}
|
||||
>
|
||||
{edition.name}
|
||||
</span>
|
||||
{isInstalled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setPlaytimeTarget({
|
||||
id: edition.instanceId,
|
||||
name: edition.name,
|
||||
});
|
||||
setIsPlaytimeModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2 py-1 bg-black/60 border border-[#555] hover:border-[#FFFF55] group transition-colors flex-shrink-0"
|
||||
title="View playtime"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#AAAAAA"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="group-hover:stroke-[#FFFF55] transition-colors"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span className="text-xs text-[#AAAAAA] group-hover:text-[#FFFF55] leading-none transition-colors">
|
||||
{playtimeMap[edition.instanceId]
|
||||
? formatPlaytime(
|
||||
playtimeMap[edition.instanceId].totalSeconds,
|
||||
)
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{edition.category &&
|
||||
edition.category.map((cat: string) => (
|
||||
<span
|
||||
|
|
@ -465,20 +593,13 @@ const VersionsView = memo(function VersionsView() {
|
|||
))}
|
||||
{isCustom && !edition.category && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-[#777] text-[#222] font-bold uppercase">
|
||||
Custom
|
||||
{t("versions.custom")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`text-base font-medium leading-tight ${
|
||||
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
|
||||
}`}
|
||||
>
|
||||
{edition.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0 relative">
|
||||
<div className="flex items-center gap-2 shrink-0 relative">
|
||||
{!isInstalled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -493,17 +614,13 @@ const VersionsView = memo(function VersionsView() {
|
|||
setHoveredBtn({ row: i, btn: "main" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-9 h-9 flex items-center justify-center ${
|
||||
isDownloading ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`w-10 h-10 flex items-center justify-center bg-cover bg-no-repeat bg-center transition-all
|
||||
${
|
||||
isDownloadFocused
|
||||
? "bg-[url(/images/checkbox_highlighted.png)] ring-4" //neo: ring-4 does not render at all (i dunno why tbh), so transition from ring-0 to ring-4 creates a transition indicating that it was selected. this is a coincidence but im keeping it
|
||||
: "bg-[url(/images/checkbox.png)] ring-0"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "main") ||
|
||||
(focusIndex === i && focusBtn === 0)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
|
|
@ -514,7 +631,7 @@ const VersionsView = memo(function VersionsView() {
|
|||
: "/images/Download_Icon.png"
|
||||
}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain"
|
||||
className="w-8 h-8 object-contain"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
filter: isDownloading
|
||||
|
|
@ -536,36 +653,31 @@ const VersionsView = memo(function VersionsView() {
|
|||
setHoveredBtn({ row: i, btn: "menu" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative"
|
||||
className={`w-10 h-10 flex flex-col items-center justify-center gap-1 relative bg-cover bg-no-repeat bg-center transition-all
|
||||
${
|
||||
isGearFocused
|
||||
? "bg-[url(/images/checkbox_highlighted.png)] ring-4" //neo: same comment as the download button above
|
||||
: "bg-[url(/images/checkbox.png)] ring-0"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "menu") ||
|
||||
(focusIndex === i &&
|
||||
(focusBtn === 0 || focusBtn === 1))
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
filter: updatesAvailable?.[edition.instanceId]
|
||||
? "drop-shadow(0 0 4px rgba(255,255,0,0.8))"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
|
||||
/>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
|
||||
/>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
|
||||
/>
|
||||
<div className={"w-8 h-8"}>
|
||||
<img
|
||||
src={
|
||||
updatesAvailable?.[edition.instanceId]
|
||||
? "/images/Update_Icon.png"
|
||||
: "/images/gear.png"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{openMenuId === edition.id && (
|
||||
<div
|
||||
className="absolute right-0 top-11 w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75"
|
||||
ref={menuRef}
|
||||
className={`absolute right-0 ${menuDir === "up" ? "bottom-full mb-2" : "top-11"} w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75`}
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
|
|
@ -581,12 +693,12 @@ const VersionsView = memo(function VersionsView() {
|
|||
className="w-full text-left px-3 py-1.5 text-xs text-[#ffff55] hover:text-white hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1"
|
||||
>
|
||||
<img
|
||||
src="/images/Download_Icon.png"
|
||||
src="/images/Update_Icon.png"
|
||||
alt=""
|
||||
className="w-3 h-3 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Update Available!
|
||||
{t("versions.updateAvailable")}
|
||||
</button>
|
||||
)}
|
||||
{!isAndroid && !isInstalled && (
|
||||
|
|
@ -607,10 +719,10 @@ const VersionsView = memo(function VersionsView() {
|
|||
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
|
||||
dialog.innerHTML = `
|
||||
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
|
||||
<h3 class="text-2xl font-bold text-[#333333] mb-4 text-left w-full px-4 mc-text-shadow">Invalid Directory</h3>
|
||||
<p class="text-[#333333] mb-6 text-left w-full px-4">Please select an empty directory for installation.</p>
|
||||
<h3 class="text-2xl font-bold text-[#333333] mb-4 text-left w-full px-4 mc-text-shadow">${t("versions.invalidDirectory")}</h3>
|
||||
<p class="text-[#333333] mb-6 text-left w-full px-4">${t("versions.selectEmptyDirectory")}</p>
|
||||
<div class="flex flex-col gap-3 w-full px-4">
|
||||
<button id="empty-dir-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">OK</button>
|
||||
<button id="empty-dir-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Layout_Button_Bmp.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/Layout_Button_Over.png)'" onmouseleave="this.style.backgroundImage='url(/images/Layout_Button_Bmp.png)'">${t("common.ok")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -643,7 +755,7 @@ const VersionsView = memo(function VersionsView() {
|
|||
className="w-3.5 h-3.5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Download to custom path
|
||||
{t("versions.downloadToCustomPath")}
|
||||
</button>
|
||||
)}
|
||||
{edition.officialDLC && isInstalled ? (
|
||||
|
|
@ -670,9 +782,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Download DLC
|
||||
{t("versions.downloadDlc")}
|
||||
</button>
|
||||
) : null}
|
||||
) : null}{" "}
|
||||
{argsSchemas[edition.instanceId] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -706,7 +818,7 @@ const VersionsView = memo(function VersionsView() {
|
|||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
Options
|
||||
{t("versions.options")}
|
||||
</button>
|
||||
)}
|
||||
{Array.isArray(edition.branches) &&
|
||||
|
|
@ -720,14 +832,31 @@ const VersionsView = memo(function VersionsView() {
|
|||
className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow flex items-center justify-between group"
|
||||
>
|
||||
<span className="text-[#AAAAAA] font-bold">
|
||||
Channel
|
||||
{t("versions.channel")}
|
||||
</span>
|
||||
<span className="text-[#ffff55] font-bold">
|
||||
{edition.selectedBranch ?? "Latest"}
|
||||
{edition.selectedBranch ?? t("versions.latest")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="h-[1px] bg-white/5 my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
toggleInstall(edition.instanceId);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-[#dddddd] hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1"
|
||||
>
|
||||
<img
|
||||
src="/images/tools/pck.png" //neo: i have no other image to use lol
|
||||
alt=""
|
||||
className="w-3 h-3 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{t("versions.repair")}
|
||||
</button>
|
||||
{!isAndroid && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -746,9 +875,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
className="w-3.5 h-3.5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Open Folder
|
||||
{t("versions.openFolder")}
|
||||
</button>
|
||||
)}
|
||||
)}{" "}
|
||||
{!isAndroid && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -782,9 +911,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
className="w-3.5 h-3.5 object-contain invert brightness-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Add to Steam
|
||||
{t("versions.addToSteam")}
|
||||
</button>
|
||||
)}
|
||||
)}{" "}
|
||||
{!isAndroid && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -808,9 +937,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Import World
|
||||
{t("versions.importWorld")}
|
||||
</button>
|
||||
)}
|
||||
)}{" "}
|
||||
{!isAndroid && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -838,9 +967,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
Backup
|
||||
{t("versions.backup")}
|
||||
</button>
|
||||
)}
|
||||
)}{" "}
|
||||
{!isAndroid && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
|
|
@ -867,9 +996,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
<path d="M21 12a9 9 0 1 1-9-9 9 9 0 0 1 9 9z" />
|
||||
<polyline points="12 7 12 12 15 15" />
|
||||
</svg>
|
||||
Restore
|
||||
{t("versions.restore")}
|
||||
</button>
|
||||
)}
|
||||
)}{" "}
|
||||
{!isAndroid && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -892,9 +1021,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
>
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
Customize
|
||||
{t("versions.customize")}
|
||||
</button>
|
||||
)}
|
||||
)}{" "}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -917,8 +1046,8 @@ const VersionsView = memo(function VersionsView() {
|
|||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
Set UID
|
||||
</button>
|
||||
{t("versions.setUid")}
|
||||
</button>{" "}
|
||||
{isCustom ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -941,9 +1070,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
>
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
Edit Custom
|
||||
{t("versions.editCustom")}
|
||||
</button>
|
||||
) : null}
|
||||
) : null}{" "}
|
||||
<div className="h-[2px] bg-[#555] my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -964,7 +1093,9 @@ const VersionsView = memo(function VersionsView() {
|
|||
className="w-3.5 h-3.5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{isCustom ? "Remove Custom" : "Uninstall"}
|
||||
{isCustom
|
||||
? t("versions.removeCustom")
|
||||
: t("versions.uninstall")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -972,73 +1103,91 @@ const VersionsView = memo(function VersionsView() {
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="w-full flex items-center justify-center gap-4 p-2 mt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setInitialPath("");
|
||||
setIsImportModalOpen(true);
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(visibleEditions.length)}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === visibleEditions.length &&
|
||||
hoveredBtn?.btn === "add") ||
|
||||
focusIndex === visibleEditions.length
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="square"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!isAndroid && (
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
handleImportFolder();
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(visibleEditions.length + 1)}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
title="Import Custom TU"
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === visibleEditions.length &&
|
||||
hoveredBtn?.btn === "folder_import") ||
|
||||
focusIndex === visibleEditions.length + 1
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/Folder_Icon.png"
|
||||
alt="Import Custom TU"
|
||||
className="w-5 h-5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="w-[calc(100%-20px)] shrink-0"
|
||||
style={{ height: ROW_ESTIMATE }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="w-13 h-85 max-h-85 shrink-0 relative select-none mc-versionrecess"
|
||||
>
|
||||
{thumb.height > 0 && (
|
||||
<div
|
||||
ref={thumbRef}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
className="absolute left-1/2 -translate-x-[30px] w-10 h-10 mc-options-bg cursor-pointer"
|
||||
style={{ top: thumb.top - 12 }}
|
||||
>
|
||||
{
|
||||
//neo: oh my goodness this took me a lot to figure out, anyway its just a transparent <hr> so the div isnt empty so that the border-image renders
|
||||
}
|
||||
<hr style={{ color: "transparent" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAndroid && (
|
||||
<div className="absolute left-3 bottom-6 z-10 flex items-center gap-1 pb-1 pr-1 w-fit mc-help">
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setInitialPath("");
|
||||
setIsImportModalOpen(true);
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(visibleEditions.length)}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#333333] mc-optionbutton"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="square"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
handleImportFolder();
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(visibleEditions.length + 1)}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
title={t("modals.customTu.importTitle")}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#333333] mc-optionbutton"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/Folder_Icon.png"
|
||||
alt={t("modals.customTu.importTitle")}
|
||||
className="w-5 h-5 object-contain invert"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mc-help text-xl text-[#FFFFFF] max-h-24 min-h-24 overflow-hidden px-4 w-280">
|
||||
{visibleEditions[focusIndex]?.desc ??
|
||||
visibleEditions.find((e) => e.id == useConfig().profile)?.desc ??
|
||||
""}
|
||||
</div>
|
||||
|
||||
{!isAndroid && (
|
||||
|
|
@ -1050,7 +1199,7 @@ const VersionsView = memo(function VersionsView() {
|
|||
playBackSound();
|
||||
setActiveView("main");
|
||||
}}
|
||||
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white"
|
||||
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === visibleEditions.length + 2
|
||||
|
|
@ -1060,8 +1209,8 @@ const VersionsView = memo(function VersionsView() {
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
{t("common.done")}
|
||||
</button>{" "}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1200,22 +1349,21 @@ const VersionsView = memo(function VersionsView() {
|
|||
}}
|
||||
>
|
||||
<h3 className="text-xl text-white mc-text-shadow mb-4 text-center">
|
||||
Delete {deleteConfirmEdition.name}?
|
||||
{t("versions.deleteTitle", { name: deleteConfirmEdition.name })}
|
||||
</h3>
|
||||
<p className="text-sm text-white mb-6 text-center leading-relaxed">
|
||||
Warning: All your saves and worlds for this version will be
|
||||
permanently deleted!
|
||||
{t("versions.deleteWarning")}
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<DeleteConfirmButton
|
||||
label="Cancel"
|
||||
label={t("common.cancel")}
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
setDeleteConfirmEdition(null);
|
||||
}}
|
||||
/>
|
||||
<DeleteConfirmButton
|
||||
label="Delete"
|
||||
label={t("common.delete")}
|
||||
isDanger
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import React, {
|
|||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
|
@ -182,6 +183,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
workshopTarget,
|
||||
onClearWorkshopTarget,
|
||||
}: WorkshopViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { isAndroid } = usePlatform();
|
||||
|
|
@ -271,7 +273,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e.message ?? "Failed to load registry");
|
||||
setError(e.message ?? t("workshop.failedToLoadRegistry"));
|
||||
setLoading(false);
|
||||
});
|
||||
refreshInstalledPlugins();
|
||||
|
|
@ -686,6 +688,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
setFocusedIdx((p) => Math.max((p ?? 1) - (isPluginTab ? 1 : COLS), 0));
|
||||
playPressSound();
|
||||
} else if (e.key === "Enter" && focusedIdx !== null) {
|
||||
e.preventDefault();
|
||||
const pkg = filteredItems[focusedIdx];
|
||||
if (pkg) openModal(pkg);
|
||||
}
|
||||
|
|
@ -703,6 +706,35 @@ const WorkshopView = memo(function WorkshopView({
|
|||
openModal,
|
||||
]);
|
||||
|
||||
const tabLabel = (tab: TabType): string => {
|
||||
switch (tab) {
|
||||
case "Skin":
|
||||
return t("workshop.tabs.skin");
|
||||
case "Texture":
|
||||
return t("workshop.tabs.texture");
|
||||
case "World":
|
||||
return t("workshop.tabs.world");
|
||||
case "Mod":
|
||||
return t("workshop.tabs.mod");
|
||||
case "DLC":
|
||||
return t("workshop.tabs.dlc");
|
||||
case "Plugins":
|
||||
return t("workshop.tabs.plugins");
|
||||
case "Versions":
|
||||
return t("workshop.tabs.versions");
|
||||
case "Installed":
|
||||
return t("workshop.tabs.installed");
|
||||
case "Search":
|
||||
return t("workshop.tabs.search");
|
||||
case "Server":
|
||||
return t("workshop.tabs.server");
|
||||
case "Server Plugins":
|
||||
return t("workshop.tabs.serverPlugins");
|
||||
default:
|
||||
return tab;
|
||||
}
|
||||
};
|
||||
|
||||
const isSearchTab = activeTab === "Search";
|
||||
const isInstalledTab = activeTab === "Installed";
|
||||
const isVersionTab = activeTab === "Versions";
|
||||
|
|
@ -722,7 +754,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: config.animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full h-full max-h-full relative font-['Mojangles'] text-white select-none outline-none focus:outline-none"
|
||||
className="flex flex-col items-center w-full h-full max-h-full relative font-[var(--font-base)] text-white select-none outline-none focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-0 mb-4 w-full px-4">
|
||||
<div
|
||||
|
|
@ -742,7 +774,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
: "text-[#aaa] hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{tab.toUpperCase()}
|
||||
{tabLabel(tab).toUpperCase()}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -799,7 +831,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
: "text-[#aaa] hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{tab.toUpperCase()}
|
||||
{tabLabel(tab).toUpperCase()}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -811,46 +843,41 @@ const WorkshopView = memo(function WorkshopView({
|
|||
{showSearch ? (
|
||||
<div className="absolute inset-0 flex flex-col pt-2">
|
||||
<div className="flex items-center gap-3 px-6 pb-4">
|
||||
<div
|
||||
className="flex items-center flex-1 h-12 px-4 border-2 border-[#444] bg-black/40 rounded shadow-inner"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background2.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setFocusedIdx(null);
|
||||
}}
|
||||
placeholder={
|
||||
isInstalledTab
|
||||
? "FILTER INSTALLED..."
|
||||
: isVersionTab
|
||||
? "FILTER VERSIONS..."
|
||||
: isPluginTab
|
||||
? "FILTER PLUGINS..."
|
||||
: activeTab === "Server Plugins"
|
||||
? "FILTER PLUGINS..."
|
||||
: activeTab === "Server"
|
||||
? "FILTER SERVERS..."
|
||||
: "ENTER KEYWORDS..."
|
||||
}
|
||||
spellCheck={false}
|
||||
autoFocus={isSearchTab}
|
||||
className="bg-transparent border-none outline-none text-white text-lg mc-text-shadow w-full placeholder-white/40 font-['Mojangles'] tracking-widest"
|
||||
/>
|
||||
<div className="flex items-center flex-1 h-12 px-4 rounded">
|
||||
<div className="mc-textinput-outer flex-1">
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setFocusedIdx(null);
|
||||
}}
|
||||
placeholder={
|
||||
isInstalledTab
|
||||
? t("workshop.filterInstalled")
|
||||
: isVersionTab
|
||||
? t("workshop.filterVersions")
|
||||
: isPluginTab
|
||||
? t("workshop.filterPlugins")
|
||||
: activeTab === "Server Plugins"
|
||||
? t("workshop.filterPlugins")
|
||||
: activeTab === "Server"
|
||||
? t("workshop.filterServers")
|
||||
: t("workshop.enterKeywords")
|
||||
}
|
||||
spellCheck={false}
|
||||
autoFocus={isSearchTab}
|
||||
className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] placeholder-white/40 tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
className="text-white/60 hover:text-white text-lg ml-2 bg-transparent border-none outline-none cursor-pointer mc-text-shadow"
|
||||
className="text-white/60 hover:text-white text-lg mx-2 border-none outline-none cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -869,7 +896,9 @@ const WorkshopView = memo(function WorkshopView({
|
|||
: "text-[#A0A0A0] bg-black/30 border-[#444] hover:text-white hover:border-[#888]"
|
||||
}`}
|
||||
>
|
||||
{cat === "all" ? "ALL" : cat.toUpperCase()}
|
||||
{cat === "all"
|
||||
? t("common.all").toUpperCase()
|
||||
: cat.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -886,7 +915,9 @@ const WorkshopView = memo(function WorkshopView({
|
|||
: "text-[#A0A0A0] bg-black/30 border-[#444] hover:text-white hover:border-[#888]"
|
||||
}`}
|
||||
>
|
||||
{cat === "all" ? "ALL" : cat.toUpperCase()}
|
||||
{cat === "all"
|
||||
? t("common.all").toUpperCase()
|
||||
: cat.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -928,9 +959,11 @@ const WorkshopView = memo(function WorkshopView({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Update All (
|
||||
{filteredItems.filter((p) => hasUpdate(p)).length})
|
||||
{t("workshop.updateAll", {
|
||||
count: filteredItems.filter((p) => hasUpdate(p)).length,
|
||||
})}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (filteredItems.length === 0) return;
|
||||
|
|
@ -961,34 +994,36 @@ const WorkshopView = memo(function WorkshopView({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Reinstall All ({filteredItems.length})
|
||||
{t("workshop.reinstallAll", {
|
||||
count: filteredItems.length,
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isSearchTab && !search.trim() ? (
|
||||
<div className="flex flex-col items-center justify-center h-[200px] opacity-40">
|
||||
<span className="text-xl mc-text-shadow tracking-widest uppercase">
|
||||
Start typing to search...
|
||||
{t("workshop.startTypingToSearch")}
|
||||
</span>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-3xl text-[#FFFF55] mc-text-shadow tracking-widest animate-pulse uppercase">
|
||||
Searching Archives...
|
||||
{t("workshop.searchingArchives")}
|
||||
</span>
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-2xl text-[#E0E0E0] mc-text-shadow uppercase tracking-widest opacity-60">
|
||||
{isInstalledTab
|
||||
? "Nothing Installed"
|
||||
? t("workshop.nothingInstalled")
|
||||
: activeTab === "Plugins"
|
||||
? "No plugins available"
|
||||
? t("workshop.noPluginsAvailable")
|
||||
: activeTab === "Server Plugins"
|
||||
? "No plugins available"
|
||||
? t("workshop.noPluginsAvailable")
|
||||
: activeTab === "Server"
|
||||
? "No servers available"
|
||||
: "No results"}
|
||||
? t("workshop.noServersAvailable")
|
||||
: t("common.noResults")}
|
||||
</span>
|
||||
</div>
|
||||
) : isPluginTab ? (
|
||||
|
|
@ -1045,7 +1080,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
) : loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-3xl text-[#FFFF55] mc-text-shadow tracking-widest animate-pulse uppercase">
|
||||
Searching Archives...
|
||||
{t("workshop.searchingArchives")}
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
|
|
@ -1062,7 +1097,7 @@ const WorkshopView = memo(function WorkshopView({
|
|||
{filteredItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-2xl text-[#E0E0E0] mc-text-shadow uppercase tracking-widest opacity-40">
|
||||
Empty category
|
||||
{t("workshop.emptyCategory")}
|
||||
</span>
|
||||
</div>
|
||||
) : isPluginTab ? (
|
||||
|
|
@ -1116,20 +1151,20 @@ const WorkshopView = memo(function WorkshopView({
|
|||
}}
|
||||
className="w-72 h-10 flex items-center justify-center text-xl mc-text-shadow hover:text-[#FFFF55] text-white border-none outline-none transition-all"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundImage: "url('/images/Layout_Button_Bmp.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')";
|
||||
"url('/images/Layout_Button_Over.png')";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
|
||||
"url('/images/Button_Background.png')";
|
||||
"url('/images/Layout_Button_Bmp.png')";
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("workshop.back")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1194,6 +1229,7 @@ function PackageCard({
|
|||
isVersionTab?: boolean;
|
||||
isPluginTab?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const thumbnailUrl = pkg.thumbnail.startsWith("http")
|
||||
? pkg.thumbnail
|
||||
: isVersionTab
|
||||
|
|
@ -1221,7 +1257,7 @@ function PackageCard({
|
|||
>
|
||||
{!thumbnailUrl || imgError ? (
|
||||
<span className="text-[#555] text-sm mc-text-shadow uppercase tracking-widest">
|
||||
No Image
|
||||
{t("workshop.noImage")}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
|
|
@ -1245,14 +1281,14 @@ function PackageCard({
|
|||
{hasUpdate && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-[8px] bg-[#FF8800]/90 border border-[#FF6600] px-1.5 py-0.5 text-white mc-text-shadow uppercase tracking-tighter">
|
||||
Update
|
||||
{t("workshop.update")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{installed && !hasUpdate && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-[8px] bg-[#55FF55] border border-[#55FF55]/60 px-1.5 py-0.5 text-[#003300] mc-text-shadow uppercase tracking-tighter shadow-sm font-bold">
|
||||
{isVersionTab ? "Added" : "Installed"}
|
||||
{isVersionTab ? t("workshop.added") : t("workshop.installed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1315,6 +1351,7 @@ function PackageModal({
|
|||
isSaved?: boolean;
|
||||
onToggleSave?: (pkg: RegistryPackage) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { addCustomEdition } = useGame();
|
||||
const thumbnailUrl = pkg.thumbnail.startsWith("http")
|
||||
? pkg.thumbnail
|
||||
|
|
@ -1365,6 +1402,7 @@ function PackageModal({
|
|||
return focusOptions[(idx + 1) % focusOptions.length];
|
||||
});
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (modalFocus === "close") onClose();
|
||||
else if (modalFocus === "install") handleAction();
|
||||
else if (modalFocus === "uninstall") setShowUninstall(true);
|
||||
|
|
@ -1389,7 +1427,7 @@ function PackageModal({
|
|||
playPressSound();
|
||||
try {
|
||||
const path = await TauriService.saveFileDialog(
|
||||
"Save Server Plugin",
|
||||
t("workshop.saveServerPlugin"),
|
||||
`${pkg.name}.dll`,
|
||||
["*.dll", "*"],
|
||||
);
|
||||
|
|
@ -1435,25 +1473,25 @@ function PackageModal({
|
|||
|
||||
const installLabel = isGameServerTab
|
||||
? isSaved
|
||||
? "ADDED"
|
||||
: "ADD"
|
||||
? t("workshop.added")
|
||||
: t("workshop.add")
|
||||
: isServerTab
|
||||
? "DOWNLOAD"
|
||||
? t("workshop.download")
|
||||
: isVersionTab
|
||||
? hasInstalled
|
||||
? "ADDED"
|
||||
: "ADD"
|
||||
? t("workshop.added")
|
||||
: t("workshop.add")
|
||||
: isPluginTab
|
||||
? hasInstalled
|
||||
? needsUpdate
|
||||
? "UPDATE"
|
||||
: "REINSTALL"
|
||||
: "INSTALL"
|
||||
? t("workshop.update")
|
||||
: t("workshop.reinstall")
|
||||
: t("workshop.install")
|
||||
: !hasInstalled
|
||||
? "INSTALL"
|
||||
? t("workshop.install")
|
||||
: needsUpdate
|
||||
? "UPDATE"
|
||||
: "REINSTALL";
|
||||
? t("workshop.update")
|
||||
: t("workshop.reinstall");
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -1462,7 +1500,7 @@ function PackageModal({
|
|||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex flex-col w-[640px] max-h-[85vh] overflow-hidden font-['Mojangles'] mc-options-bg"
|
||||
className="flex flex-col w-[640px] max-h-[85vh] overflow-hidden font-[var(--font-base)] mc-options-bg"
|
||||
>
|
||||
{isPluginTab ? (
|
||||
<div className="w-full h-[100px] flex-shrink-0 bg-black/60 flex items-center px-6 border-b border-[#444]">
|
||||
|
|
@ -1471,7 +1509,7 @@ function PackageModal({
|
|||
{pkg.name}
|
||||
</span>
|
||||
<span className="text-base text-[#FFFF55] mc-text-shadow uppercase tracking-widest opacity-90">
|
||||
By {pkg.author}
|
||||
{t("workshop.byAuthor", { author: pkg.author })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1480,7 +1518,7 @@ function PackageModal({
|
|||
{!thumbnailUrl || imgError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-20">
|
||||
<span className="text-4xl mc-text-shadow uppercase tracking-widest">
|
||||
No Image
|
||||
{t("workshop.noImage")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -1498,27 +1536,27 @@ function PackageModal({
|
|||
{pkg.name}
|
||||
</span>
|
||||
<span className="text-base text-[#FFFF55] mc-text-shadow uppercase tracking-widest opacity-90">
|
||||
By {pkg.author}
|
||||
{t("workshop.byAuthor", { author: pkg.author })}
|
||||
</span>
|
||||
</div>
|
||||
{needsUpdate && (
|
||||
<div className="absolute top-3 right-3 bg-[#FF8800] border border-[#FF6600] px-2 py-1">
|
||||
<span className="text-[10px] text-white mc-text-shadow uppercase tracking-widest">
|
||||
Update Available
|
||||
{t("workshop.updateAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasInstalled && !needsUpdate && (
|
||||
<div className="absolute top-3 right-3 bg-[#003300] border border-[#55FF55]/60 px-2 py-1">
|
||||
<span className="text-[10px] text-[#55FF55] mc-text-shadow uppercase tracking-widest">
|
||||
Installed
|
||||
{t("workshop.installed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isGameServerTab && isSaved && (
|
||||
<div className="absolute top-3 right-3 bg-[#003300] border border-[#55FF55]/60 px-2 py-1">
|
||||
<span className="text-[10px] text-[#55FF55] mc-text-shadow uppercase tracking-widest">
|
||||
Saved
|
||||
{t("workshop.saved")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1536,7 +1574,7 @@ function PackageModal({
|
|||
pkg.extended_description.trim() !== "" && (
|
||||
<div className="space-y-2 p-4 border border-[#444] bg-black/40">
|
||||
<span className="text-[10px] text-[#AAAAAA] mc-text-shadow uppercase tracking-[0.2em] font-bold">
|
||||
Details
|
||||
{t("workshop.details")}
|
||||
</span>
|
||||
<div className="text-sm text-white mc-text-shadow leading-relaxed workshop-markdown">
|
||||
<ReactMarkdown
|
||||
|
|
@ -1556,14 +1594,14 @@ function PackageModal({
|
|||
<div className="grid grid-cols-2 gap-8 pt-4 border-t border-[#444]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-[#888] mc-text-shadow uppercase tracking-[0.2em] font-bold">
|
||||
Metadata
|
||||
{t("workshop.metadata")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{isGameServerTab ? (
|
||||
<>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Address:
|
||||
{t("workshop.address")}
|
||||
</span>
|
||||
<span className="text-[#55FF55] mc-text-shadow">
|
||||
{pkg.server_address || "N/A"}
|
||||
|
|
@ -1571,7 +1609,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Type:
|
||||
{t("workshop.type")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow">
|
||||
{pkg.server_type || "N/A"}
|
||||
|
|
@ -1579,7 +1617,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Version:
|
||||
{t("workshop.version")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow">
|
||||
{pkg.version}
|
||||
|
|
@ -1587,7 +1625,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Owner:
|
||||
{t("workshop.owner")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow">
|
||||
{pkg.author}
|
||||
|
|
@ -1596,7 +1634,7 @@ function PackageModal({
|
|||
{pkg.server_discord && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Discord Server:
|
||||
{t("workshop.discordServer")}
|
||||
</span>
|
||||
<a
|
||||
href={pkg.server_discord}
|
||||
|
|
@ -1615,7 +1653,7 @@ function PackageModal({
|
|||
<>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Downloads:
|
||||
{t("workshop.downloads")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow">
|
||||
{pkg.download_count?.toLocaleString() ?? 0}
|
||||
|
|
@ -1623,7 +1661,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Likes:
|
||||
{t("workshop.likes")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow">
|
||||
{pkg.likes?.toLocaleString() ?? 0}
|
||||
|
|
@ -1631,7 +1669,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Server Type:
|
||||
{t("workshop.serverType")}
|
||||
</span>
|
||||
<span className="text-[#55FF55] mc-text-shadow">
|
||||
{pkg.game_version || "N/A"}
|
||||
|
|
@ -1639,7 +1677,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
File:
|
||||
{t("workshop.file")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow truncate ml-2">
|
||||
{pkg.name || "N/A"}
|
||||
|
|
@ -1648,7 +1686,7 @@ function PackageModal({
|
|||
{pkg.file_size && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
File Size:
|
||||
{t("workshop.fileSize")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow">
|
||||
{(pkg.file_size / 1024).toFixed(1)} KB
|
||||
|
|
@ -1658,7 +1696,7 @@ function PackageModal({
|
|||
{pkg.github_url && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
GitHub:
|
||||
{t("workshop.github")}
|
||||
</span>
|
||||
<a
|
||||
href={pkg.github_url}
|
||||
|
|
@ -1677,7 +1715,7 @@ function PackageModal({
|
|||
<>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Version:
|
||||
{t("workshop.version")}
|
||||
</span>
|
||||
<span className="text-white mc-text-shadow">
|
||||
v{pkg.version}
|
||||
|
|
@ -1685,7 +1723,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Package ID:
|
||||
{t("workshop.packageId")}
|
||||
</span>
|
||||
<span className="text-[#55FF55] mc-text-shadow truncate ml-2">
|
||||
{pkg.id}
|
||||
|
|
@ -1696,13 +1734,13 @@ function PackageModal({
|
|||
{hasInstalled && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#888] mc-text-shadow">
|
||||
Installed:
|
||||
{t("workshop.installed")}
|
||||
</span>
|
||||
<span
|
||||
className={`mc-text-shadow truncate ml-2 ${needsUpdate ? "text-[#FF8800]" : "text-[#55FF55]"}`}
|
||||
>
|
||||
v{installedEntries[0]?.version}
|
||||
{needsUpdate ? " (outdated)" : ""}
|
||||
{needsUpdate ? t("workshop.outdated") : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1710,7 +1748,7 @@ function PackageModal({
|
|||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-[#666] mc-text-shadow uppercase tracking-[0.2em] font-bold">
|
||||
Categories
|
||||
{t("workshop.categories")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pkg.category.map((c) => (
|
||||
|
|
@ -1727,7 +1765,7 @@ function PackageModal({
|
|||
{pkg.dependencies && pkg.dependencies.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 pt-4 border-t border-[#333]">
|
||||
<span className="text-[10px] text-[#FFAA33] mc-text-shadow uppercase tracking-[0.2em] font-bold">
|
||||
Required Dependencies
|
||||
{t("workshop.requiredDependencies")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{pkg.dependencies.map((depId) => {
|
||||
|
|
@ -1745,7 +1783,7 @@ function PackageModal({
|
|||
}`}
|
||||
>
|
||||
{dep?.name || depId}
|
||||
{isDepInstalled ? " (installed)" : ""}
|
||||
{isDepInstalled ? t("workshop.installedSuffix") : ""}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1755,7 +1793,7 @@ function PackageModal({
|
|||
{pkg.required_versions && pkg.required_versions.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 pt-4 border-t border-[#333]">
|
||||
<span className="text-[10px] text-[#55AAFF] mc-text-shadow uppercase tracking-[0.2em] font-bold">
|
||||
Required Editions
|
||||
{t("workshop.requiredEditions")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{pkg.required_versions.map((verId) => {
|
||||
|
|
@ -1782,7 +1820,7 @@ function PackageModal({
|
|||
{pkg.zips && Object.keys(pkg.zips).length > 0 && (
|
||||
<div className="flex flex-col gap-3 pt-4 border-t border-[#333]">
|
||||
<span className="text-[10px] text-[#666] mc-text-shadow uppercase tracking-[0.2em] font-bold">
|
||||
Files
|
||||
{t("workshop.files")}
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
{groupZips(pkg.zips).map((group) => {
|
||||
|
|
@ -1797,7 +1835,9 @@ function PackageModal({
|
|||
{group.main}
|
||||
{isSplit && (
|
||||
<span className="ml-2 text-[8px] text-[#FFFF55] bg-black/60 border border-[#555] px-1.5 py-0.5 uppercase tracking-widest">
|
||||
Split archive · {group.parts.length} parts
|
||||
{t("workshop.splitArchive", {
|
||||
count: group.parts.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -1849,7 +1889,7 @@ function PackageModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
REMOVE
|
||||
{t("workshop.remove")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -1865,7 +1905,7 @@ function PackageModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
BACK
|
||||
{t("workshop.back")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1887,7 +1927,7 @@ function PackageModal({
|
|||
exit={{ y: 20, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex flex-col w-[520px] font-['Mojangles'] text-white border-2 border-[#555] rounded-sm overflow-hidden"
|
||||
className="flex flex-col w-[520px] font-[var(--font-base)] text-white border-2 border-[#555] rounded-sm overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
|
|
@ -1896,10 +1936,10 @@ function PackageModal({
|
|||
>
|
||||
<div className="p-6 border-b border-[#555] bg-black/60">
|
||||
<span className="text-2xl mc-text-shadow block font-bold tracking-wide text-[#FFAA33]">
|
||||
Dependencies Required
|
||||
{t("workshop.dependenciesRequired")}
|
||||
</span>
|
||||
<span className="text-sm text-[#A0A0A0] mc-text-shadow uppercase tracking-widest opacity-80 mt-1">
|
||||
This package requires the following to be installed first
|
||||
{t("workshop.dependenciesRequiredDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto">
|
||||
|
|
@ -1920,7 +1960,7 @@ function PackageModal({
|
|||
);
|
||||
})}
|
||||
<span className="text-xs text-[#888] mc-text-shadow mt-3 text-center">
|
||||
These will be installed automatically before the main package.
|
||||
{t("workshop.dependenciesAutoInstall")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-4 border-t border-[#555] bg-black/40">
|
||||
|
|
@ -1933,7 +1973,7 @@ function PackageModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
CANCEL
|
||||
{t("common.cancel").toUpperCase()}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -1947,7 +1987,7 @@ function PackageModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
INSTALL
|
||||
{t("workshop.install").toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -2003,6 +2043,7 @@ function InstallModal({
|
|||
playPressSound: () => void;
|
||||
isPluginTab?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const game = useContext(GameContext);
|
||||
const matchingEditions =
|
||||
pkg.required_versions && pkg.required_versions.length > 0
|
||||
|
|
@ -2131,8 +2172,10 @@ function InstallModal({
|
|||
e.stopPropagation();
|
||||
if (status === "installing") return;
|
||||
if (status === "success") {
|
||||
if (e.key === "Escape" || e.key === "Backspace" || e.key === "Enter")
|
||||
if (e.key === "Escape" || e.key === "Backspace" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2147,6 +2190,7 @@ function InstallModal({
|
|||
playPressSound();
|
||||
setFocusedIdx((p) => Math.min(p + 1, editionOptions.length - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (editionOptions.length > 0) {
|
||||
installTo(editionOptions[focusedIdx].instanceId);
|
||||
}
|
||||
|
|
@ -2225,7 +2269,7 @@ function InstallModal({
|
|||
exit={{ y: 20, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex flex-col w-[520px] font-['Mojangles'] text-white border-2 border-[#555] rounded-sm overflow-hidden"
|
||||
className="flex flex-col w-[520px] font-[var(--font-base)] text-white border-2 border-[#555] rounded-sm overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
|
|
@ -2234,12 +2278,14 @@ function InstallModal({
|
|||
>
|
||||
<div className="p-6 border-b border-[#555] bg-black/60">
|
||||
<span className="text-2xl mc-text-shadow block font-bold tracking-wide">
|
||||
{isPluginTab ? "INSTALL PLUGIN" : "INSTALL CONTENT"}
|
||||
{isPluginTab
|
||||
? t("workshop.installPluginTitle")
|
||||
: t("workshop.installContentTitle")}
|
||||
</span>
|
||||
<span className="text-sm text-[#A0A0A0] mc-text-shadow uppercase tracking-widest opacity-80 mt-1">
|
||||
{isPluginTab
|
||||
? `Installing "${pkg.name}"`
|
||||
: `Target Edition for "${pkg.name}"`}
|
||||
? t("workshop.installingPackage", { name: pkg.name })
|
||||
: t("workshop.targetEdition", { name: pkg.name })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -2248,25 +2294,23 @@ function InstallModal({
|
|||
<div className="py-8 flex flex-col items-center justify-center gap-3">
|
||||
<span className="text-2xl text-[#FFFF55] mc-text-shadow animate-pulse">
|
||||
{dependencies.length > 0
|
||||
? "Installing dependencies..."
|
||||
: isPluginTab
|
||||
? "Installing..."
|
||||
: "Installing..."}
|
||||
? t("workshop.installingDependencies")
|
||||
: t("workshop.installing")}
|
||||
</span>
|
||||
<span className="text-xs text-[#A0A0A0] mc-text-shadow">
|
||||
{isPluginTab
|
||||
? "Downloading plugin files"
|
||||
? t("workshop.downloadingPluginFiles")
|
||||
: dependencies.length > 0
|
||||
? "Downloading and extracting required dependencies"
|
||||
: "Downloading and extracting assets"}
|
||||
? t("workshop.downloadingDependencies")
|
||||
: t("workshop.downloadingAssets")}
|
||||
</span>
|
||||
<div className="w-full flex flex-col gap-1 px-2 mt-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-[#FFFF55] mc-text-shadow truncate">
|
||||
{progressLabel ||
|
||||
(isPluginTab
|
||||
? "Downloading plugin files"
|
||||
: "Downloading assets")}
|
||||
? t("workshop.downloadingPluginFiles")
|
||||
: t("workshop.downloadingAssets"))}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#FFFF55] mc-text-shadow shrink-0">
|
||||
{Math.floor(isPluginTab ? progress : overallProgress)}%
|
||||
|
|
@ -2299,7 +2343,7 @@ function InstallModal({
|
|||
{isPluginTab && pkg.permissions && pkg.permissions.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 mt-2 w-full px-4">
|
||||
<span className="text-[10px] text-[#888] mc-text-shadow uppercase tracking-[0.2em] font-bold text-center">
|
||||
Requested Permissions
|
||||
{t("workshop.requestedPermissions")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center">
|
||||
{pkg.permissions.map((perm) => (
|
||||
|
|
@ -2318,17 +2362,17 @@ function InstallModal({
|
|||
{status === "success" && (
|
||||
<div className="py-8 flex flex-col items-center justify-center gap-3">
|
||||
<span className="text-2xl text-[#55FF55] mc-text-shadow">
|
||||
Installed Successfully!
|
||||
{t("workshop.installedSuccessfully")}
|
||||
</span>
|
||||
<span className="text-xs text-[#A0A0A0] mc-text-shadow">
|
||||
Press any key or click to continue
|
||||
{t("workshop.pressAnyKeyToContinue")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="py-6 flex flex-col items-center justify-center gap-3">
|
||||
<span className="text-xl text-[#FF5555] mc-text-shadow">
|
||||
Installation Failed
|
||||
{t("workshop.installationFailed")}
|
||||
</span>
|
||||
<span className="text-xs text-[#A0A0A0] mc-text-shadow text-center">
|
||||
{errorMsg}
|
||||
|
|
@ -2345,7 +2389,7 @@ function InstallModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
{t("workshop.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2355,7 +2399,7 @@ function InstallModal({
|
|||
(editionOptions.length === 0 ? (
|
||||
<div className="py-6 flex items-center justify-center">
|
||||
<span className="text-[#FF5555] mc-text-shadow">
|
||||
No installed editions found
|
||||
{t("workshop.noInstalledEditions")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -2395,6 +2439,7 @@ function UninstallModal({
|
|||
isVersionTab?: boolean;
|
||||
isPluginTab?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { deleteCustomEdition } = useGame();
|
||||
const game = useContext(GameContext);
|
||||
const [focusedIdx, setFocusedIdx] = useState(0);
|
||||
|
|
@ -2439,8 +2484,10 @@ function UninstallModal({
|
|||
e.stopPropagation();
|
||||
if (status === "removing") return;
|
||||
if (status === "success") {
|
||||
if (e.key === "Escape" || e.key === "Backspace" || e.key === "Enter")
|
||||
if (e.key === "Escape" || e.key === "Backspace" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2455,6 +2502,7 @@ function UninstallModal({
|
|||
playPressSound();
|
||||
setFocusedIdx((p) => Math.min(p + 1, installedEntries.length - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (installedEntries.length > 0) {
|
||||
uninstallFrom(installedEntries[focusedIdx].instanceId);
|
||||
}
|
||||
|
|
@ -2502,7 +2550,7 @@ function UninstallModal({
|
|||
exit={{ y: 20, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex flex-col w-[520px] font-['Mojangles'] text-white border-2 border-[#555] rounded-sm overflow-hidden"
|
||||
className="flex flex-col w-[520px] font-[var(--font-base)] text-white border-2 border-[#555] rounded-sm overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
|
|
@ -2511,12 +2559,14 @@ function UninstallModal({
|
|||
>
|
||||
<div className="p-6 border-b border-[#555] bg-black/60">
|
||||
<span className="text-2xl mc-text-shadow block font-bold tracking-wide text-[#FF5555]">
|
||||
{isPluginTab ? "REMOVE PLUGIN" : "REMOVE CONTENT"}
|
||||
{isPluginTab
|
||||
? t("workshop.removePluginTitle")
|
||||
: t("workshop.removeContentTitle")}
|
||||
</span>
|
||||
<span className="text-sm text-[#A0A0A0] mc-text-shadow uppercase tracking-widest opacity-80 mt-1">
|
||||
{isPluginTab
|
||||
? `Remove "${pkg.name}"`
|
||||
: `Select edition to remove "${pkg.name}"`}
|
||||
? t("workshop.removePackage", { name: pkg.name })
|
||||
: t("workshop.selectEditionToRemove", { name: pkg.name })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -2524,27 +2574,27 @@ function UninstallModal({
|
|||
{status === "removing" && (
|
||||
<div className="py-8 flex flex-col items-center justify-center gap-3">
|
||||
<span className="text-2xl text-[#FF5555] mc-text-shadow animate-pulse">
|
||||
Removing...
|
||||
{t("workshop.removing")}
|
||||
</span>
|
||||
<span className="text-xs text-[#A0A0A0] mc-text-shadow">
|
||||
Deleting installed files
|
||||
{t("workshop.deletingFiles")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<div className="py-8 flex flex-col items-center justify-center gap-3">
|
||||
<span className="text-2xl text-[#55FF55] mc-text-shadow">
|
||||
Removed Successfully!
|
||||
{t("workshop.removedSuccessfully")}
|
||||
</span>
|
||||
<span className="text-xs text-[#A0A0A0] mc-text-shadow">
|
||||
Press any key or click to continue
|
||||
{t("workshop.pressAnyKeyToContinue")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="py-6 flex flex-col items-center justify-center gap-3">
|
||||
<span className="text-xl text-[#FF5555] mc-text-shadow">
|
||||
Removal Failed
|
||||
{t("workshop.removalFailed")}
|
||||
</span>
|
||||
<span className="text-xs text-[#A0A0A0] mc-text-shadow text-center">
|
||||
{errorMsg}
|
||||
|
|
@ -2561,7 +2611,7 @@ function UninstallModal({
|
|||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
{t("workshop.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
configRaw.instanceLaunchArgs,
|
||||
configRaw.androidRunner,
|
||||
configRaw.androidAudioBackend,
|
||||
configRaw.goldmapperEnabled,
|
||||
configRaw.proxy,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -213,6 +215,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
startFullscreen: config.startFullscreen,
|
||||
skipIntro: config.skipIntro,
|
||||
instanceLaunchArgs: config.instanceLaunchArgs,
|
||||
goldmapperEnabled: config.goldmapperEnabled,
|
||||
httpProxy: config.proxy,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [
|
||||
|
|
@ -239,6 +243,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
config.startFullscreen,
|
||||
config.skipIntro,
|
||||
config.instanceLaunchArgs,
|
||||
config.goldmapperEnabled,
|
||||
config.proxy,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -19,16 +19,20 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.mc-slider-custom::-webkit-slider-runnable-track {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mc-slider-custom::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 44px;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: url('/images/Slider_Handle.png') no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--font-base: "Mojangles", monospace;
|
||||
}
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
|
|
@ -69,6 +72,62 @@
|
|||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-options-fg {
|
||||
/*neo: tysm https://leanrada.com/9-slicer/ */
|
||||
border-image: url("/images/empty.png") 30 30 30 30 fill / 30px 30px 30px
|
||||
30px;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-optionbutton {
|
||||
appearance: none;
|
||||
background: url("/images/options_background.png") center / 100% 100%
|
||||
no-repeat;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-button-ninesliced {
|
||||
/*neo: tysm https://leanrada.com/9-slicer/ */
|
||||
border-image: url("/images/Button_Background.png") 13 13 13 13 fill / 13px
|
||||
13px 13px 13px;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-button-ninesliced-selected {
|
||||
/*neo: tysm https://leanrada.com/9-slicer/ */
|
||||
border-image: url("/images/button_highlighted.png") 13 13 13 13 fill / 13px
|
||||
13px 13px 13px;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-versionrecess {
|
||||
/*neo: tysm https://leanrada.com/9-slicer/ */
|
||||
border-image: url("/images/versionrecess.png") 12 30 30 12 fill / 12px 30px
|
||||
30px 12px;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-help {
|
||||
/*neo: tysm https://leanrada.com/9-slicer/ */
|
||||
border-image: url("/images/helpbg.png") 7 16 16 7 fill / 7px 16px 16px 7px;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-lce-tab {
|
||||
background: linear-gradient(180deg, #c0c0c0 0%, #a0a0a0 100%);
|
||||
border: 2px solid #808080;
|
||||
|
|
@ -153,7 +212,7 @@
|
|||
body {
|
||||
margin: 0;
|
||||
background-color: #000;
|
||||
font-family: "Mojangles", monospace;
|
||||
font-family: var(--font-base, "Mojangles", monospace);
|
||||
}
|
||||
|
||||
.mc-button {
|
||||
|
|
@ -388,3 +447,19 @@ body {
|
|||
border-top: 2px solid #373737;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.mc-textinput-outer {
|
||||
border: 2px solid transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-textinput-outer:focus-within,
|
||||
:focus > .mc-textinput-outer {
|
||||
border: 2px solid #ffff00;
|
||||
}
|
||||
|
||||
.mc-textinput {
|
||||
background-color: #646464 !important;
|
||||
border: 2px solid #323232 !important;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export const SPLASHES = [
|
|||
"BIG SHOT",
|
||||
"A burning memory",
|
||||
"FREE MONEY!",
|
||||
"Can You Really Call This A Hotel. I didn't Reveive A Mint On My Pillow Or Anything",
|
||||
"Can You Really Call This A Hotel?",
|
||||
"Try Indie Game",
|
||||
"SHARK WITH LEGS!",
|
||||
"it's a seal!",
|
||||
|
|
@ -129,8 +129,8 @@ export const SPLASHES = [
|
|||
"Woo, reddit!",
|
||||
"piebot was here!",
|
||||
"Legacy in an evolved manner.",
|
||||
"neoapps is cool!",
|
||||
"neoapps has put a self insert into this program!",
|
||||
"neoapps is cool!", //neo: i dont think so lmao
|
||||
"neoapps put a self-insert in this!",
|
||||
"Also try neoLegacy!",
|
||||
"$20 is $20",
|
||||
"no RenderDragon!",
|
||||
|
|
@ -142,7 +142,6 @@ export const SPLASHES = [
|
|||
"Also try LC Launcher!",
|
||||
"Exclusively abandonware!",
|
||||
"100% legal in all 3 states (of matter)",
|
||||
"Herobrine has been confirmed we are fighting him please help",
|
||||
"LGTM",
|
||||
"git revert",
|
||||
"We do not crypto mine on your computer.",
|
||||
|
|
@ -213,24 +212,27 @@ export const SPLASHES = [
|
|||
"Can you guys add this one",
|
||||
"It's pronounced manga, actually.",
|
||||
"If you find a bug, don't report it",
|
||||
"I don't know what woke means can someone tell me",
|
||||
"I don't know what 'woke' means",
|
||||
"I HATE WHEN PEOPLE USE UPPERCASE LETTERS",
|
||||
"Erm.... He's right behind me, isn't he?",
|
||||
'What does "the" even mean man',
|
||||
"Tip: Bigger, longer and uncut.",
|
||||
"This is the longest sentence ever",
|
||||
"This is the shortest sentence scientifically possible, according to me.",
|
||||
"The shortest sentence scientifically possible.",
|
||||
"Must... Not... Report... To Microslop!!...",
|
||||
"When using a electric guitar, use a bass amp for better sound quality!",
|
||||
"Use a bass amp for an electric guitar!",
|
||||
"Never piss too hard",
|
||||
"This text contains 44 letters",
|
||||
"Infinity + 1, Dork!",
|
||||
"Gold is useless if you don't think about it too hard",
|
||||
"Gold is useless if you think about it",
|
||||
"(Kissing sounds)",
|
||||
"You look so much like my best friend, I feel a little gay sitting next to you!",
|
||||
"You look like my best friend, I feel gay",
|
||||
"You're using asterisks wrong*",
|
||||
"Edgy humor is soo edgy",
|
||||
"I LOVE YURI!!!!!!! oh and yaoi ig",
|
||||
"I LOVE YURI!!!!!!! oh and yaoi ig",
|
||||
"RIP PrismaChunk0's Dog 2010-2026",
|
||||
"Guess who's back, back again!",
|
||||
];
|
||||
"Minecraft: Playstation®4 Edition!",
|
||||
"Also try LegacyOrbis!",
|
||||
"bytebukkit was here!",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useLocalStorage } from "./useLocalStorage";
|
||||
import { TauriService, type CustomEdition } from "../services/TauriService";
|
||||
import {
|
||||
TauriService,
|
||||
type CustomEdition,
|
||||
type GoldMapperMapping,
|
||||
} from "../services/TauriService";
|
||||
export function useAppConfig() {
|
||||
const [username, setUsername] = useLocalStorage("lce-username", "Steve");
|
||||
const [theme, setTheme] = useLocalStorage("lce-theme", "Modern");
|
||||
|
|
@ -31,6 +35,9 @@ export function useAppConfig() {
|
|||
>({});
|
||||
const [androidRunner, setAndroidRunner] = useLocalStorage<string | undefined>("lce-android-runner", undefined);
|
||||
const [androidAudioBackend, setAndroidAudioBackend] = useLocalStorage<"alsa" | "pulseaudio">("lce-android-audio", "pulseaudio");
|
||||
const [goldmapperEnabled, setGoldmapperEnabled] = useLocalStorage("lce-goldmapper", true);
|
||||
const [goldmapperMappings, setGoldmapperMappings] = useState<GoldMapperMapping[] | undefined>();
|
||||
const [proxy, setProxy] = useState<string | undefined>();
|
||||
useEffect(() => {
|
||||
TauriService.loadConfig().then((config) => {
|
||||
if (config.username) setUsername(config.username);
|
||||
|
|
@ -57,6 +64,9 @@ export function useAppConfig() {
|
|||
if (config.instanceLaunchArgs) setInstanceLaunchArgs(config.instanceLaunchArgs);
|
||||
if (config.androidRunner) setAndroidRunner(config.androidRunner);
|
||||
if (config.androidAudioBackend) setAndroidAudioBackend(config.androidAudioBackend);
|
||||
if (config.goldmapperEnabled !== undefined) setGoldmapperEnabled(config.goldmapperEnabled);
|
||||
if (config.goldmapperMappings) setGoldmapperMappings(config.goldmapperMappings);
|
||||
if (config.httpProxy !== undefined) setProxy(config.httpProxy);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -87,9 +97,12 @@ export function useAppConfig() {
|
|||
instanceLaunchArgs,
|
||||
androidRunner,
|
||||
androidAudioBackend,
|
||||
goldmapperEnabled,
|
||||
goldmapperMappings,
|
||||
httpProxy: proxy,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded, instanceLaunchArgs, androidRunner, androidAudioBackend]);
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded, instanceLaunchArgs, androidRunner, androidAudioBackend, goldmapperEnabled, goldmapperMappings, proxy]);
|
||||
|
||||
return {
|
||||
username,
|
||||
|
|
@ -145,5 +158,11 @@ export function useAppConfig() {
|
|||
setAndroidRunner,
|
||||
androidAudioBackend,
|
||||
setAndroidAudioBackend,
|
||||
goldmapperEnabled,
|
||||
setGoldmapperEnabled,
|
||||
goldmapperMappings,
|
||||
setGoldmapperMappings,
|
||||
proxy,
|
||||
setProxy,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { SPLASHES } from "../data/splashes";
|
||||
import { PluginManager } from "../plugins/PluginManager";
|
||||
|
||||
const TRACKS = [
|
||||
"music/Moog City 2.opus",
|
||||
|
|
@ -29,10 +29,16 @@ export function useAudioController({
|
|||
isGameRunning,
|
||||
isWindowVisible,
|
||||
}: AudioControllerProps) {
|
||||
const [splashes, setSplashes] = useState<string[]>(() => PluginManager.instance.getSplashes());
|
||||
const [currentTrack, setCurrentTrack] = useState(0);
|
||||
const [splashIndex, setSplashIndex] = useState(
|
||||
() => Math.floor(Math.random() * SPLASHES.length),
|
||||
() => Math.floor(Math.random() * PluginManager.instance.getSplashes().length),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
PluginManager.instance.setSplashesChangedCallback((s) => setSplashes(s));
|
||||
setSplashes(PluginManager.instance.getSplashes());
|
||||
}, []);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const musicSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const musicGainRef = useRef<GainNode | null>(null);
|
||||
|
|
@ -235,11 +241,12 @@ export function useAudioController({
|
|||
const cycleSplash = useCallback(() => {
|
||||
playSplashSound();
|
||||
let newIndex;
|
||||
if (splashes.length === 0) return;
|
||||
do {
|
||||
newIndex = Math.floor(Math.random() * SPLASHES.length);
|
||||
} while (newIndex === splashIndex && SPLASHES.length > 1);
|
||||
newIndex = Math.floor(Math.random() * splashes.length);
|
||||
} while (newIndex === splashIndex && splashes.length > 1);
|
||||
setSplashIndex(newIndex);
|
||||
}, [playSplashSound, splashIndex]);
|
||||
}, [playSplashSound, splashIndex, splashes]);
|
||||
const skipTrack = useCallback(() => {
|
||||
isManualSkipRef.current = true;
|
||||
setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
|
||||
|
|
@ -346,7 +353,7 @@ export function useAudioController({
|
|||
playBackSound,
|
||||
playSfx,
|
||||
tracks: TRACKS,
|
||||
splashes: SPLASHES,
|
||||
splashes: splashes,
|
||||
startMusic,
|
||||
};
|
||||
}
|
||||
|
|
|
|||