merge dev into main (#197)
Some checks are pending
Nix / build (push) Waiting to run

Co-authored-by: str1k3r <115313679+S1l3ntStr1ke87@users.noreply.github.com>
Co-authored-by: ByteBukkit <codeddev21@gmail.com>
This commit is contained in:
neo 3 2026-10-10 13:03:33 +03:00 committed by GitHub
parent 54ace1339e
commit b611c97aee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
117 changed files with 7132 additions and 2030 deletions

View file

@ -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:

View file

@ -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
View file

@ -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
View file

@ -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

View file

@ -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
---

View file

@ -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",

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/Skins/individul.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

View file

@ -0,0 +1 @@
Copyright © 2004-2012, BOGDAN Software

BIN
public/fonts/rus/minecraft.ttf Executable file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 72 KiB

BIN
public/images/empty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

BIN
public/images/gear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/images/helpbg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
public/images/recess.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View 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.");

View 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
View file

@ -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"

View file

@ -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"] }

Binary file not shown.

View 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);
}

View file

@ -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))?;

View file

@ -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())?;

View file

@ -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);

View 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()
}

View file

@ -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>>,

View file

@ -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 {

View file

@ -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()
}
*/

View file

@ -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)
}
*/

View file

@ -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(&region_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
}
*/

View file

@ -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")

View file

@ -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;

View file

@ -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 }
}
}

View file

@ -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),

View file

@ -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()));
}

View file

@ -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);
}
}
}

View file

@ -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,
}
}

View file

@ -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();

View file

@ -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)
}

View file

@ -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)]

View file

@ -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();

View file

@ -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 {

View file

@ -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",

View file

@ -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>

View file

@ -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" />
</>

View 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;

View file

@ -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 && (

View file

@ -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">

View file

@ -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>

View file

@ -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" />

View file

@ -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();

View file

@ -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>

View file

@ -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>

View file

@ -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 && (

View file

@ -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);

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
)}

View file

@ -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>
);

View 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;

View file

@ -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>

View file

@ -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"
>
&larr; 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>
);

View file

@ -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)",
}}
/>
)}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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;

View file

@ -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();

View file

@ -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>
)}

View file

@ -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(() => {

View file

@ -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 {

View file

@ -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;
}

View file

@ -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!",
];

View file

@ -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,
};
}

View file

@ -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,
};
}

Some files were not shown because too many files have changed in this diff Show more