Compare commits
139 Commits
nightly
...
experimental
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e0ba69dc | |||
| 9ec65fbcc8 | |||
| 5a0b7f1a26 | |||
| 887f0d5bd0 | |||
| 3b91e96074 | |||
| 76bca26143 | |||
| a4ce0686ad | |||
| 96eec9d979 | |||
| 1600b2e942 | |||
| 29916d5c8a | |||
| 1b5acadf99 | |||
| 8817b1e611 | |||
| 9d078eaf2f | |||
| 4b577b5034 | |||
| 15d40c6961 | |||
| c10e4d20ab | |||
| 026d284644 | |||
| 34332e99ee | |||
| c5ff9c6ed7 | |||
| eb0e90f92b | |||
| 37b3c93604 | |||
| b2b279be38 | |||
| 803c1b8762 | |||
| 912a604971 | |||
| a16e3acc91 | |||
| 6c591729ff | |||
| f4841c3be0 | |||
| 17353f6984 | |||
| a2021529b2 | |||
| 7ec96e6a14 | |||
| 6ad97219c9 | |||
| 14e92937e8 | |||
| c9ef1db546 | |||
| 7d530fe258 | |||
| 265a2b3992 | |||
| 5f8f084064 | |||
| a24330ae2b | |||
| b04d1f06eb | |||
| 1411f78388 | |||
| c5a05ca3e2 | |||
| f9487ce632 | |||
| e17fbd2354 | |||
| c087dbcd69 | |||
| 5fb311859a | |||
| b01b1273b1 | |||
| 1b02b443d7 | |||
| d874b194a2 | |||
| f648cbbcc4 | |||
| 212413ff1a | |||
| 7414efce47 | |||
| eddba38829 | |||
| 365a8143ff | |||
| 1ecc47c32f | |||
| b589e43d3e | |||
| 74d2b71084 | |||
| 972a5958c6 | |||
| f52f8b4700 | |||
| 5d38578349 | |||
| 60c8e016da | |||
| 87e841f565 | |||
| 9d80eea7d7 | |||
| e19d311546 | |||
| a6641065ab | |||
| ac9fdec501 | |||
| c25bb7cbaf | |||
| 29f4fdff47 | |||
| 8080a35b16 | |||
| 559ee0452c | |||
| e1df420379 | |||
| 79c819a2eb | |||
| 79bd169439 | |||
| 0bc81e5854 | |||
| eb63406540 | |||
| 1b36112fde | |||
| f960c75f9a | |||
| e7442ab8ad | |||
| f8f9e27d67 | |||
| 657c97a71b | |||
| 341acc9071 | |||
| 754efe1195 | |||
| d9787595db | |||
| 4ccae74746 | |||
| 4f307dee39 | |||
| 3cb787fc72 | |||
| fcb389b86d | |||
| 106cc736bb | |||
| 31d229a48f | |||
| fea0d56f9d | |||
| b18dc6f898 | |||
| c4436b27cd | |||
| 020ed90786 | |||
| 3f269ea08e | |||
| f6cac74de4 | |||
| 1febea2f68 | |||
| 2fe2749d62 | |||
| a691a55885 | |||
| 32fa9b48a6 | |||
| 3cd8340704 | |||
| 2706ad9cbc | |||
| fb52972089 | |||
| 86dc293b26 | |||
| 394c667407 | |||
| eedd9700f4 | |||
| 02fa714f3c | |||
| 8d48b2f938 | |||
| 37a3666022 | |||
| 8dfab72ae2 | |||
| 09bb57e9a6 | |||
| b13260ed05 | |||
| 7ab84170c4 | |||
| afebdffced | |||
| 1a0f51d698 | |||
| 51f2a78469 | |||
| 912d0da76e | |||
| 4a0a329f6b | |||
| 3f4a776600 | |||
| 3088ea0fd3 | |||
| 718aa8f125 | |||
| f046e06e12 | |||
| 24a4599ecf | |||
| 8165e6c22c | |||
| 2d425c7bd0 | |||
| 30e4de0577 | |||
| d51ca80202 | |||
| c49f62e539 | |||
| 55acb96468 | |||
| aa28d50a2b | |||
| 7b25f2afbc | |||
| 7941e8e1da | |||
| e2a3e4b52c | |||
| a78064377d | |||
| a7577bbd72 | |||
| 4a0f2df6eb | |||
| 12fdcf0335 | |||
| 84c9296b51 | |||
| 6b434699e9 | |||
| fc0dbae680 | |||
| 331d3e1ae9 | |||
| c534c98ad5 |
@@ -1,7 +1,8 @@
|
||||
name: 'nightly'
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: '0 17 * * *' #neo: 5PM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
nightly-tauri:
|
||||
@@ -91,7 +92,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
needs: nightly-tauri # Wait for Tauri release to be created
|
||||
container:
|
||||
image: bilelmoussaoui/flatpak-github-actions:freedesktop-23.08
|
||||
options: --privileged
|
||||
|
||||
@@ -91,7 +91,10 @@ jobs:
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
uploadPlainBinary: true
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'Emerald v__VERSION__'
|
||||
releaseBody: |
|
||||
@@ -142,4 +145,4 @@ jobs:
|
||||
tag_name: v${{ needs.get-version.outputs.version }}
|
||||
files: Emerald.Legacy.Launcher.flatpak
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,12 +1,11 @@
|
||||
<div align="center">
|
||||
<img height="150" src="https://raw.githubusercontent.com/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher/refs/heads/main/public/images/icon.png" alt="LCE Emerald Launcher Logo">
|
||||
<img height="150" src="https://raw.githubusercontent.com/LCE-Hub/LCE-Emerald-Launcher/refs/heads/main/public/images/icon.png" alt="LCE Emerald Launcher Logo">
|
||||
<h1>LCE Emerald Launcher</h1>
|
||||
<p><strong>FOSS cross-platform launcher for Minecraft Legacy Console Edition</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/version-1.0.0-blue?style=flat-square" alt="Version">
|
||||
<img src="https://img.shields.io/badge/license-GPL--3.0-green?style=flat-square" alt="License">
|
||||
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey?style=flat-square" alt="Platforms">
|
||||
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20GNU/Linux-lightgrey?style=flat-square" alt="Platforms">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -14,16 +13,17 @@
|
||||
<a href="https://ko-fi.com/kayjann">
|
||||
<img src="https://img.shields.io/badge/ko--fi-Donate%20to%20the%20Project-grey?style=for-the-badge&logo=kofi&logoColor=white&labelColor=FF5E5B" alt="Donate to the Project">
|
||||
</a>
|
||||
<a href="https://discord.gg/vD8akRU24f">
|
||||
<a href="https://discord.gg/NPMp9JhGYH">
|
||||
<img src="https://img.shields.io/badge/discord-Join%20the%20Community-grey?style=for-the-badge&logo=discord&logoColor=white&labelColor=5865F2" alt="Join the Community">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **macOS support for LCE Emerald Launcher is currently in Beta.**
|
||||
> Expect minor bugs, frequent updates, and features that are still being polished as we work toward a stable release.
|
||||
| Main Screen | Versions Menu | Workshop |
|
||||
|-------------|---------------|----------|
|
||||
| <img width="1388" height="918" alt="image" src="https://github.com/user-attachments/assets/28269ade-db3b-4bff-ac15-e8a6eb53a69e" /> | <img width="1551" height="997" alt="image" src="https://github.com/user-attachments/assets/dca543ab-94cf-48ce-8e61-dfe92fe278a7" /> | <img width="1551" height="997" alt="image" src="https://github.com/user-attachments/assets/d7bc0ed2-3bf4-4704-83c3-75c716d13a65" /> |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -31,15 +31,13 @@
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Installation](#installation)
|
||||
- [Windows](#windows)
|
||||
- [macOS](#macos)
|
||||
- [Linux](#linux)
|
||||
- [GNU/Linux](#gnulinux)
|
||||
- [Game Versions & Partnerships](#game-versions--partnerships)
|
||||
- [Building from Source](#building-from-source)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Acknowledgments](#acknowledgments)
|
||||
- [License](#license)
|
||||
|
||||
@@ -47,76 +45,55 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Emerald Legacy Launcher is a **high-performance, open-source launcher** for Minecraft Legacy Console Edition (LCE). Built to centralize the fragmented LCE scene, it provides a lightweight, unified hub for playing your favorite console versions of Minecraft on PC.
|
||||
LCE Emerald Launcher is the easiest way to play Minecraft Legacy Console Edition on PC. Install community builds, manage versions, customize skins, and launch instantly from one lightweight hub.
|
||||
|
||||
**Why Emerald?** Traditional launchers often rely on bloated frameworks, consuming excessive resources. Emerald utilizes a modern **Rust/Tauri** architecture, using only **~15MB of RAM** — leaving your PC's resources dedicated to the game itself.
|
||||
|
||||
The project started as a solo effort and has grown into a collaborative community project, bringing together expertise from various LCE preservation initiatives.
|
||||
**Why Emerald?** Traditional launchers often rely on bloated frameworks, consuming excessive resources. Emerald utilizes a modern **Tauri** architecture, using only a low amount of RAM, leaving your PC's resources dedicated to the game itself.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Automated Setup** | One-click installation for TU19, Legacy Evolved, Revelations, and 360Revived versions |
|
||||
| **Cross-Platform** | Native support for Windows, macOS (Intel & Apple Silicon), and Linux |
|
||||
| **Automated Setup** | One-click installation for neoLegacy, Revelations, 360 Revived, and Hellish Ends |
|
||||
| **Cross-Platform** | Native support for Windows, macOS (Intel & Apple Silicon), and Linux (Steam Deck is supported !) |
|
||||
| **Lightweight** | Very light RAM usage thanks to Rust backend and Tauri framework |
|
||||
| **Easy Configuration** | Built-in settings for username, game parameters, and profiles |
|
||||
|
||||
### Advanced Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **3D Skin Viewer** | Interactive skin preview using Three.js with layer support |
|
||||
| **Skin Viewer** | Interactive skin preview using Three.js with layer support |
|
||||
| **Custom Skins** | Import and manage your own skins with local storage |
|
||||
| **Controller Support** | Full gamepad navigation support (keyboard fallback included) |
|
||||
| **Controller Support** | Full gamepad navigation support (keyboard support included) |
|
||||
| **Discord Rich Presence** | Show your current activity and game status on Discord |
|
||||
| **Workshop and modloader** | *(Coming Soon)* Community content and mod browser |
|
||||
| **Workshop** | Community content |
|
||||
|
||||
---
|
||||
|
||||
## Game Versions & Partnerships
|
||||
|
||||
Emerald Legacy Launcher integrates multiple community-driven builds of Minecraft Legacy Console Edition. Each build brings unique features and improvements to the classic LCE experience.
|
||||
LCE Emerald Launcher integrates multiple community-driven builds of Minecraft Legacy Console Edition. Each build brings unique features and improvements to the classic LCE experience.
|
||||
|
||||
### Available Builds
|
||||
|
||||
| Build | Developer | Platform | Description |
|
||||
|-------|-----------|----------|-------------|
|
||||
| **[Revelations](https://github.com/itsRevela/MinecraftConsoles)** | [@itsRevela](https://github.com/itsRevela) | GitHub | Enhanced LCE with uncapped FPS, graphics fixes, hardcore hearts, and dedicated server security. Features LAN multiplayer, split-screen, and keyboard & mouse support. |
|
||||
| **[360 Revived](https://github.com/BluTac10/360Revived)** | [@BluTac10](https://github.com/BluTac10) | GitHub | PC port of Xbox 360 Edition TU19 with desktop optimizations. Features keyboard & mouse, fullscreen, LAN multiplayer, dedicated server, and split-screen support. |
|
||||
| **[Legacy Evolved](https://codeberg.org/piebot/LegacyEvolved)** | [@piebot](https://codeberg.org/piebot) | Codeberg | Backports newer title updates to LCE TU19 base. Currently porting TU25 (~98% complete) and TU31 (~76% complete). |
|
||||
|
||||
### Coming Soon: Legacy Minecraft Restoration Project (LMRP)
|
||||
|
||||
**LMRP** will be our **first major collaboration** to bring fully functional **online mini-games** back to Legacy Console Edition. This groundbreaking project aims to:
|
||||
|
||||
- Restore classic LCE mini-games (Battle, Tumble, Glide)
|
||||
- Preserve the authentic mini-game mechanics and maps
|
||||
|
||||
Stay tuned for updates on this exciting partnership! :)
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img width="1680" alt="Emerald Legacy Launcher Screenshot" src="https://github.com/user-attachments/assets/a5dd6aa1-2200-4f08-84e5-e75a8052ba79" />
|
||||
| **[neoLegacy](https://github.com/pieeebot/neoLegacy)** | [@pieeebot](https://github.com/pieeebot) | GitHub | Backports newer title updates to LCE TU19 base. And Minigames |
|
||||
| **[Revelations](https://github.com/itsRevela/LCE-Revelations)** | [@itsRevela](https://github.com/itsRevela) | GitHub | Enhanced LCE with uncapped FPS, graphics fixes, hardcore hearts, and dedicated server security. Features LAN multiplayer, split-screen, and keyboard & mouse support |
|
||||
| **[360 Revived](https://github.com/BluTac10/360Revived)** | [@BluTac10](https://github.com/BluTac10) | GitHub | PC port of Xbox 360 Edition TU19 with desktop optimizations. Features keyboard & mouse, fullscreen, LAN multiplayer, dedicated server, and split-screen support |
|
||||
| **[Hellish Ends](https://github.com/deadvoxelx/HellishEnds)** | [DeadVoxelx](https://github.com/deadvoxelx) | GitHub | Modded build that overhauls Nether and End |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
<a href="https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases">
|
||||
<img src="https://raw.githubusercontent.com/rubenpgrady/get-it-on-github/refs/heads/main/get-it-on-github.png" style="width: 160px">
|
||||
</a>
|
||||
|
||||
### Windows
|
||||
|
||||
Download the latest release from [GitHub Releases](https://github.com/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher/releases):
|
||||
|
||||
| Format | Best For |
|
||||
|--------|----------|
|
||||
| `.exe` (NSIS) | Standard installation with uninstaller |
|
||||
| `.msi` | Enterprise/IT deployment |
|
||||
| `.msi` | A fallback option in case the `.exe` does not work |
|
||||
|
||||
**⚠️ Windows SmartScreen Warning:**
|
||||
> Since the launcher is unsigned, Windows may show a "Windows protected your PC" warning. To proceed:
|
||||
@@ -128,7 +105,7 @@ Download the latest release from [GitHub Releases](https://github.com/Emerald-Le
|
||||
| Format | Architecture |
|
||||
|--------|-------------|
|
||||
| `.dmg` (x64) | Intel Macs |
|
||||
| `.dmg` (aarch64) | Apple Silicon (M1/M2/M3) |
|
||||
| `.dmg` (aarch64) | Apple Silicon (M-series and A-series) |
|
||||
|
||||
**Installation Steps:**
|
||||
1. Download the appropriate DMG for your Mac
|
||||
@@ -137,7 +114,7 @@ Download the latest release from [GitHub Releases](https://github.com/Emerald-Le
|
||||
- Right-click the app → **Open** → confirm **Open**
|
||||
- Or run: `xattr -cr "/Applications/Emerald Legacy Launcher.app"`
|
||||
|
||||
### Linux
|
||||
### GNU/Linux
|
||||
|
||||
Multiple distribution formats available:
|
||||
|
||||
@@ -146,7 +123,7 @@ Multiple distribution formats available:
|
||||
| `.deb` | Debian, Ubuntu, Linux Mint |
|
||||
| `.rpm` | RHEL, Fedora, openSUSE |
|
||||
| `.AppImage` | Universal (no installation required) |
|
||||
| `Flatpak` | Universal with sandboxing |
|
||||
| `.flatpak` | Universal with sandboxing (recommended over AppImage) |
|
||||
|
||||
**Flatpak Installation:**
|
||||
```bash
|
||||
@@ -166,37 +143,7 @@ sudo dnf install webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel p
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Node.js** (LTS recommended)
|
||||
- **Rust** (latest stable)
|
||||
- **PNPM** (recommended) or NPM
|
||||
- Platform-specific dependencies:
|
||||
- Linux: `WebKit2GTK-4.1`
|
||||
- macOS: WebKit (pre-installed)
|
||||
- Windows: Edge WebView2 Runtime (usually pre-installed)
|
||||
|
||||
### Build Steps
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher.git
|
||||
cd Emerald-Legacy-Launcher
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Development mode
|
||||
pnpm tauri dev
|
||||
|
||||
# Production build
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
**Flatpak Build:**
|
||||
```bash
|
||||
pnpm flatpak
|
||||
```
|
||||
Refer to [BUILDING.md](BUILDING.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -230,36 +177,18 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0
|
||||
### Controller Not Detected
|
||||
|
||||
- Connect controller before launching the launcher
|
||||
- Xbox and PlayStation controllers are best supported
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
Track our progress and upcoming features on the [Development Roadmap](https://github.com/orgs/Emerald-Legacy-Launcher/projects/2).
|
||||
- PlayStation controllers are not supported in-game but work in launcher. Use Steam Input.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
*Proudly developed by a passionate team from the LCE community.*
|
||||
|
||||
- **The Emerald Team** — Technical development and maintenance
|
||||
- **4J Studios & Mojang** — Original creators of Legacy Console Edition
|
||||
- **The LCE Community** — Research and foundations for LCE on PC
|
||||
- **Tauri & Rust Communities** — Core technologies and ecosystem
|
||||
- **The Emerald Team** - Technical development and maintenance
|
||||
- **4J Studios & Mojang** - Original creators of Legacy Console Edition
|
||||
- **The LCE Community** - Research and foundations for LCE on PC
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **[GNU GPL v3 License](LICENSE)**.
|
||||
|
||||
You are free to:
|
||||
- **Use** — Use the launcher for any purpose
|
||||
- **Study** — Access and study the source code
|
||||
- **Share** — Redistribute copies
|
||||
- **Improve** — Submit improvements and modifications
|
||||
|
||||
See `LICENSE` file for full terms.
|
||||
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
{
|
||||
"name": "emerald-legacy-launcher",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:build": "tauri build && pnpm run post-build:macos",
|
||||
"post-build:macos": "sh src-tauri/scripts/post-build-macos.sh",
|
||||
"flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"framer-motion": "^12.36.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "^0.183.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.183.1",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"tauri-plugin-drpc": "^1.0.3",
|
||||
"tauri-plugin-gamepad-api": "^0.0.5"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "emerald-legacy-launcher",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:build": "tauri build && pnpm run post-build:macos",
|
||||
"post-build:macos": "sh src-tauri/scripts/post-build-macos.sh",
|
||||
"flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "~2.3.1",
|
||||
"@tauri-apps/plugin-updater": "~2.10.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.183.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tauri-plugin-drpc": "^1.0.3",
|
||||
"tauri-plugin-gamepad-api": "^0.0.5",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 893 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 715 B |
|
After Width: | Height: | Size: 400 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -47,6 +47,21 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
@@ -284,6 +299,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -357,6 +378,12 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -512,6 +539,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -624,12 +657,37 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -724,6 +782,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -734,6 +798,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -911,6 +986,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.8"
|
||||
@@ -933,22 +1014,30 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "emerald-legacy-launcher"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"image 0.24.9",
|
||||
"libc",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.11.27",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"steam_shortcuts_util",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-drpc",
|
||||
"tauri-plugin-gamepad",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1055,6 +1144,21 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
"half",
|
||||
"lebe",
|
||||
"miniz_oxide",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -1080,6 +1184,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1424,6 +1538,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gilrs"
|
||||
version = "0.11.1"
|
||||
@@ -1627,6 +1751,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1805,6 +1940,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"hyper 1.9.0",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
@@ -1990,6 +2140,24 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.24.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder",
|
||||
"color_quant",
|
||||
"exr",
|
||||
"gif",
|
||||
"jpeg-decoder",
|
||||
"num-traits",
|
||||
"png 0.17.16",
|
||||
"qoi",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -2163,6 +2331,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-decoder"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.94"
|
||||
@@ -2226,6 +2403,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -2391,6 +2574,18 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2514,6 +2709,27 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom_locate"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"memchr",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -2621,6 +2837,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2647,6 +2864,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2763,6 +2992,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3212,6 +3455,15 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -3367,6 +3619,26 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -3492,15 +3764,20 @@ dependencies = [
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3536,6 +3813,20 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpcdiscord"
|
||||
version = "0.2.6"
|
||||
@@ -3577,6 +3868,32 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
@@ -3586,6 +3903,53 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3928,6 +4292,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4059,6 +4434,18 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "steam_shortcuts_util"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"crc32fast",
|
||||
"nom",
|
||||
"nom_locate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -4114,6 +4501,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -4256,6 +4649,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4279,7 +4683,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http 1.4.0",
|
||||
"image",
|
||||
"image 0.25.10",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4448,6 +4852,49 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.2",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.10.1"
|
||||
@@ -4622,6 +5069,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"jpeg-decoder",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -4701,6 +5159,30 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4924,6 +5406,26 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -5006,6 +5508,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -5404,6 +5912,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -5440,6 +5957,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -6197,6 +6720,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
@@ -6322,6 +6855,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
@@ -6355,12 +6894,33 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "emerald-legacy-launcher"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
description = "A FOSS, cross-platform launcher for Minecraft Legacy Edition"
|
||||
authors = ["Emerald Team"]
|
||||
edition = "2021"
|
||||
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri = { version = "2", features = [ "tray-icon", "image-png"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -24,5 +24,15 @@ tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
base64 = "0.21"
|
||||
tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "native-tls"] }
|
||||
url = "2"
|
||||
rand = "0.8"
|
||||
http = "1"
|
||||
rfd = "0.15"
|
||||
libc = "0.2"
|
||||
image = "0.24"
|
||||
steam_shortcuts_util = "1.1.8"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-set-focus",
|
||||
"opener:default",
|
||||
"gamepad:default",
|
||||
"drpc:allow-is-running",
|
||||
"drpc:default",
|
||||
"core:event:allow-listen",
|
||||
"core:app:default"
|
||||
]
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:app:default",
|
||||
"core:event:default",
|
||||
"core:image:default",
|
||||
"core:menu:default",
|
||||
"core:path:default",
|
||||
"core:resources:default",
|
||||
"core:tray:default",
|
||||
"core:webview:default",
|
||||
"core:window:default",
|
||||
"opener:default",
|
||||
"gamepad:default",
|
||||
"drpc:default",
|
||||
"updater:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{
|
||||
"path": "**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-reveal-item-in-dir",
|
||||
"allow": [
|
||||
{
|
||||
"path": "**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"process:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"process:default"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-set-title","core:window:allow-start-dragging","core:window:allow-show","core:window:allow-hide","core:window:allow-unminimize","core:window:allow-set-focus","opener:default","gamepad:default","drpc:allow-is-running","drpc:default","core:event:allow-listen","core:app:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:window:default","opener:default","gamepad:default","drpc:default","updater:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]},"process:default"]},"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","process:default"],"platforms":["macOS","windows","linux"]}}
|
||||
@@ -2443,6 +2443,90 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2443,6 +2443,90 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -10,9 +10,16 @@ fn main() {
|
||||
use std::thread;
|
||||
let stage = env::var("EMERALD_LAUNCH_STAGE").unwrap_or_else(|_| "0".to_string());
|
||||
if stage == "0" {
|
||||
let mut child = Command::new(env::current_exe().unwrap())
|
||||
.env("EMERALD_LAUNCH_STAGE", "1")
|
||||
.env("LD_PRELOAD", "LD_PRELOAD=/usr/lib64/libwayland-client.so.0") //neo: hacky way to fix appimage on systems like gentoo
|
||||
let mut cmd = Command::new(env::current_exe().unwrap());
|
||||
cmd.args(env::args().skip(1));
|
||||
cmd.env("EMERALD_LAUNCH_STAGE", "1");
|
||||
|
||||
let wayland_libs = ["/usr/lib64/libwayland-client.so.0", "/usr/lib/libwayland-client.so.0"];
|
||||
if let Some(path) = wayland_libs.iter().find(|p| std::path::Path::new(p).exists()) {
|
||||
cmd.env("LD_PRELOAD", path);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -69,14 +76,18 @@ fn main() {
|
||||
if found_error {
|
||||
println!("Emerald: Automatic recovery triggered for graphics crash/invisible launch.");
|
||||
}
|
||||
|
||||
let mut retry_child = Command::new(env::current_exe().unwrap())
|
||||
.env("EMERALD_LAUNCH_STAGE", "2")
|
||||
let mut retry_cmd = Command::new(env::current_exe().unwrap());
|
||||
retry_cmd.args(env::args().skip(1));
|
||||
retry_cmd.env("EMERALD_LAUNCH_STAGE", "2")
|
||||
.env("GDK_BACKEND", "x11")
|
||||
.env("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
|
||||
.env("WEBKIT_DISABLE_COMPOSITING_MODE", "1")
|
||||
.env("LD_PRELOAD", "LD_PRELOAD=/usr/lib64/libwayland-client.so.0") //neo: hacky way to fix appimage on systems like gentoo
|
||||
.spawn()
|
||||
.expect("failed to spawn fallback child process");
|
||||
.env("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
|
||||
if let Some(path) = wayland_libs.iter().find(|p| std::path::Path::new(p).exists()) {
|
||||
retry_cmd.env("LD_PRELOAD", path);
|
||||
}
|
||||
|
||||
let mut retry_child = retry_cmd.spawn().expect("failed to spawn fallback child process");
|
||||
|
||||
let retry_status = retry_child.wait().expect("failed to wait on fallback child process");
|
||||
exit(retry_status.code().unwrap_or(1));
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LCE Emerald Launcher",
|
||||
"version": "1.0.1",
|
||||
"identifier": "com.emerald.legacy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "LCE Emerald Launcher",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
"resources/DLC"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"signingIdentity": null,
|
||||
"providerShortName": "Emerald"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LCE Emerald Launcher",
|
||||
"version": "1.1.0",
|
||||
"identifier": "com.emerald.legacy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "LCE Emerald Launcher",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"visible": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1NTk5MjQ4QjMxQjFCQgpSV1M3c1RHTEpKbFZBMEtxMnNUZ3F5V2FmQmQrREY5Z0pkbnJuUlBYL2d1cUtzRkJIeDFkUkR4RQo=",
|
||||
"endpoints": ["https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest/download/latest.json"],
|
||||
"windows": {"installMode": "passive"}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
"resources/DLC"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"signingIdentity": null,
|
||||
"providerShortName": "Emerald"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ interface AchievementToastProps {
|
||||
onClose: () => void;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
variant?: "error" | "update";
|
||||
variant?: "error" | "update" | "steam";
|
||||
}
|
||||
|
||||
export function AchievementToast({
|
||||
@@ -43,6 +43,11 @@ export function AchievementToast({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (variant === "steam") {
|
||||
return (
|
||||
<img src="/images/steam.png" alt="Steam" className="w-8 h-8 object-contain" style={{ imageRendering: "pixelated", filter: "brightness(0) invert(1)" }} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -72,9 +77,9 @@ export function AchievementToast({
|
||||
onClick={
|
||||
onClick
|
||||
? () => {
|
||||
onClick();
|
||||
onClose();
|
||||
}
|
||||
onClick();
|
||||
onClose();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`}
|
||||
|
||||
@@ -1,70 +1,72 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useUI } from '../../context/LauncherContext';
|
||||
|
||||
interface PanoramaProps {
|
||||
profile: string;
|
||||
isDay: boolean;
|
||||
}
|
||||
|
||||
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
|
||||
const { isWindowVisible } = useUI();
|
||||
const PANORAMA_PROFILES = ['legacy_evolved', '360revived'];
|
||||
const profileId = PANORAMA_PROFILES.includes(profile) ? profile : 'legacy_evolved';
|
||||
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? 'Day' : 'Night'}.png`;
|
||||
|
||||
const [bgWidth, setBgWidth] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const updateWidth = () => {
|
||||
if (!containerRef.current) return;
|
||||
const img = new Image();
|
||||
img.src = currentPanorama;
|
||||
img.onload = () => {
|
||||
if (!active || !containerRef.current) return;
|
||||
const height = containerRef.current.clientHeight;
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
setBgWidth(Math.ceil(height * aspectRatio));
|
||||
};
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener('resize', updateWidth);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('resize', updateWidth);
|
||||
};
|
||||
}, [currentPanorama]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{bgWidth && (
|
||||
<style>{`
|
||||
@keyframes panoramaLoop {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
|
||||
<div 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>
|
||||
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default PanoramaBackground;
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useUI } from "../../context/LauncherContext";
|
||||
|
||||
interface PanoramaProps {
|
||||
profile: string;
|
||||
isDay: boolean;
|
||||
}
|
||||
|
||||
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
|
||||
const { isWindowVisible } = useUI();
|
||||
const baseId = profile;
|
||||
const profileId = baseId ? baseId : "vanilla_tu19";
|
||||
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? "Day" : "Night"}.png`;
|
||||
const [bgWidth, setBgWidth] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const updateWidth = () => {
|
||||
if (!containerRef.current) return;
|
||||
const img = new Image();
|
||||
img.src = currentPanorama;
|
||||
img.onload = () => {
|
||||
if (!active || !containerRef.current) return;
|
||||
const height = containerRef.current.clientHeight;
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
setBgWidth(Math.ceil(height * aspectRatio));
|
||||
};
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("resize", updateWidth);
|
||||
};
|
||||
}, [currentPanorama]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{bgWidth && (
|
||||
<style>{`
|
||||
@keyframes panoramaLoop {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
|
||||
<div
|
||||
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>
|
||||
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default PanoramaBackground;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
interface ScreenshotImageProps {
|
||||
path: string;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
style?: React.CSSProperties;
|
||||
fallbackSrc?: string;
|
||||
}
|
||||
|
||||
export function ScreenshotImage({
|
||||
path,
|
||||
className,
|
||||
alt,
|
||||
loading,
|
||||
style,
|
||||
fallbackSrc,
|
||||
}: ScreenshotImageProps) {
|
||||
const [src, setSrc] = useState<string | undefined>(fallbackSrc);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSrc(fallbackSrc);
|
||||
TauriService.readScreenshotAsDataUrl(path)
|
||||
.then((url) => {
|
||||
if (!cancelled) setSrc(url);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && fallbackSrc) setSrc(fallbackSrc);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, fallbackSrc]);
|
||||
|
||||
const handleError = () => {
|
||||
if (fallbackSrc) setSrc(fallbackSrc);
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
className={className}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
style={style}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useRef, memo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { PCKAsset, PCKAssetType } from '../../types/pck';
|
||||
|
||||
interface SkinPreview3DProps {
|
||||
asset: PCKAsset;
|
||||
previewUrl?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
enum SKIN_ANIM {
|
||||
STATIONARY_ARMS = 1 << 0,
|
||||
ZOMBIE_ARMS = 1 << 1,
|
||||
STATIONARY_LEGS = 1 << 2,
|
||||
BAD_SANTA_IDLE = 1 << 3,
|
||||
UNKNOWN_EFFECT = 1 << 4,
|
||||
SYNCHRONIZED_LEGS = 1 << 5,
|
||||
SYNCHRONIZED_ARMS = 1 << 6,
|
||||
STATUE_OF_LIBERTY = 1 << 7,
|
||||
HIDE_ARMOR = 1 << 8,
|
||||
FIRST_PERSON_BOBBING_DISABLED = 1 << 9,
|
||||
HIDE_HEAD = 1 << 10,
|
||||
HIDE_RIGHT_ARM = 1 << 11,
|
||||
HIDE_LEFT_ARM = 1 << 12,
|
||||
HIDE_BODY = 1 << 13,
|
||||
HIDE_RIGHT_LEG = 1 << 14,
|
||||
HIDE_LEFT_LEG = 1 << 15,
|
||||
HIDE_HAT = 1 << 16,
|
||||
BACKWARDS_CROUCH = 1 << 17,
|
||||
MODERN_WIDE_FORMAT = 1 << 18,
|
||||
SLIM_FORMAT = 1 << 19,
|
||||
HIDE_LEFT_SLEEVE = 1 << 20,
|
||||
HIDE_RIGHT_SLEEVE = 1 << 21,
|
||||
HIDE_LEFT_PANT = 1 << 22,
|
||||
HIDE_RIGHT_PANT = 1 << 23,
|
||||
HIDE_JACKET = 1 << 24,
|
||||
ALLOW_HEAD_ARMOR = 1 << 25,
|
||||
ALLOW_RIGHT_ARM_ARMOR = 1 << 26,
|
||||
ALLOW_LEFT_ARM_ARMOR = 1 << 27,
|
||||
ALLOW_CHESTPLATE = 1 << 28,
|
||||
ALLOW_RIGHT_LEGGING = 1 << 29,
|
||||
ALLOW_LEFT_LEGGING = 1 << 30,
|
||||
DINNER_BONE_RENDERING = 1 << 31
|
||||
}
|
||||
|
||||
const SkinPreview3D = memo(function SkinPreview3D({ asset, previewUrl, className }: SkinPreview3DProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const playerGroupRef = useRef<THREE.Group | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRef.current) return;
|
||||
const width = mountRef.current.clientWidth;
|
||||
const height = mountRef.current.clientHeight;
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
camera.position.set(0, 0, 50);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
mountRef.current.innerHTML = "";
|
||||
mountRef.current.appendChild(renderer.domElement);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
dl.position.set(10, 20, 10);
|
||||
scene.add(dl);
|
||||
const playerGroup = new THREE.Group();
|
||||
playerGroup.position.y = 4;
|
||||
scene.add(playerGroup);
|
||||
playerGroupRef.current = playerGroup;
|
||||
const render = () => {
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
const isFallbackUrl = !previewUrl;
|
||||
const url = previewUrl || URL.createObjectURL(new Blob([asset.data as any], { type: 'image/png' }));
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let active = true;
|
||||
textureLoader.load(url, (texture) => {
|
||||
if (!active) return;
|
||||
texture.magFilter = THREE.NearestFilter;
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
const img = texture.image;
|
||||
const isLegacy = img.height === 32;
|
||||
const animProp = asset.properties.find(p => p.key === "ANIM");
|
||||
const animValue = animProp ? parseInt(animProp.value) || 0 : 0;
|
||||
const slimFormat = !!(animValue & SKIN_ANIM.SLIM_FORMAT);
|
||||
const texW = img.width || 64;
|
||||
const texH = img.height || 32;
|
||||
const createFaceMaterial = (x: number, y: number, w: number, h: number, flipX = false, flipY = false) => {
|
||||
const matTex = texture.clone();
|
||||
matTex.repeat.set((flipX ? -w : w) / texW, (flipY ? -h : h) / texH);
|
||||
matTex.offset.set((flipX ? (x + w) : x) / texW, 1 - (flipY ? y : (y + h)) / texH);
|
||||
matTex.needsUpdate = true;
|
||||
return new THREE.MeshLambertMaterial({ map: matTex, transparent: true, alphaTest: 0.5, side: THREE.FrontSide });
|
||||
};
|
||||
|
||||
const createPart = (w: number, h: number, d: number, uv: any, overlayUv?: any, isMirror = false) => {
|
||||
const group = new THREE.Group();
|
||||
const geo = new THREE.BoxGeometry(w, h, d);
|
||||
const getMats = (uvSet: any) => {
|
||||
return [
|
||||
createFaceMaterial(uvSet.right[0], uvSet.right[1], uvSet.right[2], uvSet.right[3], isMirror), // +x
|
||||
createFaceMaterial(uvSet.left[0], uvSet.left[1], uvSet.left[2], uvSet.left[3], isMirror), // -x
|
||||
createFaceMaterial(uvSet.top[0], uvSet.top[1], uvSet.top[2], uvSet.top[3], isMirror, true), // +y
|
||||
createFaceMaterial(uvSet.bottom[0], uvSet.bottom[1], uvSet.bottom[2], uvSet.bottom[3], isMirror, true), // -y
|
||||
createFaceMaterial(uvSet.front[0], uvSet.front[1], uvSet.front[2], uvSet.front[3], isMirror), // +z
|
||||
createFaceMaterial(uvSet.back[0], uvSet.back[1], uvSet.back[2], uvSet.back[3], !isMirror) // -z
|
||||
];
|
||||
};
|
||||
|
||||
const mesh = new THREE.Mesh(geo, getMats(uv));
|
||||
group.add(mesh);
|
||||
if (overlayUv) {
|
||||
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
|
||||
const oMesh = new THREE.Mesh(oGeo, getMats(overlayUv));
|
||||
group.add(oMesh);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
const limbUv = (x: number, y: number, w = 4) => ({
|
||||
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]
|
||||
});
|
||||
|
||||
if (asset.type === PCKAssetType.CAPE) {
|
||||
const capeUv = {
|
||||
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 cape = createPart(10, 16, 1, capeUv);
|
||||
cape.position.y = 2;
|
||||
playerGroup.add(cape);
|
||||
} else {
|
||||
const armW = slimFormat ? 3 : 4;
|
||||
|
||||
if (!(animValue & SKIN_ANIM.HIDE_HEAD)) {
|
||||
const headUv = { 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 = (animValue & SKIN_ANIM.HIDE_HAT) ? undefined : { 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(8, 8, 8, headUv, hatUv);
|
||||
head.position.y = 10;
|
||||
playerGroup.add(head);
|
||||
}
|
||||
|
||||
if (!(animValue & SKIN_ANIM.HIDE_BODY)) {
|
||||
const bodyUv = { 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 = (isLegacy || (animValue & SKIN_ANIM.HIDE_JACKET)) ? undefined : { 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(8, 12, 4, bodyUv, jacketUv));
|
||||
}
|
||||
|
||||
if (!(animValue & SKIN_ANIM.HIDE_RIGHT_ARM)) {
|
||||
const rArmUv = limbUv(40, 16, armW);
|
||||
const rSleeveUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_RIGHT_SLEEVE)) ? undefined : limbUv(40, 32, armW);
|
||||
const rightArm = createPart(armW, 12, 4, rArmUv, rSleeveUv);
|
||||
rightArm.position.set(slimFormat ? -5.5 : -6, 0, 0);
|
||||
playerGroup.add(rightArm);
|
||||
}
|
||||
|
||||
if (!(animValue & SKIN_ANIM.HIDE_LEFT_ARM)) {
|
||||
const lArmUv = isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW);
|
||||
const lSleeveUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_LEFT_SLEEVE)) ? undefined : limbUv(48, 48, armW);
|
||||
const leftArm = createPart(armW, 12, 4, lArmUv, lSleeveUv, isLegacy);
|
||||
leftArm.position.set(slimFormat ? 5.5 : 6, 0, 0);
|
||||
playerGroup.add(leftArm);
|
||||
}
|
||||
|
||||
if (!(animValue & SKIN_ANIM.HIDE_RIGHT_LEG)) {
|
||||
const rLegUv = limbUv(0, 16);
|
||||
const rPantUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_RIGHT_PANT)) ? undefined : limbUv(0, 32);
|
||||
const rightLeg = createPart(4, 12, 4, rLegUv, rPantUv);
|
||||
rightLeg.position.set(-2, -12, 0);
|
||||
playerGroup.add(rightLeg);
|
||||
}
|
||||
|
||||
if (!(animValue & SKIN_ANIM.HIDE_LEFT_LEG)) {
|
||||
const lLegUv = isLegacy ? limbUv(0, 16) : limbUv(16, 48);
|
||||
const lPantUv = (isLegacy || (animValue & SKIN_ANIM.HIDE_LEFT_PANT)) ? undefined : limbUv(0, 48);
|
||||
const leftLeg = createPart(4, 12, 4, lLegUv, lPantUv, isLegacy);
|
||||
leftLeg.position.set(2, -12, 0);
|
||||
playerGroup.add(leftLeg);
|
||||
}
|
||||
}
|
||||
|
||||
const boxProps = asset.properties.filter(p => p.key === "BOX");
|
||||
boxProps.forEach(prop => {
|
||||
const parts = prop.value.split(/\s+/);
|
||||
if (parts.length >= 11) {
|
||||
const type = parts[0];
|
||||
const bx = parseFloat(parts[1]);
|
||||
const by = parseFloat(parts[2]);
|
||||
const bz = parseFloat(parts[3]);
|
||||
const bw = parseFloat(parts[4]);
|
||||
const bh = parseFloat(parts[5]);
|
||||
const bd = parseFloat(parts[6]);
|
||||
const bu = parseFloat(parts[7]);
|
||||
const bv = parseFloat(parts[8]);
|
||||
const mir = parseInt(parts[10]) === 1;
|
||||
const scale = parseFloat(parts[11] || "0");
|
||||
|
||||
const uv = {
|
||||
top: [bu + bd, bv, bw, bd],
|
||||
bottom: [bu + bd + bw, bv, bw, bd],
|
||||
right: [bu, bv + bd, bd, bh],
|
||||
front: [bu + bd, bv + bd, bw, bh],
|
||||
left: [bu + bd + bw, bv + bd, bd, bh],
|
||||
back: [bu + 2 * bd + bw, bv + bd, bw, bh]
|
||||
};
|
||||
|
||||
const boxPart = createPart(bw, bh, bd, uv, undefined, mir);
|
||||
|
||||
let ox = 0, oy = 0, oz = 0;
|
||||
if (type === "HEAD_DEFAULT") oy = -4;
|
||||
else if (type === "HEAD") oy = -8;
|
||||
else if (type === "BODY") oy = 2;
|
||||
else if (type === "ARM0") { ox = -5; oy = 2; }
|
||||
else if (type === "ARM1") { ox = 5; oy = 2; }
|
||||
else if (type === "LEG0") { ox = -1.9; oy = 12; }
|
||||
else if (type === "LEG1") { ox = 1.9; oy = 12; }
|
||||
|
||||
boxPart.position.set(ox + bx + bw / 2, -(oy + by) - bh / 2, oz + bz + bd / 2);
|
||||
if (scale !== 0) boxPart.scale.set(1 + scale / bw, 1 + scale / bh, 1 + scale / bd);
|
||||
playerGroup.add(boxPart);
|
||||
}
|
||||
});
|
||||
|
||||
playerGroup.rotation.y = -0.3;
|
||||
render();
|
||||
}, undefined, (err) => {
|
||||
console.error("Failed to load skin texture", err);
|
||||
});
|
||||
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
isDragging = true;
|
||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
const onMouseUp = () => { isDragging = false; };
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging && playerGroupRef.current) {
|
||||
playerGroupRef.current.rotation.y += (e.clientX - previousMousePosition.x) * 0.01;
|
||||
playerGroupRef.current.rotation.x += (e.clientY - previousMousePosition.y) * 0.01;
|
||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
camera.position.z += e.deltaY * 0.1;
|
||||
camera.position.z = Math.max(20, Math.min(camera.position.z, 200));
|
||||
render();
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
renderer.domElement.addEventListener("wheel", onWheel);
|
||||
|
||||
const handleResize = () => {
|
||||
if (!mountRef.current || !renderer) return;
|
||||
const w = mountRef.current.clientWidth;
|
||||
const h = mountRef.current.clientHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
render();
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (isFallbackUrl) URL.revokeObjectURL(url);
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
if (object.geometry) object.geometry.dispose();
|
||||
if (object.material) {
|
||||
if (Array.isArray(object.material)) {
|
||||
object.material.forEach((mat) => {
|
||||
if (mat.map) mat.map.dispose();
|
||||
mat.dispose();
|
||||
});
|
||||
} else {
|
||||
if (object.material.map) object.material.map.dispose();
|
||||
object.material.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
renderer.dispose();
|
||||
};
|
||||
}, [asset, previewUrl]);
|
||||
|
||||
return <div ref={mountRef} className={`w-full h-full cursor-move ${className}`} />;
|
||||
});
|
||||
|
||||
export default SkinPreview3D;
|
||||
@@ -9,61 +9,53 @@ interface SkinViewerProps {
|
||||
setUsername: (name: string) => void;
|
||||
playPressSound: () => void;
|
||||
skinUrl: string;
|
||||
capeUrl?: string | null;
|
||||
setSkinUrl: (url: string) => void;
|
||||
setActiveView: (view: string) => void;
|
||||
isFocusedSection: boolean;
|
||||
onNavigateRight: () => void;
|
||||
}
|
||||
|
||||
const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSound, skinUrl, setSkinUrl, setActiveView, isFocusedSection, onNavigateRight }: SkinViewerProps) {
|
||||
const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSound, skinUrl, capeUrl, setSkinUrl, setActiveView, isFocusedSection, onNavigateRight }: SkinViewerProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const { legacyMode } = useConfig();
|
||||
|
||||
const [showLayers, setShowLayers] = useLocalStorage('lce-show-layers', true);
|
||||
const overlaysRef = useRef<THREE.Mesh[]>([]);
|
||||
const capeRef = useRef<THREE.Group | null>(null);
|
||||
const requestRenderRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRef.current) return;
|
||||
|
||||
const width = 260;
|
||||
const height = 450;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
|
||||
camera.position.set(0, 0, 68);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
mountRef.current.innerHTML = "";
|
||||
mountRef.current.appendChild(renderer.domElement);
|
||||
|
||||
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);
|
||||
|
||||
const playerGroup = new THREE.Group();
|
||||
playerGroup.position.y = -1.5;
|
||||
scene.add(playerGroup);
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
textureLoader.load(skinUrl || "/images/Default.png", (texture) => {
|
||||
texture.magFilter = THREE.NearestFilter;
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
const img = texture.image;
|
||||
const isLegacy = img.height === 32;
|
||||
|
||||
const createFaceMaterial = (x: number, y: number, w: number, h: number, flipX = false, flipY = false) => {
|
||||
const matTex = texture.clone();
|
||||
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / img.height);
|
||||
matTex.offset.set((flipX ? (x + w) : x) / 64, 1 - (flipY ? y : (y + h)) / img.height);
|
||||
const createFaceMaterial = (x: number, y: number, w: number, h: number, flipX = false, flipY = false, tex = texture) => {
|
||||
const matTex = tex.clone();
|
||||
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / tex.image.height);
|
||||
matTex.offset.set((flipX ? (x + w) : x) / 64, 1 - (flipY ? y : (y + h)) / tex.image.height);
|
||||
matTex.needsUpdate = true;
|
||||
return new THREE.MeshLambertMaterial({ map: matTex, transparent: true, alphaTest: 0.5, side: THREE.FrontSide });
|
||||
};
|
||||
@@ -71,7 +63,6 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
const createPart = (w: number, h: number, d: number, uv: any, overlayUv?: any, swapMats = false, isLegacyMirror = false) => {
|
||||
const group = new THREE.Group();
|
||||
const geo = new THREE.BoxGeometry(w, h, d);
|
||||
|
||||
const getMats = (uvSet: any) => {
|
||||
const flipX = isLegacyMirror;
|
||||
return [
|
||||
@@ -86,7 +77,6 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
|
||||
const mesh = new THREE.Mesh(geo, getMats(uv));
|
||||
group.add(mesh);
|
||||
|
||||
if (overlayUv) {
|
||||
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
|
||||
const oMesh = new THREE.Mesh(oGeo, getMats(overlayUv));
|
||||
@@ -113,32 +103,57 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
return data[3] === 0;
|
||||
})();
|
||||
const armW = isSlim ? 3 : 4;
|
||||
|
||||
const headUv = { 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 = { 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(8, 8, 8, headUv, hatUv);
|
||||
head.position.y = 10;
|
||||
playerGroup.add(head);
|
||||
|
||||
const bodyUv = { 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 = { 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(8, 12, 4, bodyUv, isLegacy ? undefined : jacketUv));
|
||||
|
||||
const rightArm = createPart(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(armW, 12, 4, isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW), isLegacy ? undefined : limbUv(48, 48, armW), true, isLegacy);
|
||||
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
|
||||
playerGroup.add(leftArm);
|
||||
|
||||
const rightLeg = createPart(4, 12, 4, limbUv(0, 16), isLegacy ? undefined : limbUv(0, 32));
|
||||
rightLeg.position.set(-2, -12, 0);
|
||||
playerGroup.add(rightLeg);
|
||||
|
||||
const leftLeg = createPart(4, 12, 4, isLegacy ? limbUv(0, 16) : limbUv(16, 48), isLegacy ? undefined : limbUv(0, 48), true, isLegacy);
|
||||
leftLeg.position.set(2, -12, 0);
|
||||
playerGroup.add(leftLeg);
|
||||
if (capeUrl) {
|
||||
textureLoader.load(capeUrl, (capeTexture) => {
|
||||
capeTexture.magFilter = THREE.NearestFilter;
|
||||
capeTexture.minFilter = THREE.NearestFilter;
|
||||
capeTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
const capeUv = {
|
||||
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(capeUv.left[0], capeUv.left[1], capeUv.left[2], capeUv.left[3], false, false, capeTexture),
|
||||
createFaceMaterial(capeUv.right[0], capeUv.right[1], capeUv.right[2], capeUv.right[3], false, false, capeTexture),
|
||||
createFaceMaterial(capeUv.top[0], capeUv.top[1], capeUv.top[2], capeUv.top[3], false, true, capeTexture),
|
||||
createFaceMaterial(capeUv.bottom[0], capeUv.bottom[1], capeUv.bottom[2], capeUv.bottom[3], false, true, capeTexture),
|
||||
createFaceMaterial(capeUv.back[0], capeUv.back[1], capeUv.back[2], capeUv.back[3], false, false, capeTexture),
|
||||
createFaceMaterial(capeUv.front[0], capeUv.front[1], capeUv.front[2], capeUv.front[3], false, false, capeTexture)
|
||||
];
|
||||
|
||||
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);
|
||||
capeRef.current = capeGroup;
|
||||
});
|
||||
}
|
||||
|
||||
playerGroup.rotation.y = -0.3;
|
||||
requestRenderRef.current?.();
|
||||
@@ -146,7 +161,6 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
isDragging = true;
|
||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||
@@ -154,23 +168,21 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
const onMouseUp = () => { isDragging = false; };
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
playerGroup.rotation.y += (e.clientX - previousMousePosition.x) * 0.01;
|
||||
const delta = (e.clientX - previousMousePosition.x) * 0.01;
|
||||
playerGroup.rotation.y += delta;
|
||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||
requestRenderRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
requestRenderRef.current = () => renderer.render(scene, camera);
|
||||
requestRenderRef.current();
|
||||
renderer.domElement.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
requestRenderRef.current = () => renderer.render(scene, camera);
|
||||
requestRenderRef.current();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
if (object.geometry) object.geometry.dispose();
|
||||
@@ -189,9 +201,10 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
});
|
||||
renderer.dispose();
|
||||
overlaysRef.current = [];
|
||||
capeRef.current = null;
|
||||
requestRenderRef.current = null;
|
||||
};
|
||||
}, [skinUrl]);
|
||||
}, [skinUrl, capeUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
overlaysRef.current.forEach(overlay => {
|
||||
@@ -207,20 +220,20 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.activeElement?.tagName === 'INPUT' && e.key !== 'ArrowDown' && e.key !== 'ArrowRight') return;
|
||||
if (document.activeElement?.tagName === 'INPUT') return;
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
if (legacyMode) onNavigateRight();
|
||||
else if (focusIndex === 3) onNavigateRight();
|
||||
else if (focusIndex === 1 || focusIndex === 2) setFocusIndex(prev => prev + 1);
|
||||
else if (focusIndex === 4) onNavigateRight();
|
||||
else if (focusIndex >= 1 && focusIndex < 4) setFocusIndex(prev => prev + 1);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
if (legacyMode) return;
|
||||
if (focusIndex === 2 || focusIndex === 3) setFocusIndex(prev => prev - 1);
|
||||
if (focusIndex > 1 && focusIndex <= 4) setFocusIndex(prev => prev - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (legacyMode) {
|
||||
return;
|
||||
setFocusIndex(prev => (prev < 4 ? prev + 1 : prev));
|
||||
} else {
|
||||
setFocusIndex(prev => (prev < 3 ? prev + 1 : prev));
|
||||
setFocusIndex(prev => (prev < 4 ? prev + 1 : prev));
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (legacyMode) {
|
||||
@@ -240,6 +253,12 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
} else if (focusIndex === 3) {
|
||||
playPressSound();
|
||||
setSkinUrl('/images/Default.png');
|
||||
} else if (focusIndex === 4) {
|
||||
playPressSound();
|
||||
setActiveView('screenshots');
|
||||
} else if (focusIndex === 5) {
|
||||
playPressSound();
|
||||
setActiveView('lcelive');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -265,18 +284,18 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
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`}
|
||||
>
|
||||
{!legacyMode && (
|
||||
<div className={`bg-black/20 flex justify-center items-center ${legacyMode ? 'mb-0' : 'mb-2'} px-2 py-1 rounded-sm border-2 transition-colors ${isFocusedSection && focusIndex === 0 ? 'border-[#FFFF55]' : 'border-transparent'}`} data-focus="0" tabIndex={0}>
|
||||
<div className={`relative z-20 bg-black/20 flex justify-center items-center ${legacyMode ? 'mb-0' : 'mb-2'} px-2 py-1 rounded-sm border-2 transition-colors ${isFocusedSection && focusIndex === 0 ? 'border-[#FFFF55]' : 'border-transparent'}`} 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.key === 'ArrowDown') {
|
||||
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-default"
|
||||
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>
|
||||
)}
|
||||
@@ -320,6 +339,26 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
<img src="/images/Trash_Bin_Icon.png" alt="Delete" className="w-8 h-8 object-contain brightness-200" style={{ imageRendering: 'pixelated' }} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-focus="4" tabIndex={0}
|
||||
onMouseEnter={() => isFocusedSection && setFocusIndex(4)}
|
||||
onClick={() => { playPressSound(); setActiveView('screenshots'); }}
|
||||
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 4 ? 'scale-110' : ''}`}
|
||||
style={isFocusedSection && focusIndex === 4 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
|
||||
title="Screenshots"
|
||||
>
|
||||
<img src="/images/Screenshots_Icon.png" alt="Screenshots" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||
</button>
|
||||
<button
|
||||
data-focus="5" tabIndex={0}
|
||||
onMouseEnter={() => isFocusedSection && setFocusIndex(5)}
|
||||
onClick={() => { playPressSound(); setActiveView('lcelive'); }}
|
||||
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 5 ? 'scale-110' : ''}`}
|
||||
style={isFocusedSection && focusIndex === 5 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
|
||||
title="LCELive"
|
||||
>
|
||||
<img src="/images/friends.png" alt="LCELive" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import { lceLiveService, GameInvite } from "../../services/LceLiveService";
|
||||
|
||||
export default function ChooseInstanceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
editions,
|
||||
installs,
|
||||
invite,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
playPressSound: (s?: string) => void;
|
||||
playBackSound: (s?: string) => void;
|
||||
editions: any[];
|
||||
installs: string[];
|
||||
invite: GameInvite | null;
|
||||
}) {
|
||||
const [selectedInstance, setSelectedInstance] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [connectionMethod, setConnectionMethod] = useState<"direct" | "relay">("direct");
|
||||
|
||||
const validInstances = editions.filter((e: any) =>
|
||||
installs.includes(e.instanceId)
|
||||
);
|
||||
|
||||
const hasRelay = !!(invite?.signalingSessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedInstance("");
|
||||
setStatus("");
|
||||
setError("");
|
||||
setIsJoining(false);
|
||||
setFocusIndex(0);
|
||||
setConnectionMethod("direct");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && validInstances.length > 0 && !selectedInstance) {
|
||||
setSelectedInstance(validInstances[0].instanceId);
|
||||
}
|
||||
}, [isOpen, validInstances, selectedInstance]);
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!invite || !selectedInstance) return;
|
||||
playPressSound();
|
||||
setIsJoining(true);
|
||||
setError("");
|
||||
setStatus("Accepting invite...");
|
||||
try {
|
||||
const inviteData = await lceLiveService.acceptGameInvite(invite.inviteId);
|
||||
const hostIp = inviteData.hostIp || (typeof invite.from !== 'string' && (invite as any).from?.hostIp);
|
||||
const hostPort = inviteData.hostPort || invite.hostPort;
|
||||
const sessionId = inviteData.signalingSessionId || invite.signalingSessionId || "";
|
||||
|
||||
if (connectionMethod === "relay") {
|
||||
if (!sessionId) {
|
||||
throw new Error("Relay connection is not available for this invite.");
|
||||
}
|
||||
setStatus("Connecting via relay...");
|
||||
const baseUrl = lceLiveService.apiBaseUrl;
|
||||
const accessToken = lceLiveService.accessToken ?? "";
|
||||
const port = await TauriService.startRelayProxy(baseUrl, accessToken, sessionId);
|
||||
setStatus("Launching game...");
|
||||
await TauriService.launchGame(selectedInstance, [
|
||||
{ name: invite.hostName || "LCELive Game", ip: "127.0.0.1", port }
|
||||
]);
|
||||
} else {
|
||||
setStatus("Launching game...");
|
||||
await TauriService.stopProxy();
|
||||
await TauriService.launchGame(selectedInstance, [
|
||||
{ name: invite.hostName || "LCELive Game", ip: hostIp, port: hostPort }
|
||||
]);
|
||||
}
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
setStatus("");
|
||||
setIsJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
playBackSound();
|
||||
if (isJoining) return;
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
|
||||
setFocusIndex((prev) => (prev + 1) % max);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
|
||||
setFocusIndex((prev) => (prev - 1 + max) % max);
|
||||
} else if (e.key === "Enter") {
|
||||
if (focusIndex === 0 && validInstances.length > 0) {
|
||||
const currentIdx = validInstances.findIndex((i: any) => i.instanceId === selectedInstance);
|
||||
const next = (currentIdx + 1) % validInstances.length;
|
||||
setSelectedInstance(validInstances[next].instanceId);
|
||||
playPressSound();
|
||||
} else if (focusIndex === 1 && !isJoining) {
|
||||
handleJoin();
|
||||
} else if (focusIndex === (validInstances.length > 0 ? 2 : 1) && !isJoining) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, focusIndex, selectedInstance, validInstances, isJoining]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostName = invite ? (typeof invite.from === 'string' ? invite.from : invite.from.displayName) : "";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[450px] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{!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
|
||||
</h2>
|
||||
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
|
||||
Joining {hostName}'s game. Choose an instance:
|
||||
</p>
|
||||
|
||||
{validInstances.length > 0 ? (
|
||||
<div className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
|
||||
style={{ scrollbarWidth: "thin", scrollbarColor: "#373737 transparent" }}>
|
||||
{validInstances.map((inst: any) => {
|
||||
const isSelected = selectedInstance === inst.instanceId;
|
||||
return (
|
||||
<div
|
||||
key={inst.instanceId}
|
||||
onClick={() => { playPressSound(); setSelectedInstance(inst.instanceId); }}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
className={`w-full px-4 py-3 cursor-pointer flex items-center gap-3 transition-all outline-none border-none ${isSelected ? "bg-white/15 border-l-4 border-[#FFFF55]" : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent"}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-[#FFFF55]" />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-lg font-bold mc-text-shadow">{inst.name}</span>
|
||||
{inst.selectedBranch && (
|
||||
<span className="text-gray-400 text-xs">{inst.selectedBranch}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-red-400 text-sm mc-text-shadow mb-4 text-center">
|
||||
No installed instances available. Install a version first.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="w-full mb-3 flex flex-col items-center gap-1">
|
||||
<span className="text-gray-400 text-xs mc-text-shadow uppercase tracking-wider">
|
||||
Connection Method
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setConnectionMethod("direct")}
|
||||
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
|
||||
connectionMethod === "direct"
|
||||
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
|
||||
: "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
Direct
|
||||
</button>
|
||||
<button
|
||||
onClick={() => hasRelay && setConnectionMethod("relay")}
|
||||
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
|
||||
connectionMethod === "relay"
|
||||
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
|
||||
: hasRelay
|
||||
? "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
|
||||
: "text-gray-600 bg-black/10 border-l-2 border-transparent cursor-not-allowed"
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
title={!hasRelay ? "Relay not available for this invite" : "Connect via LCELive relay server"}
|
||||
>
|
||||
Relay
|
||||
</button>
|
||||
</div>
|
||||
{!hasRelay && (
|
||||
<span className="text-gray-600 text-[10px] mc-text-shadow">Relay not available for this invite</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 w-full justify-center">
|
||||
<button
|
||||
onMouseEnter={() => {
|
||||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
setFocusIndex(cancelIdx);
|
||||
}}
|
||||
onClick={() => { playBackSound(); onClose(); }}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${(() => {
|
||||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
return focusIndex === cancelIdx ? "text-[#FFFF55]" : "text-white";
|
||||
})()}`}
|
||||
style={{
|
||||
backgroundImage: (() => {
|
||||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
return focusIndex === cancelIdx
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')";
|
||||
})(),
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{validInstances.length > 0 && (
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
onClick={handleJoin}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
</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" />
|
||||
<p className="text-white text-lg mc-text-shadow text-center">{status}</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -18,9 +18,8 @@ const ModalButton = memo(function ModalButton({
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none bg-transparent ${
|
||||
isDanger ? "text-red-500" : "text-white"
|
||||
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none bg-transparent ${isDanger ? "text-red-500" : "text-white"
|
||||
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
|
||||
style={{
|
||||
backgroundImage: isHovered
|
||||
? "url('/images/button_highlighted.png')"
|
||||
@@ -41,10 +40,12 @@ export default function CustomTUModal({
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
editingEdition = null,
|
||||
initialPath = "",
|
||||
}: any) {
|
||||
const [name, setName] = useState("");
|
||||
const [desc, setDesc] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
@@ -53,13 +54,17 @@ export default function CustomTUModal({
|
||||
setName(editingEdition.name);
|
||||
setDesc(editingEdition.desc);
|
||||
setUrl(editingEdition.url);
|
||||
setPath(editingEdition.path || "");
|
||||
} else if (isOpen && initialPath) {
|
||||
setPath(initialPath);
|
||||
} else if (!isOpen) {
|
||||
setName("");
|
||||
setDesc("");
|
||||
setUrl("");
|
||||
setPath("");
|
||||
setError("");
|
||||
}
|
||||
}, [editingEdition, isOpen]);
|
||||
}, [editingEdition, isOpen, initialPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -88,25 +93,30 @@ export default function CustomTUModal({
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, focusIndex, name, desc, url]);
|
||||
}, [isOpen, focusIndex, name, desc, url, path]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleImport = () => {
|
||||
if (!name || !url) {
|
||||
setError("Name and URL are required");
|
||||
if (!name) {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("http")) {
|
||||
if (!url && !path) {
|
||||
setError("URL or Path is required");
|
||||
return;
|
||||
}
|
||||
if (url && !url.startsWith("http")) {
|
||||
setError("Invalid URL");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onImport({ name, desc: desc || "Custom imported TU", url });
|
||||
onImport({ name, desc: desc || "Custom imported TU", url, path: path || undefined });
|
||||
onClose();
|
||||
setName("");
|
||||
setDesc("");
|
||||
setUrl("");
|
||||
setPath("");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -114,19 +124,19 @@ export default function CustomTUModal({
|
||||
<div
|
||||
className="relative w-[400px] p-6 flex flex-col items-center"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Download_Background.png')",
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl text-white mc-text-shadow mb-4 text-center">
|
||||
<h2 className="text-xl text-black mc-text-shadow mb-4 text-center">
|
||||
{editingEdition ? "Edit Custom TU" : "Import Custom TU"}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-white text-sm mc-text-shadow uppercase tracking-widest">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
TU Name
|
||||
</label>
|
||||
<input
|
||||
@@ -142,7 +152,7 @@ export default function CustomTUModal({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-white text-sm mc-text-shadow uppercase tracking-widest">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
Description (Optional)
|
||||
</label>
|
||||
<input
|
||||
@@ -157,7 +167,7 @@ export default function CustomTUModal({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-white text-sm mc-text-shadow uppercase tracking-widest">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
Download URL (.zip)
|
||||
</label>
|
||||
<input
|
||||
@@ -165,12 +175,27 @@ export default function CustomTUModal({
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onFocus={() => setFocusIndex(2)}
|
||||
placeholder="https://example.com/mod.zip"
|
||||
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']"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{path && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
|
||||
Local Path
|
||||
</label>
|
||||
<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"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest">
|
||||
{error}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
export default function SetUidModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
instances,
|
||||
installedVersions,
|
||||
targetInstanceId,
|
||||
}: any) {
|
||||
const [mode, setMode] = useState<"manual" | "copy">("manual");
|
||||
const [uid, setUid] = useState("0xFF02F0C87E8AC1F2");
|
||||
const [selectedInstance, setSelectedInstance] = useState("");
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMode("manual");
|
||||
setUid("0xFF02F0C87E8AC1F2");
|
||||
setSelectedInstance("");
|
||||
setError("");
|
||||
setFocusIndex(0);
|
||||
setIsDropdownOpen(false);
|
||||
} else if (targetInstanceId) {
|
||||
(async () => {
|
||||
try {
|
||||
const targetPath = await TauriService.getInstancePath(targetInstanceId);
|
||||
const data = await TauriService.readBinaryFile(`${targetPath}/uid.dat`);
|
||||
const currentUid = new TextDecoder().decode(data);
|
||||
if (currentUid.trim()) {
|
||||
setUid(currentUid);
|
||||
} else {
|
||||
setUid("0xFF02F0C87E8AC1F2");
|
||||
}
|
||||
} catch (e) {
|
||||
setUid("0xFF02F0C87E8AC1F2");
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isOpen, targetInstanceId]);
|
||||
|
||||
const validInstances = instances.filter((i: any) => installedVersions.includes(i.instanceId) && i.instanceId !== targetInstanceId);
|
||||
const handleSave = async () => {
|
||||
playPressSound("save_click.wav");
|
||||
try {
|
||||
let finalUid = uid;
|
||||
if (mode === "copy") {
|
||||
if (!selectedInstance) {
|
||||
setError("Select an instance to copy from.");
|
||||
return;
|
||||
}
|
||||
const sourcePath = await TauriService.getInstancePath(selectedInstance);
|
||||
try {
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalUid) {
|
||||
setError("UID cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedUid = new TextEncoder().encode(finalUid);
|
||||
const targetPath = await TauriService.getInstancePath(targetInstanceId);
|
||||
await TauriService.writeBinaryFile(`${targetPath}/uid.dat`, encodedUid);
|
||||
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev + 1) % 5);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev - 1 + 5) % 5);
|
||||
} else if (e.key === "Enter") {
|
||||
if (focusIndex === 0) {
|
||||
playPressSound();
|
||||
setMode("manual");
|
||||
} else if (focusIndex === 1) {
|
||||
playPressSound();
|
||||
setMode("copy");
|
||||
} else if (focusIndex === 2 && mode === "copy") {
|
||||
playPressSound();
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
} else if (focusIndex === 3) {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
} else if (focusIndex === 4) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, focusIndex, mode, uid, selectedInstance, isDropdownOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-4 mb-5 w-full justify-center">
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
onClick={() => { playPressSound(); setMode("manual"); }}
|
||||
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 0 ? "text-[#FFFF55]" : mode === "manual" ? "text-white" : "text-gray-400"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 0 || mode === "manual"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
onClick={() => { playPressSound(); setMode("copy"); }}
|
||||
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 1 ? "text-[#FFFF55]" : mode === "copy" ? "text-white" : "text-gray-400"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 1 || mode === "copy"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full min-h-[80px]">
|
||||
{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
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={uid}
|
||||
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`}
|
||||
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
|
||||
/>
|
||||
</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
|
||||
</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`}
|
||||
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedInstance
|
||||
? (() => {
|
||||
const i = validInstances.find((inst: any) => inst.instanceId === selectedInstance);
|
||||
return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : "-- Select an Instance --";
|
||||
})()
|
||||
: "-- Select an Instance --"}
|
||||
</span>
|
||||
<span className="text-xs">▼</span>
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && validInstances.length > 0 && (
|
||||
<div className="absolute top-[60px] left-0 w-full max-h-40 overflow-y-auto bg-black/90 border-2 border-[#373737] z-50 flex flex-col custom-scrollbar shadow-xl" style={{ imageRendering: "pixelated" }}>
|
||||
{validInstances.map((i: any) => (
|
||||
<div
|
||||
key={i.instanceId}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setSelectedInstance(i.instanceId);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="px-3 py-2 text-white text-sm cursor-pointer hover:bg-white/20 transition-colors truncate font-['Mojangles']"
|
||||
>
|
||||
{i.name} {i.selectedBranch ? `(${i.selectedBranch})` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validInstances.length === 0 && (
|
||||
<p className="text-red-400 text-xs text-center mt-1">No other installed instances available.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-6 w-full justify-center">
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(3)}
|
||||
onClick={() => {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
}}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 3 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 3
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(4)}
|
||||
onClick={handleSave}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 4 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 4
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,11 @@ export default function TeamModal({
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
const team = [
|
||||
{ name: "neoapps", url: "https://github.com/neoapps-dev" },
|
||||
{ name: "KayJann", url: "https://github.com/KayJannOnGit" },
|
||||
{ name: "Santiago Fisela", url: "https://github.com/PinkLittleKitty" },
|
||||
{ name: "Leon", url: "https://github.com/hornyalcoholic" },
|
||||
{ name: "Criador_Mods", url: "https://github.com/CriadorMods" },
|
||||
{ name: "journ3ym3m", url: "https://github.com/journ3ym3n" },
|
||||
{ name: "KayJann", url: "https://github.com/KayJannOnGit" },
|
||||
{ name: "neoapps", url: "https://github.com/neoapps-dev" },
|
||||
{ name: "Santiago Fisela", url: "https://github.com/PinkLittleKitty" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,11 +3,14 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ArcService } from "../../services/ArcService";
|
||||
import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
export const ArcEditorView: React.FC = () => {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [arc, setArc] = useState<ArcFile | null>(null);
|
||||
const [openedPath, setOpenedPath] = useState<string | null>(null);
|
||||
const [loc, setLoc] = useState<LocFile | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"arc" | "loc">("arc");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -18,9 +21,9 @@ export const ArcEditorView: React.FC = () => {
|
||||
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [isLocEditModalOpen, setIsLocEditModalOpen] = useState<{ langIdx: number, strIdx: number, isNew: boolean } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const injectInputRef = useRef<HTMLInputElement>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!arc) return [];
|
||||
return arc.entries.map((e, i) => ({ ...e, originalIdx: i }))
|
||||
@@ -43,15 +46,17 @@ export const ArcEditorView: React.FC = () => {
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const handleFileLoad = async () => {
|
||||
try {
|
||||
const parsed = await ArcService.readARC(buffer);
|
||||
parsed.name = file.name;
|
||||
const path = await TauriService.pickFile("Open ARC", ["arc"]);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
const bytes = await TauriService.readBinaryFile(path);
|
||||
const parsed = await ArcService.readARC(bytes.buffer as ArrayBuffer);
|
||||
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 {
|
||||
@@ -65,37 +70,48 @@ export const ArcEditorView: React.FC = () => {
|
||||
setLoc(null);
|
||||
}
|
||||
setSelectedEntryIdx(null);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse ARC", err);
|
||||
showNotification("Failed to parse ARC", "error");
|
||||
showNotification(`Loaded ${parsed.name}`);
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") {
|
||||
console.error("Failed to parse ARC", err);
|
||||
showNotification("Failed to parse ARC", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveArc = () => {
|
||||
const handleSaveArc = async () => {
|
||||
if (!arc) return;
|
||||
playPressSound();
|
||||
const buffer = ArcService.serializeARC(arc);
|
||||
const blob = new Blob([buffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = arc.name || "archive.arc";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("ARC Saved Successfully");
|
||||
const data = new Uint8Array(buffer);
|
||||
|
||||
try {
|
||||
let targetPath = openedPath;
|
||||
if (!targetPath) {
|
||||
targetPath = await TauriService.saveFileDialog("Save ARC", arc.name || "archive.arc", ["arc"]);
|
||||
}
|
||||
|
||||
if (targetPath) {
|
||||
await TauriService.writeBinaryFile(targetPath, data);
|
||||
setOpenedPath(targetPath);
|
||||
showNotification("ARC Saved Successfully");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Save failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtractEntry = (entry: ArcEntry) => {
|
||||
playPressSound();
|
||||
const blob = new Blob([entry.data as any]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = entry.filename.split("/").pop() || "asset";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification(`Extracted: ${entry.filename}`);
|
||||
const handleExtractEntry = async (entry: ArcEntry) => {
|
||||
try {
|
||||
const fileName = entry.filename.split("/").pop() || "asset";
|
||||
const path = await TauriService.saveFileDialog("Export Asset", fileName, []);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
await TauriService.writeBinaryFile(path, entry.data);
|
||||
showNotification(`Extracted: ${entry.filename}`);
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Extraction failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = (idx: number) => {
|
||||
@@ -195,34 +211,140 @@ export const ArcEditorView: React.FC = () => {
|
||||
showNotification("String Deleted");
|
||||
};
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
const root: any = { name: "<root>", children: {}, isFolder: true };
|
||||
filteredEntries.forEach((entry) => {
|
||||
const parts = entry.filename.split(/\//);
|
||||
let current = root;
|
||||
parts.forEach((part, i) => {
|
||||
const isLast = i === parts.length - 1;
|
||||
if (!current.children[part]) {
|
||||
current.children[part] = isLast
|
||||
? { ...entry, isFolder: false }
|
||||
: { name: part, children: {}, isFolder: true };
|
||||
}
|
||||
current = current.children[part];
|
||||
});
|
||||
});
|
||||
return root;
|
||||
}, [filteredEntries]);
|
||||
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set(["<root>"]));
|
||||
const toggleNode = (path: string) => {
|
||||
const newExpanded = new Set(expandedNodes);
|
||||
if (newExpanded.has(path)) newExpanded.delete(path);
|
||||
else newExpanded.add(path);
|
||||
setExpandedNodes(newExpanded);
|
||||
};
|
||||
|
||||
const renderTree = (node: any, path: string = "") => {
|
||||
const nodePath = path ? `${path}/${node.name}` : node.name;
|
||||
const isExpanded = expandedNodes.has(nodePath);
|
||||
|
||||
if (!node.isFolder) {
|
||||
const isSelected = selectedEntryIdx === node.originalIdx;
|
||||
return (
|
||||
<div
|
||||
key={node.originalIdx}
|
||||
onClick={() => { playPressSound(); setSelectedEntryIdx(node.originalIdx); }}
|
||||
className={`group flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors ${isSelected ? "bg-[#FFFF55]/20 text-[#FFFF55]" : "hover:bg-white/5 text-white/80"}`}
|
||||
>
|
||||
<img src="/images/Download_Icon.png" className="w-3 h-3 opacity-40" style={{ imageRendering: "pixelated" }} />
|
||||
<span className="truncate text-sm font-medium tracking-tight">
|
||||
{node.filename.split("/").pop()}
|
||||
</span>
|
||||
{node.isCompressed && (
|
||||
<span className="ml-auto text-[8px] opacity-40 border border-white/20 px-1">ZLIB</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={nodePath} className="flex flex-col">
|
||||
<div
|
||||
onClick={() => { playPressSound(); toggleNode(nodePath); }}
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-white/5 text-white/50 transition-colors group"
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: isExpanded ? 90 : 0 }}
|
||||
className="text-[10px]"
|
||||
>
|
||||
▶
|
||||
</motion.span>
|
||||
<img src="/images/tools/arc.png" className="w-4 h-4 opacity-40 grayscale" style={{ imageRendering: "pixelated" }} />
|
||||
<span className="text-xs uppercase tracking-widest font-bold group-hover:text-white/80 transition-colors">
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="ml-4 border-l border-white/10 overflow-hidden"
|
||||
>
|
||||
{Object.values(node.children).map((child: any) => renderTree(child, nodePath))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportAll = async () => {
|
||||
if (!arc || arc.entries.length === 0) return;
|
||||
try {
|
||||
const baseFolder = await TauriService.pickFolder();
|
||||
if (!baseFolder) return;
|
||||
playPressSound();
|
||||
showNotification("Exporting all archive entries...");
|
||||
|
||||
for (const entry of arc.entries) {
|
||||
const fileName = entry.filename.replace(/\//g, "_");
|
||||
await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, entry.data);
|
||||
}
|
||||
showNotification("All Entries Exported");
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedEntry = selectedEntryIdx !== null ? arc?.entries[selectedEntryIdx] : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
className="flex flex-col items-center w-full max-w-7xl h-[85vh] outline-none"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".arc" />
|
||||
<input type="file" ref={injectInputRef} onChange={handleAddEntry} className="hidden" />
|
||||
<input type="file" ref={replaceInputRef} onChange={handleReplaceEntry} 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">ARC Editor</h2>
|
||||
{arc && <span className="text-white/40 mc-text-shadow italic">editing: <span className="text-[#FFFF55]">{arc.name}</span></span>}
|
||||
</div>
|
||||
<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
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
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%" }}
|
||||
>
|
||||
Open ARC
|
||||
</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%" }}
|
||||
>
|
||||
Export All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveArc}
|
||||
disabled={!arc}
|
||||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!arc ? "opacity-50 grayscale" : ""}`}
|
||||
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%" }}
|
||||
>
|
||||
Save ARC
|
||||
@@ -230,6 +352,9 @@ export const ArcEditorView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" ref={injectInputRef} onChange={handleAddEntry} className="hidden" />
|
||||
<input type="file" ref={replaceInputRef} onChange={handleReplaceEntry} className="hidden" />
|
||||
|
||||
{!arc ? (
|
||||
<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" }}>
|
||||
@@ -244,74 +369,115 @@ export const ArcEditorView: React.FC = () => {
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "arc" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<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">Archive</span>
|
||||
<span className="text-lg uppercase tracking-wider font-bold">Archive</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("loc"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "loc" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<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">Languages (LOC)</span>
|
||||
<span className="text-lg uppercase tracking-wider font-bold">Languages (LOC)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === "arc" ? (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search entries..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add Entry
|
||||
</button>
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search entries..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add Entry
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{renderTree(treeData)}
|
||||
</div>
|
||||
</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">Filename</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Offset</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">Flags</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.map((entry) => (
|
||||
<tr key={entry.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
|
||||
<td className="p-3 text-white truncate max-w-md font-medium">{entry.filename}</td>
|
||||
<td className="p-3 text-white/60 text-right font-mono text-xs">0x{entry.ptr.toString(16).toUpperCase().padStart(8, '0')}</td>
|
||||
<td className="p-3 text-white/60 text-right text-xs">{(entry.size / 1024).toFixed(1)} KB</td>
|
||||
<td className="p-3">
|
||||
{entry.isCompressed && (
|
||||
<span className="bg-[#FFFF55]/10 text-[#FFFF55] border border-[#FFFF55]/20 px-2 py-0.5 text-[10px] uppercase font-bold">zlib</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-right flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleExtractEntry(entry)} className="p-1 hover:text-[#FFFF55] transition-colors"><img src="/images/Download_Icon.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} /></button>
|
||||
<button onClick={() => { setSelectedEntryIdx(entry.originalIdx); setIsReplaceModalOpen(true); }} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 transition-all uppercase">Replace</button>
|
||||
<button onClick={() => { setSelectedEntryIdx(entry.originalIdx); setIsRenameModalOpen(true); }} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 transition-all uppercase">Rename</button>
|
||||
<button onClick={() => handleDeleteEntry(entry.originalIdx)} className="p-1 hover:text-red-500 transition-colors opacity-60 hover:opacity-100">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="w-1/3 flex flex-col p-6 overflow-y-auto custom-scrollbar">
|
||||
{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>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<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%" }}
|
||||
>
|
||||
Export File
|
||||
</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%" }}
|
||||
>
|
||||
Replace Data
|
||||
</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%" }}
|
||||
>
|
||||
Rename / Path
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!arc) return;
|
||||
const newEntries = [...arc.entries];
|
||||
newEntries[selectedEntryIdx!] = { ...selectedEntry, isCompressed: !selectedEntry.isCompressed };
|
||||
setArc({ ...arc, entries: newEntries });
|
||||
showNotification(`Compression ${!selectedEntry.isCompressed ? "Enabled" : "Disabled"}`);
|
||||
}}
|
||||
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"}
|
||||
</button>
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => handleDeleteEntry(selectedEntryIdx!)}
|
||||
className="w-full py-2 text-red-500/80 mc-text-shadow text-sm transition-all hover:text-red-500 hover:scale-[1.02]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Delete Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<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>
|
||||
@@ -401,7 +567,7 @@ export const ArcEditorView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<div className="flex justify-center mt-6 h-14 w-full">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useState, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ColService } from "../../services/ColService";
|
||||
import { ColFile } from "../../types/col";
|
||||
function argbToHex(argb: number) {
|
||||
return (argb >>> 0).toString(16).padStart(8, '0').toUpperCase();
|
||||
}
|
||||
|
||||
function hexToArgb(hex: string) {
|
||||
const cleanHex = hex.replace("#", "");
|
||||
return parseInt(cleanHex, 16) >>> 0;
|
||||
}
|
||||
|
||||
export default function ColEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [col, setCol] = useState<ColFile | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<"colors" | "worldColors">("colors");
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const currentColors = useMemo(() => {
|
||||
if (!col) return [];
|
||||
return col.colors.map((c, i) => ({ ...c, originalIdx: i }))
|
||||
.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}, [col, searchTerm]);
|
||||
|
||||
const currentWorldColors = useMemo(() => {
|
||||
if (!col) return [];
|
||||
return col.worldColors.map((c, i) => ({ ...c, originalIdx: i }))
|
||||
.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}, [col, searchTerm]);
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
const parsedCol = ColService.readCOL(buffer);
|
||||
setCol(parsedCol);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to parse COL", err);
|
||||
showNotification(err.message || "Failed to parse COL", "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleSaveCol = () => {
|
||||
if (!col) return;
|
||||
playPressSound();
|
||||
try {
|
||||
const buffer = ColService.serializeCOL(col);
|
||||
const blob = new Blob([buffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "colours.col";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("COL Saved Successfully");
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save COL", err);
|
||||
showNotification(err.message || "Failed to save COL", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateColor = (idx: number, field: string, val: string | number) => {
|
||||
if (!col) return;
|
||||
const newCol = { ...col, colors: [...col.colors] };
|
||||
newCol.colors[idx] = { ...newCol.colors[idx], [field]: val };
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
const handleUpdateWorldColor = (idx: number, field: string, val: string | number) => {
|
||||
if (!col) return;
|
||||
const newCol = { ...col, worldColors: [...col.worldColors] };
|
||||
newCol.worldColors[idx] = { ...newCol.worldColors[idx], [field]: val };
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
const handleAddColor = () => {
|
||||
if (!col) return;
|
||||
playPressSound();
|
||||
setCol({
|
||||
...col,
|
||||
colors: [{ name: "NewColor", color: 0xFFFFFFFF }, ...col.colors]
|
||||
});
|
||||
showNotification("Color Added");
|
||||
};
|
||||
|
||||
const handleAddWorldColor = () => {
|
||||
if (!col) return;
|
||||
playPressSound();
|
||||
setCol({
|
||||
...col,
|
||||
worldColors: [{ name: "NewWorldColor", waterColor: 0xFFFFFFFF, underwaterColor: 0xFFFFFFFF, fogColor: 0xFFFFFFFF }, ...col.worldColors]
|
||||
});
|
||||
showNotification("World Color Added");
|
||||
};
|
||||
|
||||
const handleDeleteColor = (idx: number) => {
|
||||
if (!col) return;
|
||||
playBackSound();
|
||||
const newCol = { ...col, colors: [...col.colors] };
|
||||
newCol.colors.splice(idx, 1);
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
const handleDeleteWorldColor = (idx: number) => {
|
||||
if (!col) return;
|
||||
playBackSound();
|
||||
const newCol = { ...col, worldColors: [...col.worldColors] };
|
||||
newCol.worldColors.splice(idx, 1);
|
||||
setCol(newCol);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".col" />
|
||||
<div className="flex items-center justify-between mb-6 px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">COL Editor</h2>
|
||||
{col && <span className="text-white/40 mc-text-shadow italic">Version: <span className="text-[#FFFF55]">{col.version}</span></span>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open COL
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveCol}
|
||||
disabled={!col}
|
||||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!col ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save COL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!col ? (
|
||||
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open a COL file to begin editing</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="flex gap-1 p-2 pt-4 border-b-2 border-[#373737]">
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("colors"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "colors" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<span className="text-lg">Colors ({col.colors.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("worldColors"); }}
|
||||
disabled={col.version === 0}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "worldColors" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"} ${col.version === 0 ? "opacity-20 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span className="text-lg">World Colors ({col.worldColors.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search colors..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={activeTab === "colors" ? handleAddColor : handleAddWorldColor}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add {activeTab === "colors" ? "Color" : "World Color"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-[#252525] z-10 w-full mb-2">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold w-1/4">Name</th>
|
||||
{activeTab === "colors" && <th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Color (ARGB)</th>}
|
||||
{activeTab === "worldColors" && (
|
||||
<>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Water (ARGB)</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Underwater (ARGB)</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Fog (ARGB)</th>
|
||||
</>
|
||||
)}
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right w-16">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeTab === "colors" && currentColors.map((c) => (
|
||||
<tr key={c.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={c.name}
|
||||
onChange={(e) => handleUpdateColor(c.originalIdx, "name", e.target.value)}
|
||||
className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(c.color).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(c.color).substring(0, 2);
|
||||
const newArgb = hexToArgb(`${alpha}${rgb}`);
|
||||
handleUpdateColor(c.originalIdx, "color", newArgb);
|
||||
}}
|
||||
className="w-8 h-8 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(c.color)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateColor(c.originalIdx, "color", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-24 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-sm uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<button onClick={() => handleDeleteColor(c.originalIdx)} className="p-1 hover:text-red-500 opacity-60 hover:opacity-100 transition-colors">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{activeTab === "worldColors" && currentWorldColors.map((w) => (
|
||||
<tr key={w.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={w.name}
|
||||
onChange={(e) => handleUpdateWorldColor(w.originalIdx, "name", e.target.value)}
|
||||
className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(w.waterColor).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(w.waterColor).substring(0, 2);
|
||||
handleUpdateWorldColor(w.originalIdx, "waterColor", hexToArgb(`${alpha}${rgb}`));
|
||||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.waterColor)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "waterColor", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(w.underwaterColor).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(w.underwaterColor).substring(0, 2);
|
||||
handleUpdateWorldColor(w.originalIdx, "underwaterColor", hexToArgb(`${alpha}${rgb}`));
|
||||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.underwaterColor)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "underwaterColor", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={`#${argbToHex(w.fogColor).substring(2, 8)}`}
|
||||
onChange={(e) => {
|
||||
const rgb = e.target.value.substring(1).toUpperCase();
|
||||
const alpha = argbToHex(w.fogColor).substring(0, 2);
|
||||
handleUpdateWorldColor(w.originalIdx, "fogColor", hexToArgb(`${alpha}${rgb}`));
|
||||
}}
|
||||
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={argbToHex(w.fogColor)}
|
||||
onChange={(e) => {
|
||||
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (s.length <= 8) {
|
||||
const parsed = parseInt(s, 16);
|
||||
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "fogColor", parsed >>> 0);
|
||||
}
|
||||
}}
|
||||
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<button onClick={() => handleDeleteWorldColor(w.originalIdx)} className="p-1 hover:text-red-500 opacity-60 hover:opacity-100 transition-colors">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{notification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
className="fixed top-12 right-12 z-[100] p-6 flex flex-col items-center justify-center min-w-[240px]"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
|
||||
{notification.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,10 @@ interface DevTool {
|
||||
const DEV_TOOLS: DevTool[] = [
|
||||
{ id: "pck", name: "PCK Editor", view: "pck-editor", comingSoon: false },
|
||||
{ id: "arc", name: "ARC Editor", view: "arc-editor", comingSoon: false },
|
||||
{ id: "loc", name: "LOC Editor", view: "devtools", comingSoon: false }
|
||||
{ id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false },
|
||||
{ id: "grf", name: "GRF Editor", view: "grf-editor", comingSoon: false },
|
||||
{ id: "col", name: "COL Editor", view: "col-editor", comingSoon: false },
|
||||
{ id: "options", name: "Options Editor", view: "options-editor", comingSoon: false }
|
||||
];
|
||||
|
||||
export default function DevtoolsView() {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { GrfService } from "../../services/GrfService";
|
||||
import { GrfFile, GrfNode, GrfFileEntry } from "../../types/grf";
|
||||
export default function GrfEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [grf, setGrf] = useState<GrfFile | null>(null);
|
||||
const [filename, setFilename] = useState("game_rules.grf");
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const addFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<"rules" | "files">("rules");
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
const parsedGrf = GrfService.readGRF(buffer);
|
||||
setGrf(parsedGrf);
|
||||
setFilename(file.name);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to parse GRF", err);
|
||||
showNotification(err.message || "Failed to parse GRF", "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleNewGrf = () => {
|
||||
playPressSound();
|
||||
setGrf(GrfService.createDefaultGRF());
|
||||
setFilename("new_rules.grf");
|
||||
showNotification("New GRF Created");
|
||||
};
|
||||
|
||||
const handleAddFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !grf) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const newFile: GrfFileEntry = {
|
||||
filename: file.name,
|
||||
data: new Uint8Array(buffer)
|
||||
};
|
||||
setGrf({
|
||||
...grf,
|
||||
files: [...grf.files, newFile]
|
||||
});
|
||||
showNotification(`Added ${file.name}`);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDeleteFile = (index: number) => {
|
||||
if (!grf) return;
|
||||
playPressSound();
|
||||
const newFiles = [...grf.files];
|
||||
const removed = newFiles.splice(index, 1)[0];
|
||||
setGrf({ ...grf, files: newFiles });
|
||||
showNotification(`Removed ${removed.filename}`);
|
||||
};
|
||||
|
||||
const handleUpdateParameter = (nodePath: string[], paramIndex: number, value: string) => {
|
||||
if (!grf) return;
|
||||
const updateNode = (node: GrfNode, path: string[]): GrfNode => {
|
||||
if (path.length === 0) {
|
||||
if (!node.parameters[paramIndex]) return node;
|
||||
const newParams = [...node.parameters];
|
||||
newParams[paramIndex] = { ...newParams[paramIndex], value };
|
||||
return { ...node, parameters: newParams };
|
||||
}
|
||||
const [next, ...rest] = path;
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map(child => child.name === next ? updateNode(child, rest) : child)
|
||||
};
|
||||
};
|
||||
setGrf({
|
||||
...grf,
|
||||
root: updateNode(grf.root, nodePath)
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveGrf = () => {
|
||||
if (!grf) return;
|
||||
playPressSound();
|
||||
try {
|
||||
const buffer = GrfService.serializeGRF(grf);
|
||||
const blob = new Blob([buffer as any]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("GRF Saved Successfully");
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save GRF", err);
|
||||
showNotification(err.message || "Failed to save GRF", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".grf" />
|
||||
<input type="file" ref={addFileInputRef} onChange={handleAddFile} className="hidden" />
|
||||
<div className="flex items-center justify-between mb-6 px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">GRF Editor</h2>
|
||||
{grf && <span className="text-white/40 mc-text-shadow italic">editing: <span className="text-[#FFFF55]">{filename}</span></span>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleNewGrf}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
New GRF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open GRF
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveGrf}
|
||||
disabled={!grf}
|
||||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!grf ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save GRF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!grf ? (
|
||||
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open a GRF file to begin editing</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="flex gap-1 p-2 pt-4 border-b-2 border-[#373737]">
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("rules"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "rules" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<span className="text-lg">Game Rules</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("files"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "files" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<span className="text-lg">Files ({grf.files.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 custom-scrollbar">
|
||||
{activeTab === "rules" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{grf.root.children.map((node, i) => (
|
||||
<GrfNodeView key={i} node={node} level={0} path={[]} onUpdate={handleUpdateParameter} />
|
||||
))}
|
||||
{grf.root.children.length === 0 && <span className="text-white/40 italic">No rules found</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
onClick={() => addFileInputRef.current?.click()}
|
||||
className="self-start px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add File
|
||||
</button>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#252525]">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Filename</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Size</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grf.files.length === 0 && (
|
||||
<tr><td colSpan={3} className="p-4 text-center text-white/40">No files in GRF</td></tr>
|
||||
)}
|
||||
{grf.files.map((f, i) => (
|
||||
<tr key={i} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors">
|
||||
<td className="p-3 text-white font-medium">{f.filename}</td>
|
||||
<td className="p-3 text-white/60 text-right text-xs">{(f.data.length / 1024).toFixed(2)} KB</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDeleteFile(i)}
|
||||
className="text-[#FF5555] hover:text-[#FF8888] transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{notification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
className="fixed top-12 right-12 z-[100] p-6 flex flex-col items-center justify-center min-w-[240px]"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
|
||||
{notification.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrfNodeView({ node, level, path, onUpdate }: { node: GrfNode, level: number, path: string[], onUpdate: (path: string[], paramIdx: number, val: string) => void }) {
|
||||
const [expanded, setExpanded] = useState(level < 1);
|
||||
const currentPath = [...path, node.name];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-1 select-none">
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 hover:bg-white/10 cursor-pointer transition-colors"
|
||||
style={{ paddingLeft: `${level * 24 + 8}px` }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{node.children.length > 0 ? (
|
||||
<img
|
||||
src={expanded ? "/images/Settings_Arrow_Down.png" : "/images/Settings_Arrow_Right.png"}
|
||||
className="w-3 h-3 object-contain opacity-80"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-3" />
|
||||
)}
|
||||
<img
|
||||
src={node.children.length > 0 ? "/images/Folder_Icon.png" : "/images/tools/grf.png"}
|
||||
className="w-4 h-4 object-contain grayscale opacity-60"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => (e.currentTarget.src = "/images/tools/pck.png")}
|
||||
/>
|
||||
<span className="text-[#FFFF55] font-bold">{node.name}</span>
|
||||
{node.parameters.length > 0 && <span className="text-white/40 text-xs ml-2">[{node.parameters.length} props]</span>}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="flex flex-col border-l-2 border-[#373737] ml-2 pl-2">
|
||||
{node.parameters.length > 0 && (
|
||||
<div className="flex flex-col bg-black/20 p-2 mb-2 ml-4">
|
||||
{node.parameters.map((p, i) => (
|
||||
<div key={i} className="flex gap-4 border-b border-[#373737]/30 py-2 text-sm items-center">
|
||||
<span className="text-[#AAAAAA] w-1/3 truncate">{p.name}</span>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{node.children.map((child, i) => (
|
||||
<GrfNodeView key={i} node={child} level={level + 1} path={currentPath} onUpdate={onUpdate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const HomeView = memo(function HomeView() {
|
||||
downloadingId,
|
||||
isGameRunning,
|
||||
stopGame,
|
||||
updatesAvailable,
|
||||
} = useGame();
|
||||
|
||||
const isFocusedSection = focusSection === "menu";
|
||||
@@ -54,10 +55,30 @@ const HomeView = memo(function HomeView() {
|
||||
isDanger: isGameRunning,
|
||||
disabled: isDownloading,
|
||||
},
|
||||
{ label: "Help & Options", action: () => setActiveView("settings"), disabled: false },
|
||||
{ label: "Versions", action: () => setActiveView("versions"), disabled: false },
|
||||
{ label: "Workshop", action: () => setActiveView("workshop"), disabled: false },
|
||||
{ label: "Developer Tools", action: () => setActiveView("devtools"), disabled: false },
|
||||
{
|
||||
label: "Help & Options",
|
||||
action: () => setActiveView("settings"),
|
||||
disabled: false,
|
||||
id: "settings",
|
||||
},
|
||||
{
|
||||
label: "Versions",
|
||||
action: () => setActiveView("versions"),
|
||||
disabled: false,
|
||||
id: "versions",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
action: () => setActiveView("workshop"),
|
||||
disabled: false,
|
||||
id: "workshop",
|
||||
},
|
||||
{
|
||||
label: "Developer Tools",
|
||||
action: () => setActiveView("devtools"),
|
||||
disabled: false,
|
||||
id: "devtools",
|
||||
},
|
||||
],
|
||||
[
|
||||
isDownloading,
|
||||
@@ -105,40 +126,58 @@ const HomeView = memo(function HomeView() {
|
||||
animate={{ opacity: isFocusedSection ? 1 : 0.5, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
|
||||
className="w-full max-w-[540px] flex flex-col space-y-3 outline-none"
|
||||
className="relative w-full max-w-[540px] flex flex-col space-y-3 outline-none"
|
||||
>
|
||||
{buttons.map((btn: any, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
onMouseEnter={() => isFocusedSection && !btn.disabled && setMenuFocus(i)}
|
||||
onMouseLeave={() => setMenuFocus(null)}
|
||||
onClick={() => {
|
||||
if (isFocusedSection && !btn.disabled) {
|
||||
playPressSound();
|
||||
btn.action();
|
||||
<div key={i} className="relative w-full group">
|
||||
<button
|
||||
onMouseEnter={() =>
|
||||
isFocusedSection && !btn.disabled && setMenuFocus(i)
|
||||
}
|
||||
}}
|
||||
disabled={btn.disabled}
|
||||
className={`w-full h-12 flex items-center justify-center 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"}`}
|
||||
style={{
|
||||
backgroundImage: btn.disabled
|
||||
? "url('/images/Button_Background.png')"
|
||||
: menuFocus === i
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: btn.disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
onMouseLeave={() => setMenuFocus(null)}
|
||||
onClick={() => {
|
||||
if (isFocusedSection && !btn.disabled) {
|
||||
playPressSound();
|
||||
btn.action();
|
||||
}
|
||||
}}
|
||||
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"}`}
|
||||
style={{
|
||||
backgroundImage: btn.disabled
|
||||
? "url('/images/Button_Background.png')"
|
||||
: menuFocus === i
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: btn.disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full flex items-center justify-center relative">
|
||||
<span>{btn.label}</span>
|
||||
{btn.id === "versions" &&
|
||||
Object.values(updatesAvailable || {}).some((v) => v) && (
|
||||
<img
|
||||
src="/images/Update_Icon.png"
|
||||
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)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!legacyMode && (
|
||||
<div className="pt-4 flex flex-col items-center w-full gap-3">
|
||||
<div className="flex gap-8">
|
||||
<a
|
||||
href="https://discord.gg/vD8akRU24f"
|
||||
href="https://discord.gg/cQVKhQXcCx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
@@ -180,7 +219,7 @@ const HomeView = memo(function HomeView() {
|
||||
setShowCredits(true);
|
||||
}
|
||||
}}
|
||||
className={`text-white hover:text-[#FFFF55] text-xl mc-text-shadow tracking-widest transition-colors mt-1 bg-transparent border-none outline-none ${!isFocusedSection ? "cursor-default pointer-events-none" : ""}`}
|
||||
className={`text-white hover:text-[#FFFF55] text-xl mc-text-shadow tracking-widest transition-colors mt-1 bg-transparent border-none outline-none ${!isFocusedSection ? "pointer-events-none" : ""}`}
|
||||
>
|
||||
EMERALD TEAM
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
import {
|
||||
lceLiveService,
|
||||
LceLiveAccount,
|
||||
FriendRequest,
|
||||
GameInvite,
|
||||
} from "../../services/LceLiveService";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import ChooseInstanceModal from "../modals/ChooseInstanceModal";
|
||||
|
||||
const LceLiveView = memo(function LceLiveView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { editions, installs } = useGame();
|
||||
const [isSignedIn, setIsSignedIn] = useState(lceLiveService.signedIn);
|
||||
const [currentTab, setCurrentTab] = useState<
|
||||
"friends" | "requests" | "invites" | "device_link"
|
||||
>("friends");
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(0);
|
||||
const [acceptInvite, setAcceptInvite] = useState<GameInvite | null>(null);
|
||||
const [friends, setFriends] = useState<LceLiveAccount[]>([]);
|
||||
const [incomingReqs, setIncomingReqs] = useState<FriendRequest[]>([]);
|
||||
const [outgoingReqs, setOutgoingReqs] = useState<FriendRequest[]>([]);
|
||||
const [invites, setInvites] = useState<GameInvite[]>([]);
|
||||
const [linkData, setLinkData] = useState<any>(null);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const [isHosting, setIsHosting] = useState(false);
|
||||
const [hostStatus, setHostStatus] = useState("");
|
||||
const [hostIp, setHostIp] = useState("");
|
||||
const [hostPort, setHostPort] = useState(19132);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [invitedFriends, setInvitedFriends] = useState<Set<string>>(new Set());
|
||||
const [showHostMethodPicker, setShowHostMethodPicker] = useState(false);
|
||||
const [hostSessionId, setHostSessionId] = useState<string>("");
|
||||
const [hostMethodPick, setHostMethodPick] = useState<"direct" | "relay">(
|
||||
"direct",
|
||||
);
|
||||
const [relayStarted, setRelayStarted] = useState(false);
|
||||
const [isAddingFriend, setIsAddingFriend] = useState(false);
|
||||
const [addFriendUsername, setAddFriendUsername] = useState("");
|
||||
const addFriendInputRef = useRef<HTMLInputElement>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fetchSocialData = async () => {
|
||||
if (!lceLiveService.signedIn) return;
|
||||
try {
|
||||
const [f, reqs, invs] = await Promise.all([
|
||||
lceLiveService.getFriends(),
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites(),
|
||||
]);
|
||||
setFriends(f);
|
||||
setIncomingReqs(reqs.incoming);
|
||||
setOutgoingReqs(reqs.outgoing);
|
||||
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSignedIn) {
|
||||
if (currentTab === "device_link") setCurrentTab("friends");
|
||||
fetchSocialData();
|
||||
const pollInvites = setInterval(async () => {
|
||||
try {
|
||||
const invs = await lceLiveService.getGameInvites();
|
||||
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
|
||||
} catch (e) {
|
||||
console.warn("Failed to poll invites", e);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(pollInvites);
|
||||
} else {
|
||||
setCurrentTab("device_link");
|
||||
}
|
||||
}, [isSignedIn, currentTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTab !== "device_link") return;
|
||||
let mounted = true;
|
||||
let pollInterval: any = null;
|
||||
const startLink = async () => {
|
||||
try {
|
||||
if (!linkData) {
|
||||
const data = await lceLiveService.startDeviceLink();
|
||||
if (mounted) setLinkData(data);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (mounted) setLinkError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
startLink();
|
||||
if (linkData?.deviceCode) {
|
||||
pollInterval = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const res = await lceLiveService.pollDeviceLink(
|
||||
linkData.deviceCode,
|
||||
);
|
||||
if (res.isLinked && mounted) {
|
||||
setIsSignedIn(true);
|
||||
setLinkData(null);
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn("Poll failed", e);
|
||||
}
|
||||
},
|
||||
Math.max(linkData.intervalSeconds * 1000, 2000),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
}, [currentTab, linkData]);
|
||||
|
||||
const handleLogout = () => {
|
||||
playPressSound();
|
||||
lceLiveService.logoutLocal();
|
||||
setIsSignedIn(false);
|
||||
setLinkData(null);
|
||||
};
|
||||
|
||||
const handleAction = async (action: () => Promise<void>) => {
|
||||
playPressSound();
|
||||
try {
|
||||
await action();
|
||||
fetchSocialData();
|
||||
} catch (e: any) {
|
||||
setErrorModal(e.message || "An error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartHosting = () => {
|
||||
playPressSound();
|
||||
setRelayStarted(false);
|
||||
setShowHostMethodPicker(true);
|
||||
};
|
||||
|
||||
const handleHostDirect = async () => {
|
||||
setShowHostMethodPicker(false);
|
||||
setIsDiscovering(true);
|
||||
setHostStatus("Discovering external IP...");
|
||||
try {
|
||||
const endpoint = await TauriService.stunDiscover();
|
||||
setHostIp(endpoint.ip);
|
||||
setHostPort(25565);
|
||||
setIsHosting(true);
|
||||
setHostStatus(`Hosting at ${endpoint.ip}:25565`);
|
||||
setInvitedFriends(new Set());
|
||||
setHostSessionId("");
|
||||
} catch (e: any) {
|
||||
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
|
||||
setErrorModal("STUN discovery failed: " + msg);
|
||||
setHostStatus("");
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostRelay = async () => {
|
||||
setShowHostMethodPicker(false);
|
||||
setIsDiscovering(true);
|
||||
setHostStatus("Discovering external IP for invite...");
|
||||
const sessionId = crypto.randomUUID();
|
||||
setHostSessionId(sessionId);
|
||||
try {
|
||||
const endpoint = await TauriService.stunDiscover();
|
||||
setHostIp(endpoint.ip);
|
||||
setHostPort(25565);
|
||||
} catch {
|
||||
setHostIp("127.0.0.1");
|
||||
setHostPort(25565);
|
||||
}
|
||||
setRelayStarted(false);
|
||||
setIsHosting(true);
|
||||
setHostStatus("Relay ready - invite friends to activate");
|
||||
setInvitedFriends(new Set());
|
||||
setIsDiscovering(false);
|
||||
};
|
||||
|
||||
const handleStopHosting = async () => {
|
||||
playPressSound();
|
||||
try {
|
||||
await TauriService.stopProxy();
|
||||
await lceLiveService.deactivateGameInvites();
|
||||
} catch (e: any) {
|
||||
console.warn("Stop hosting failed", e);
|
||||
}
|
||||
setIsHosting(false);
|
||||
setHostStatus("");
|
||||
setHostIp("");
|
||||
setInvitedFriends(new Set());
|
||||
setHostSessionId("");
|
||||
setRelayStarted(false);
|
||||
};
|
||||
|
||||
const handleInviteFriend = async (friend: LceLiveAccount) => {
|
||||
playPressSound();
|
||||
const name = lceLiveService.displayUsername;
|
||||
try {
|
||||
await lceLiveService.sendGameInvite(
|
||||
friend.accountId,
|
||||
hostIp,
|
||||
hostPort,
|
||||
name,
|
||||
hostSessionId || undefined,
|
||||
);
|
||||
setInvitedFriends((prev) => new Set(prev).add(friend.accountId));
|
||||
if (hostSessionId && !relayStarted) {
|
||||
setRelayStarted(true);
|
||||
setHostStatus("Connecting relay...");
|
||||
const baseUrl = lceLiveService.apiBaseUrl;
|
||||
const accessToken = lceLiveService.accessToken ?? "";
|
||||
TauriService.startHostRelay(baseUrl, accessToken, hostSessionId, 25565)
|
||||
.then(() => setHostStatus("Relay active"))
|
||||
.catch((relayErr: any) => {
|
||||
const relayMsg =
|
||||
typeof relayErr === "string"
|
||||
? relayErr
|
||||
: relayErr?.message || "Unknown error";
|
||||
console.warn("Relay failed:", relayMsg);
|
||||
setHostStatus("Relay disconnected");
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
|
||||
setErrorModal("Failed to send invite: " + msg);
|
||||
}
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
type: "button" | "friend" | "request_in" | "request_out" | "invite";
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClickSecondary?: () => void;
|
||||
};
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [];
|
||||
if (currentTab === "device_link") {
|
||||
if (linkData) {
|
||||
items.push({
|
||||
id: "link_retry",
|
||||
type: "button",
|
||||
label: "Restart Link",
|
||||
onClick: () => {
|
||||
setLinkData(null);
|
||||
playPressSound();
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (currentTab === "friends") {
|
||||
if (!isDiscovering && !showHostMethodPicker) {
|
||||
if (!isHosting) {
|
||||
items.push({
|
||||
id: "host_game",
|
||||
type: "button",
|
||||
label: "Host Game",
|
||||
onClick: handleStartHosting,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
id: "stop_hosting",
|
||||
type: "button",
|
||||
label: "Stop Hosting",
|
||||
onClick: handleStopHosting,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
id: "add_friend",
|
||||
type: "button",
|
||||
label: "Add Friend",
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setIsAddingFriend(true);
|
||||
setAddFriendUsername("");
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "sign_out",
|
||||
type: "button",
|
||||
label: "Sign Out",
|
||||
onClick: handleLogout,
|
||||
});
|
||||
friends.forEach((f) => {
|
||||
items.push({
|
||||
id: `friend_${f.accountId}`,
|
||||
type: "friend",
|
||||
label: f.displayName,
|
||||
onClick: isHosting
|
||||
? () => handleInviteFriend(f)
|
||||
: () =>
|
||||
handleAction(() => lceLiveService.removeFriend(f.accountId)),
|
||||
onClickSecondary: isHosting
|
||||
? () => handleAction(() => lceLiveService.removeFriend(f.accountId))
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
} else if (currentTab === "requests") {
|
||||
incomingReqs.forEach((r) => {
|
||||
items.push({
|
||||
id: `req_in_${r.accountId}`,
|
||||
type: "request_in",
|
||||
label: r.displayName,
|
||||
onClick: () =>
|
||||
handleAction(() => lceLiveService.acceptFriendRequest(r.accountId)),
|
||||
onClickSecondary: () =>
|
||||
handleAction(() =>
|
||||
lceLiveService.declineFriendRequest(r.accountId),
|
||||
),
|
||||
});
|
||||
});
|
||||
outgoingReqs.forEach((r) => {
|
||||
items.push({
|
||||
id: `req_out_${r.accountId}`,
|
||||
type: "request_out",
|
||||
label: r.displayName,
|
||||
onClick: () =>
|
||||
handleAction(() =>
|
||||
lceLiveService.declineFriendRequest(r.accountId),
|
||||
),
|
||||
});
|
||||
});
|
||||
} else if (currentTab === "invites") {
|
||||
invites.forEach((inv) => {
|
||||
items.push({
|
||||
id: `inv_${inv.inviteId}`,
|
||||
type: "invite",
|
||||
label:
|
||||
typeof inv.from === "string" ? "Unknown" : inv.from.displayName,
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setAcceptInvite(inv);
|
||||
},
|
||||
onClickSecondary: () =>
|
||||
handleAction(() => lceLiveService.declineGameInvite(inv.inviteId)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
currentTab,
|
||||
friends,
|
||||
incomingReqs,
|
||||
outgoingReqs,
|
||||
invites,
|
||||
linkData,
|
||||
playPressSound,
|
||||
isHosting,
|
||||
isDiscovering,
|
||||
showHostMethodPicker,
|
||||
]);
|
||||
|
||||
const tabs = ["friends", "requests", "invites"];
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (errorModal) {
|
||||
if (e.key === "Escape" || e.key === "Enter") {
|
||||
setErrorModal(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAddingFriend) {
|
||||
if (e.key === "Escape") {
|
||||
setIsAddingFriend(false);
|
||||
playBackSound();
|
||||
} else if (e.key === "Enter") {
|
||||
if (addFriendUsername.trim() !== "") {
|
||||
handleAction(() =>
|
||||
lceLiveService.sendFriendRequest(addFriendUsername.trim()),
|
||||
);
|
||||
setIsAddingFriend(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (showHostMethodPicker) {
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
setShowHostMethodPicker(false);
|
||||
playBackSound();
|
||||
} else if (
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowRight" ||
|
||||
e.key === "q" ||
|
||||
e.key === "Q" ||
|
||||
e.key === "e" ||
|
||||
e.key === "E"
|
||||
) {
|
||||
setHostMethodPick((prev: "direct" | "relay") =>
|
||||
prev === "direct" ? "relay" : "direct",
|
||||
);
|
||||
playPressSound();
|
||||
} else if (e.key === "Enter") {
|
||||
if (hostMethodPick === "direct") handleHostDirect();
|
||||
else handleHostRelay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTab !== "device_link") {
|
||||
const curIdx = tabs.indexOf(currentTab);
|
||||
if (e.key === "q" || e.key === "Q" || e.key === "ArrowLeft") {
|
||||
const next = curIdx > 0 ? tabs[curIdx - 1] : tabs[tabs.length - 1];
|
||||
setCurrentTab(next as any);
|
||||
setFocusIndex(0);
|
||||
playPressSound();
|
||||
return;
|
||||
}
|
||||
if (e.key === "e" || e.key === "E" || e.key === "ArrowRight") {
|
||||
const next = curIdx < tabs.length - 1 ? tabs[curIdx + 1] : tabs[0];
|
||||
setCurrentTab(next as any);
|
||||
setFocusIndex(0);
|
||||
playPressSound();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const itemCount = menuItems.length;
|
||||
if (itemCount > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
setFocusIndex((prev) =>
|
||||
prev === null || prev >= itemCount - 1 ? 0 : prev + 1,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
setFocusIndex((prev) =>
|
||||
prev === null || prev <= 0 ? itemCount - 1 : prev - 1,
|
||||
);
|
||||
} else if (e.key === "Enter" && focusIndex !== null) {
|
||||
menuItems[focusIndex]?.onClick();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
focusIndex,
|
||||
menuItems,
|
||||
currentTab,
|
||||
playBackSound,
|
||||
setActiveView,
|
||||
isAddingFriend,
|
||||
addFriendUsername,
|
||||
errorModal,
|
||||
showHostMethodPicker,
|
||||
hostMethodPick,
|
||||
handleHostDirect,
|
||||
handleHostRelay,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddingFriend && addFriendInputRef.current) {
|
||||
addFriendInputRef.current.focus();
|
||||
} else if (focusIndex !== null) {
|
||||
const el = containerRef.current?.querySelector(
|
||||
`[data-index="${focusIndex}"]`,
|
||||
) as HTMLElement;
|
||||
if (el) {
|
||||
el.focus();
|
||||
if (scrollRef.current) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
if (rect.bottom > scrollRect.bottom || rect.top < scrollRect.top) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [focusIndex, isAddingFriend]);
|
||||
const renderContent = () => {
|
||||
if (currentTab === "device_link") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-6 flex-1 text-center py-20">
|
||||
{!linkData ? (
|
||||
<p className="text-xl text-[#2a2a2a] font-bold">
|
||||
{linkError || "Starting device link..."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl text-[#2a2a2a] font-bold">
|
||||
Open this link in your browser:
|
||||
</p>
|
||||
<p className="text-[#111] text-3xl font-bold tracking-widest break-words bg-black/10 px-6 py-2 rounded shadow-inner">
|
||||
{linkData.verificationUri}
|
||||
</p>
|
||||
<p className="text-2xl text-[#2a2a2a] font-bold mt-8">
|
||||
And enter the code:
|
||||
</p>
|
||||
<p className="text-[#111] text-6xl tracking-[0.2em] font-bold mt-2 bg-black/10 px-8 py-4 rounded shadow-inner">
|
||||
{linkData.userCode}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const topButtons = menuItems.filter((m) => m.type === "button");
|
||||
const listItems = menuItems.filter((m) => m.type !== "button");
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-4">
|
||||
{showHostMethodPicker ? (
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={handleHostDirect}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "direct" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
hostMethodPick === "direct"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Direct (STUN)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleHostRelay}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "relay" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
hostMethodPick === "relay"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Relay
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowHostMethodPicker(false);
|
||||
playBackSound();
|
||||
}}
|
||||
className="flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all text-white mc-text-shadow hover:text-gray-200"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
topButtons.length > 0 && (
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{topButtons.map((btn) => {
|
||||
const idx = menuItems.indexOf(btn);
|
||||
const isFocused = focusIndex === idx;
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
data-index={idx}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
onClick={btn.onClick}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${isFocused ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
|
||||
style={{
|
||||
backgroundImage: isFocused
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{hostStatus && (
|
||||
<div className="text-center text-sm text-[#FFFF55] mc-text-shadow py-1 tracking-wider">
|
||||
{hostStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 bg-black/5 shadow-inner rounded overflow-hidden border-4 border-[#222]">
|
||||
<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"
|
||||
? "Joinable Friends"
|
||||
: currentTab === "requests"
|
||||
? "Pending Requests"
|
||||
: "Game Invites"}
|
||||
</span>
|
||||
<span className="text-[#111]">{listItems.length}</span>
|
||||
</div>
|
||||
|
||||
<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
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col p-2 space-y-2">
|
||||
{listItems.map((item) => {
|
||||
const idx = menuItems.indexOf(item);
|
||||
const isFocused = focusIndex === idx;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
data-index={idx}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 relative outline-none border-none rounded transition-all ${isFocused ? "bg-black/15 shadow-inner" : "bg-transparent hover:bg-black/5"}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex flex-col ml-2 flex-1 min-w-0">
|
||||
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[#555] text-base font-bold truncate">
|
||||
@
|
||||
{menuItems.find((m) => m.id === item.id)?.type ===
|
||||
"friend"
|
||||
? friends.find(
|
||||
(f) => `friend_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: item.type === "request_in"
|
||||
? incomingReqs.find(
|
||||
(f) => `req_in_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: item.type === "request_out"
|
||||
? outgoingReqs.find(
|
||||
(f) => `req_out_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: "Invite"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 pr-2 shrink-0">
|
||||
{item.type === "friend" && !isHosting && (
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
)}
|
||||
{item.type === "friend" && isHosting && (
|
||||
<>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{invitedFriends.has(
|
||||
item.id.replace("friend_", ""),
|
||||
)
|
||||
? "INVITED"
|
||||
: "INVITE"}
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{item.type === "request_out" && (
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
)}
|
||||
{(item.type === "request_in" ||
|
||||
item.type === "invite") && (
|
||||
<>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
ACCEPT
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
DECLINE
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center justify-center w-full h-full absolute inset-0 outline-none p-12"
|
||||
>
|
||||
<div className="w-full max-w-5xl h-full flex flex-col mt-[4vh] mb-[4vh] relative drop-shadow-2xl">
|
||||
{currentTab !== "device_link" && (
|
||||
<div
|
||||
className="flex z-10 space-x-2 px-12 relative w-full items-end"
|
||||
style={{ marginBottom: "-4px" }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<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"}`}
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "bottom",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
setCurrentTab(t as any);
|
||||
setFocusIndex(0);
|
||||
playPressSound();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{t}
|
||||
{t === "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]"}`}
|
||||
>
|
||||
{incomingReqs.length}
|
||||
</span>
|
||||
)}
|
||||
{t === "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-[#30872a] border-[#1b5e16]" : "bg-[#23681d] border-[#111]"}`}
|
||||
>
|
||||
{invites.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 flex flex-col p-8 z-10 relative overflow-hidden rounded-b shadow-[0_0_30px_rgba(0,0,0,0.6)] border-4 border-[#222] border-t-0"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% auto",
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundPosition: "top",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isAddingFriend && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[420px] p-8 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
</h2>
|
||||
<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"
|
||||
value={addFriendUsername}
|
||||
onChange={(e) => setAddFriendUsername(e.target.value)}
|
||||
/>
|
||||
<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 transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
if (addFriendUsername.trim() !== "") {
|
||||
handleAction(() =>
|
||||
lceLiveService.sendFriendRequest(
|
||||
addFriendUsername.trim(),
|
||||
),
|
||||
);
|
||||
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 transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsAddingFriend(false);
|
||||
playBackSound();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[400px] p-8 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
</h2>
|
||||
<p className="text-white text-lg mc-text-shadow text-center mb-6">
|
||||
{errorModal}
|
||||
</p>
|
||||
<button
|
||||
className="h-12 w-48 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => setErrorModal(null)}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ChooseInstanceModal
|
||||
isOpen={acceptInvite !== null}
|
||||
onClose={() => {
|
||||
setAcceptInvite(null);
|
||||
fetchSocialData();
|
||||
}}
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
editions={editions}
|
||||
installs={installs}
|
||||
invite={acceptInvite}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default LceLiveView;
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState, useRef, useMemo } from "react";
|
||||
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 { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [loc, setLoc] = useState<LocFile | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedLocLangIdx, setSelectedLocLangIdx] = useState<number>(0);
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const [isLocEditModalOpen, setIsLocEditModalOpen] = useState<{ langIdx: number, strIdx: number, isNew: boolean } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentLocLang = useMemo(() => {
|
||||
if (!loc) return null;
|
||||
return loc.languages[selectedLocLangIdx] || null;
|
||||
}, [loc, selectedLocLangIdx]);
|
||||
|
||||
const filteredLocStrings = useMemo(() => {
|
||||
if (!currentLocLang) return [];
|
||||
return currentLocLang.strings.map((s, i) => ({ ...s, originalIdx: i }))
|
||||
.filter(s => (s.key?.toLowerCase().includes(searchTerm.toLowerCase()) || s.value.toLowerCase().includes(searchTerm.toLowerCase())));
|
||||
}, [currentLocLang, searchTerm]);
|
||||
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
const parsedLoc = ArcService.parseLOC(new Uint8Array(buffer));
|
||||
setLoc(parsedLoc);
|
||||
setSelectedLocLangIdx(0);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse LOC", err);
|
||||
showNotification("Failed to parse LOC", "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleSaveLoc = () => {
|
||||
if (!loc) return;
|
||||
playPressSound();
|
||||
const buffer = ArcService.serializeLOC(loc);
|
||||
const blob = new Blob([buffer as any]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "languages.loc";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("LOC Saved Successfully");
|
||||
};
|
||||
|
||||
const handleLocStringEdit = (langIdx: number, strIdx: number, isNew: boolean, key: string, value: string) => {
|
||||
if (!loc) return;
|
||||
playPressSound();
|
||||
const newLoc = { ...loc };
|
||||
const lang = newLoc.languages[langIdx];
|
||||
if (isNew) {
|
||||
lang.strings.push(lang.isStatic ? { value } : { key, value });
|
||||
} else {
|
||||
if (!lang.isStatic) lang.strings[strIdx].key = key;
|
||||
lang.strings[strIdx].value = value;
|
||||
}
|
||||
setLoc(newLoc);
|
||||
setIsLocEditModalOpen(null);
|
||||
showNotification(isNew ? "String Added" : "String Updated");
|
||||
};
|
||||
|
||||
const handleLocStringDelete = (langIdx: number, strIdx: number) => {
|
||||
if (!loc) return;
|
||||
playBackSound();
|
||||
const newLoc = { ...loc };
|
||||
newLoc.languages[langIdx].strings.splice(strIdx, 1);
|
||||
setLoc(newLoc);
|
||||
showNotification("String Deleted");
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".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>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open LOC
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveLoc}
|
||||
disabled={!loc}
|
||||
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
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<div className="mb-4 flex gap-4 items-center">
|
||||
<select
|
||||
value={selectedLocLangIdx}
|
||||
onChange={(e) => setSelectedLocLangIdx(parseInt(e.target.value))}
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search strings..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLocStrings.map((str) => (
|
||||
<tr key={str.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
|
||||
<td className="p-3 text-[#FFFF55] font-mono text-sm max-w-[200px] truncate">
|
||||
{currentLocLang?.isStatic ? str.originalIdx : str.key}
|
||||
</td>
|
||||
<td className="p-3 text-white text-sm whitespace-pre-wrap">{str.value}</td>
|
||||
<td className="p-3 text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
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 transition-all uppercase"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button onClick={() => handleLocStringDelete(selectedLocLangIdx, str.originalIdx)} className="p-1 hover:text-red-500 transition-colors opacity-60 hover:opacity-100">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{notification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
className="fixed top-12 right-12 z-[100] p-6 flex flex-col items-center justify-center min-w-[240px]"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
|
||||
{notification.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{isLocEditModalOpen && (
|
||||
<LocEditModal
|
||||
data={isLocEditModalOpen}
|
||||
lang={loc?.languages[isLocEditModalOpen.langIdx]!}
|
||||
onClose={() => setIsLocEditModalOpen(null)}
|
||||
onConfirm={handleLocStringEdit}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
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 [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>
|
||||
<div className="flex flex-col gap-4">
|
||||
{!lang.isStatic ? (
|
||||
<div>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">String Key</label>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/40 italic mb-2">Static entry - Index: {data.isNew ? lang.strings.length : data.strIdx}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-white/40 text-xs uppercase mb-2 block">String Value</label>
|
||||
<textarea
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors resize-none"
|
||||
/>
|
||||
</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={() => 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"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { OptionsService } from "../../services/OptionsService";
|
||||
import { OptionsFile } from "../../types/options";
|
||||
const BUTTONS: Record<number, string> = {
|
||||
0x00: "NONE", 0x01: "A", 0x02: "B", 0x03: "X", 0x04: "Y",
|
||||
0x05: "D-Pad Left", 0x06: "D-Pad Right", 0x07: "D-Pad Up", 0x08: "D-Pad Down",
|
||||
0x09: "RB", 0x0A: "LB", 0x0B: "RT", 0x0C: "LT",
|
||||
0x0D: "RS", 0x0E: "LS"
|
||||
};
|
||||
|
||||
export default function OptionsEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [opt, setOpt] = useState<OptionsFile | null>(null);
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<"settings" | "skins" | "actions">("settings");
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
const parsed = OptionsService.readOptions(buffer);
|
||||
setOpt(parsed);
|
||||
showNotification(`Loaded options.dat`);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to parse Options", err);
|
||||
showNotification(err.message || "Failed to parse Options", "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleSaveOptions = () => {
|
||||
if (!opt) return;
|
||||
playPressSound();
|
||||
try {
|
||||
const buffer = OptionsService.serializeOptions(opt);
|
||||
const blob = new Blob([buffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "options.dat";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("Options Saved");
|
||||
} catch (err: any) {
|
||||
showNotification("Failed to save", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = (field: keyof OptionsFile, value: any) => {
|
||||
if (!opt) return;
|
||||
setOpt({ ...opt, [field]: value });
|
||||
};
|
||||
|
||||
const updateAction = (field: keyof OptionsFile["actions"], value: number) => {
|
||||
if (!opt) return;
|
||||
setOpt({ ...opt, actions: { ...opt.actions, [field]: value } });
|
||||
};
|
||||
|
||||
const updateFavSkin = (idx: number, value: number) => {
|
||||
if (!opt) return;
|
||||
const newFav = [...opt.favoriteSkins];
|
||||
newFav[idx] = value;
|
||||
setOpt({ ...opt, favoriteSkins: newFav });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".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>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open settings.dat
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveOptions}
|
||||
disabled={!opt}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<div className="flex gap-1 p-2 pt-4 border-b-2 border-[#373737]">
|
||||
{(["settings", "skins", "actions"] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { playPressSound(); setActiveTab(tab); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === tab ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<span className="text-lg capitalize">{tab}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col p-6 overflow-y-auto custom-scrollbar">
|
||||
{activeTab === "settings" && (
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-6">
|
||||
{Object.keys(opt).filter(k => k !== "actions" && k !== "rawData" && k !== "chosenSkin" && k !== "playerCape" && k !== "favoriteSkins" && k !== "endianness").map((k) => {
|
||||
const val = opt[k as keyof OptionsFile];
|
||||
if (typeof val === "boolean") {
|
||||
return (
|
||||
<label key={k} className="flex items-center justify-between cursor-pointer group">
|
||||
<span className="text-white/80 uppercase text-sm tracking-widest">{k.replace(/([A-Z])/g, ' $1').trim()}</span>
|
||||
<div className={`w-12 h-6 border-2 transition-colors ${val ? "bg-[#55FF55] border-[#55FF55]" : "bg-black/40 border-[#373737]"}`} onClick={() => { playPressSound(); updateSetting(k as keyof OptionsFile, !val); }} />
|
||||
</label>
|
||||
);
|
||||
} else if (typeof val === "number") {
|
||||
return (
|
||||
<div key={k} className="flex flex-col gap-2">
|
||||
<span className="text-white/80 uppercase text-sm tracking-widest">{k.replace(/([A-Z])/g, ' $1').trim()}: {val}</span>
|
||||
<input
|
||||
type="range" min="0" max={k.includes("Size") || k === "difficulty" ? 3 : 255}
|
||||
value={val}
|
||||
onChange={(e) => updateSetting(k as keyof OptionsFile, parseInt(e.target.value))}
|
||||
className="w-full accent-[#FFFF55]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-white/80 uppercase tracking-widest">Player Cape ID</span>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-white/80 uppercase tracking-widest">Favorite Skins</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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "actions" && (
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-4">
|
||||
{Object.keys(opt.actions).map((k) => {
|
||||
const val = opt.actions[k as keyof typeof opt.actions];
|
||||
return (
|
||||
<div key={k} className="flex justify-between items-center group">
|
||||
<span className="text-white/80 uppercase text-sm tracking-widest">{k.replace(/([A-Z])/g, ' $1').trim()}</span>
|
||||
<select
|
||||
value={val}
|
||||
onChange={(e) => updateAction(k as keyof typeof opt.actions, parseInt(e.target.value))}
|
||||
className="bg-black/40 border border-[#373737] p-2 text-white outline-none focus:border-[#FFFF55] min-w-[120px] cursor-pointer"
|
||||
>
|
||||
{Object.entries(BUTTONS).map(([id, name]) => (
|
||||
<option key={id} value={id}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{notification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
className="fixed top-12 right-12 z-[100] p-6 flex flex-col items-center justify-center min-w-[240px]"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
|
||||
{notification.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { useState, useEffect, memo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
useUI,
|
||||
useAudio,
|
||||
useGame,
|
||||
useConfig,
|
||||
} from "../../context/LauncherContext";
|
||||
import {
|
||||
ScreenshotService,
|
||||
ScreenshotInfo,
|
||||
} from "../../services/ScreenshotService";
|
||||
import { ScreenshotImage } from "../common/ScreenshotImage";
|
||||
const ScreenshotsView = memo(function ScreenshotsView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { editions } = useGame();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
|
||||
const [selectedScreenshot, setSelectedScreenshot] =
|
||||
useState<ScreenshotInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [gridFocusIndex, setGridFocusIndex] = useState(0);
|
||||
const [modalFocusIndex, setModalFocusIndex] = useState(0);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteConfirmFocusIndex, setDeleteConfirmFocusIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
ScreenshotService.getScreenshots().then((data) => {
|
||||
setScreenshots(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
};
|
||||
|
||||
const handleOpenFolder = (screenshot: ScreenshotInfo) => {
|
||||
playPressSound();
|
||||
ScreenshotService.showInFolder(screenshot.path);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedScreenshot) return;
|
||||
playPressSound();
|
||||
await ScreenshotService.deleteScreenshot(selectedScreenshot.path);
|
||||
setScreenshots((prev) =>
|
||||
prev.filter((s) => s.path !== selectedScreenshot.path),
|
||||
);
|
||||
setSelectedScreenshot(null);
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (loading) return;
|
||||
if (showDeleteConfirm) {
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
playBackSound();
|
||||
setShowDeleteConfirm(false);
|
||||
} else if (
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowRight" ||
|
||||
e.key === "Tab"
|
||||
) {
|
||||
e.preventDefault();
|
||||
playPressSound();
|
||||
setDeleteConfirmFocusIndex((prev) => (prev === 0 ? 1 : 0));
|
||||
} else if (e.key === "Enter") {
|
||||
if (deleteConfirmFocusIndex === 1) confirmDelete();
|
||||
else {
|
||||
playBackSound();
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedScreenshot) {
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
playBackSound();
|
||||
setSelectedScreenshot(null);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
playPressSound();
|
||||
setModalFocusIndex((prev) => (prev > 0 ? prev - 1 : 2));
|
||||
} else if (e.key === "ArrowRight" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
playPressSound();
|
||||
setModalFocusIndex((prev) => (prev < 2 ? prev + 1 : 0));
|
||||
} else if (e.key === "Enter") {
|
||||
if (modalFocusIndex === 0) handleOpenFolder(selectedScreenshot);
|
||||
else if (modalFocusIndex === 1) {
|
||||
playPressSound();
|
||||
setDeleteConfirmFocusIndex(0);
|
||||
setShowDeleteConfirm(true);
|
||||
} else if (modalFocusIndex === 2) {
|
||||
playBackSound();
|
||||
setSelectedScreenshot(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cols =
|
||||
window.innerWidth >= 1024 ? 4 : window.innerWidth >= 768 ? 3 : 2;
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
handleBack();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
setGridFocusIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === "ArrowRight") {
|
||||
setGridFocusIndex((prev) =>
|
||||
prev < screenshots.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
setGridFocusIndex((prev) => (prev >= cols ? prev - cols : prev));
|
||||
} else if (e.key === "ArrowDown") {
|
||||
setGridFocusIndex((prev) =>
|
||||
prev <= screenshots.length - 1 - cols ? prev + cols : prev,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
if (screenshots[gridFocusIndex]) {
|
||||
playPressSound();
|
||||
setModalFocusIndex(2);
|
||||
setSelectedScreenshot(screenshots[gridFocusIndex]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
loading,
|
||||
selectedScreenshot,
|
||||
gridFocusIndex,
|
||||
modalFocusIndex,
|
||||
screenshots,
|
||||
showDeleteConfirm,
|
||||
deleteConfirmFocusIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedScreenshot) {
|
||||
const element = document.getElementById(`ss-${gridFocusIndex}`);
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [gridFocusIndex, selectedScreenshot]);
|
||||
|
||||
const getEditionLogo = (instanceId: string) => {
|
||||
const edition = editions.find((e: any) => e.id === instanceId);
|
||||
return edition?.logo || edition?.titleImage;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full h-full max-w-6xl relative font-['Mojangles'] 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
|
||||
</h2>
|
||||
|
||||
<div className="w-[98%] flex-1 relative overflow-hidden">
|
||||
<div className="absolute inset-0 overflow-y-auto p-6 scroll-smooth custom-scrollbar">
|
||||
{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...
|
||||
</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
|
||||
</span>
|
||||
<span className="text-sm mc-text-shadow tracking-widest italic">
|
||||
Take some in-game with F2
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{screenshots.map((ss, index) => (
|
||||
<div
|
||||
key={ss.path}
|
||||
id={`ss-${index}`}
|
||||
onClick={() => {
|
||||
setGridFocusIndex(index);
|
||||
setModalFocusIndex(2); // Close button
|
||||
setSelectedScreenshot(ss);
|
||||
playPressSound();
|
||||
}}
|
||||
onMouseEnter={() => setGridFocusIndex(index)}
|
||||
className={`
|
||||
relative aspect-video flex flex-col cursor-pointer transition-all border-2 rounded-sm overflow-hidden bg-black/40
|
||||
${gridFocusIndex === index ? "border-[#FFFF55] scale-105 z-10" : "border-[#333] hover:border-[#FFFF55]"}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
boxShadow:
|
||||
gridFocusIndex === index
|
||||
? "0 0 20px rgba(255, 255, 85, 0.2)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full relative overflow-hidden bg-black/50">
|
||||
<ScreenshotImage
|
||||
path={ss.path}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
alt={ss.name}
|
||||
fallbackSrc="/images/Folder_Icon.png"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60" />
|
||||
|
||||
{getEditionLogo(ss.instanceId) && (
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-2">
|
||||
<img
|
||||
src={getEditionLogo(ss.instanceId)}
|
||||
className="w-6 h-6 object-contain drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)]"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 right-2 text-[8px] bg-black/60 border border-[#555] px-1.5 py-0.5 text-[#A0A0A0] mc-text-shadow uppercase tracking-tighter">
|
||||
{new Date(ss.date * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-6 mb-4 flex justify-center">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`
|
||||
w-72 h-10 flex items-center justify-center text-xl mc-text-shadow border-none outline-none transition-all text-white
|
||||
hover:text-[#FFFF55]
|
||||
`}
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
|
||||
"url('/images/button_highlighted.png')";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
|
||||
"url('/images/Button_Background.png')";
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedScreenshot && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[200] bg-black/90 flex flex-col items-center justify-center p-8 backdrop-blur-md"
|
||||
onClick={() => setSelectedScreenshot(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="relative max-w-5xl w-full flex flex-col items-center border-2 border-[#555] rounded-sm p-2"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative w-full aspect-video bg-black/60 overflow-hidden border border-[#444] rounded-sm">
|
||||
<ScreenshotImage
|
||||
path={selectedScreenshot.path}
|
||||
className="w-full h-full object-contain"
|
||||
fallbackSrc="/images/Pack_Icon.png"
|
||||
/>
|
||||
<div className="absolute bottom-4 left-6 right-6 flex items-end justify-between pointer-events-none">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xl text-white mc-text-shadow block leading-tight tracking-wide font-bold">
|
||||
{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()}
|
||||
</span>
|
||||
</div>
|
||||
{getEditionLogo(selectedScreenshot.instanceId) && (
|
||||
<img
|
||||
src={getEditionLogo(selectedScreenshot.instanceId)}
|
||||
className="w-16 h-16 object-contain drop-shadow-[0_4px_4px_rgba(0,0,0,0.8)]"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 mt-6 mb-2 w-full justify-center px-6">
|
||||
<button
|
||||
onMouseEnter={() => setModalFocusIndex(0)}
|
||||
onClick={() => handleOpenFolder(selectedScreenshot)}
|
||||
className={`
|
||||
flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow border-none outline-none cursor-pointer transition-all
|
||||
${modalFocusIndex === 0 ? "text-[#FFFF55] scale-105" : "text-white"}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
modalFocusIndex === 0
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
OPEN FOLDER
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setModalFocusIndex(1)}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setDeleteConfirmFocusIndex(0);
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
className={`
|
||||
flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow border-none outline-none cursor-pointer transition-all
|
||||
${modalFocusIndex === 1 ? "text-[#FF5555] scale-105" : "text-white"}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
modalFocusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
DELETE
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setModalFocusIndex(2)}
|
||||
onClick={() => setSelectedScreenshot(null)}
|
||||
className={`
|
||||
w-48 h-12 flex items-center justify-center text-xl mc-text-shadow border-none outline-none cursor-pointer transition-all
|
||||
${modalFocusIndex === 2 ? "text-[#FFFF55] scale-105" : "text-white"}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
modalFocusIndex === 2
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
CLOSE
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showDeleteConfirm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[300] bg-black/80 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="w-[420px] p-6 border-2 border-[#555] rounded-sm flex flex-col items-center"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
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?
|
||||
</span>
|
||||
|
||||
<div className="flex gap-4 w-full">
|
||||
<button
|
||||
onMouseEnter={() => setDeleteConfirmFocusIndex(0)}
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
className={`
|
||||
flex-1 h-10 flex items-center justify-center text-lg mc-text-shadow border-none outline-none cursor-pointer transition-all
|
||||
${deleteConfirmFocusIndex === 0 ? "text-[#FFFF55] scale-105" : "text-white"}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
deleteConfirmFocusIndex === 0
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setDeleteConfirmFocusIndex(1)}
|
||||
onClick={confirmDelete}
|
||||
className={`
|
||||
flex-1 h-10 flex items-center justify-center text-lg mc-text-shadow border-none outline-none cursor-pointer transition-all
|
||||
${deleteConfirmFocusIndex === 1 ? "text-[#FF5555] scale-105" : "text-white"}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
deleteConfirmFocusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
DELETE
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScreenshotsView;
|
||||
@@ -2,16 +2,56 @@ import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService, Runner } from "../../services/TauriService";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
|
||||
const SettingsView = memo(function SettingsView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { vfxEnabled, setVfxEnabled, animationsEnabled, setAnimationsEnabled, musicVol: musicVolume, setMusicVol: setMusicVolume, sfxVol: sfxVolume, setSfxVol: setSfxVolume, layout, setLayout, linuxRunner, setLinuxRunner, perfBoost, setPerfBoost, rpcEnabled, setRpcEnabled, legacyMode, setLegacyMode } = useConfig();
|
||||
const { currentTrack, setCurrentTrack, tracks, playPressSound, playBackSound } = useAudio();
|
||||
const { isGameRunning, stopGame, isRunnerDownloading, runnerDownloadProgress, downloadRunner } = useGame();
|
||||
const {
|
||||
vfxEnabled,
|
||||
setVfxEnabled,
|
||||
animationsEnabled,
|
||||
setAnimationsEnabled,
|
||||
musicVol: musicVolume,
|
||||
setMusicVol: setMusicVolume,
|
||||
sfxVol: sfxVolume,
|
||||
setSfxVol: setSfxVolume,
|
||||
layout,
|
||||
setLayout,
|
||||
linuxRunner,
|
||||
setLinuxRunner,
|
||||
perfBoost,
|
||||
setPerfBoost,
|
||||
rpcEnabled,
|
||||
setRpcEnabled,
|
||||
legacyMode,
|
||||
setLegacyMode,
|
||||
mangohudEnabled,
|
||||
setMangohudEnabled,
|
||||
} = useConfig();
|
||||
const {
|
||||
currentTrack,
|
||||
setCurrentTrack,
|
||||
tracks,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
} = useAudio();
|
||||
const {
|
||||
isGameRunning,
|
||||
stopGame,
|
||||
isRunnerDownloading,
|
||||
runnerDownloadProgress,
|
||||
downloadRunner,
|
||||
} = useGame();
|
||||
const { isLinux, isMac } = usePlatform();
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||
const [currentSubMenu, setCurrentSubMenu] = useState<"main" | "audio" | "video" | "controls" | "launcher">("main");
|
||||
const [currentSubMenu, setCurrentSubMenu] = useState<
|
||||
"main" | "audio" | "video" | "controls" | "launcher"
|
||||
>("main");
|
||||
const [runners, setRunners] = useState<Runner[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -53,6 +93,11 @@ const SettingsView = memo(function SettingsView() {
|
||||
setLegacyMode(!legacyMode);
|
||||
};
|
||||
|
||||
const handleMangohudToggle = () => {
|
||||
playPressSound();
|
||||
setMangohudEnabled(!mangohudEnabled);
|
||||
};
|
||||
|
||||
const handleRunnerToggle = () => {
|
||||
playPressSound();
|
||||
if (runners.length === 0) return;
|
||||
@@ -69,35 +114,37 @@ const SettingsView = memo(function SettingsView() {
|
||||
const handleResetSetup = () => {
|
||||
playPressSound();
|
||||
|
||||
// Create styled confirmation dialog
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className =
|
||||
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
|
||||
dialog.innerHTML = `
|
||||
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
|
||||
<h3 class="text-2xl font-bold text-white mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">Reset Setup</h3>
|
||||
<p class="text-white mb-6 text-center">Are you sure you want to reset launcher setup?</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<button id="reset-yes" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Yes</button>
|
||||
<button id="reset-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">No</button>
|
||||
<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">Reset Setup</h3>
|
||||
<p class="text-[#333333] mb-8 text-left w-full px-4">Are you sure you want to reset launcher setup?</p>
|
||||
<div class="flex flex-col gap-3 w-full px-4">
|
||||
<button id="reset-cancel" 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)'">Cancel</button>
|
||||
<button id="reset-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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const handleYes = () => {
|
||||
const handleOk = () => {
|
||||
document.body.removeChild(dialog);
|
||||
showSecondConfirmation();
|
||||
};
|
||||
|
||||
const handleNo = () => {
|
||||
const handleCancel = () => {
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
|
||||
dialog.querySelector('#reset-yes')?.addEventListener('click', handleYes);
|
||||
dialog.querySelector('#reset-no')?.addEventListener('click', handleNo);
|
||||
dialog.querySelector("#reset-ok")?.addEventListener("click", handleOk);
|
||||
dialog
|
||||
.querySelector("#reset-cancel")
|
||||
?.addEventListener("click", handleCancel);
|
||||
|
||||
dialog.addEventListener('click', (e) => {
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
document.body.removeChild(dialog);
|
||||
}
|
||||
@@ -105,43 +152,48 @@ const SettingsView = memo(function SettingsView() {
|
||||
};
|
||||
|
||||
const showSecondConfirmation = () => {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className =
|
||||
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
|
||||
dialog.innerHTML = `
|
||||
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
|
||||
<h3 class="text-2xl font-bold text-yellow-400 mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">CONFIRM RESET</h3>
|
||||
<div class="text-white mb-6 text-left">
|
||||
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
|
||||
<h3 class="text-2xl font-bold text-[#333333] mb-2 text-left w-full px-4 mc-text-shadow">CONFIRM RESET</h3>
|
||||
<div class="text-[#333333] mb-6 text-left w-full px-4">
|
||||
<p class="mb-2">⚠️ This will:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<ul class="list-none space-y-1 text-sm">
|
||||
<li>Clear all launcher settings</li>
|
||||
<li>Reset your username</li>
|
||||
<li>Show setup screen again</li>
|
||||
<li>Require reconfiguration</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-yellow-400 font-bold">This action cannot be undone!</p>
|
||||
<p class="mt-3 text-[#333333] font-bold">This action cannot be undone!</p>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<button id="reset-final-yes" class="mc-sq-btn px-6 py-3 text-yellow-400 hover:scale-105 active:scale-95 transition-transform">YES, RESET</button>
|
||||
<button id="reset-final-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Cancel</button>
|
||||
<div class="flex flex-col gap-3 w-full px-4">
|
||||
<button id="reset-final-cancel" 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)'">Cancel</button>
|
||||
<button id="reset-final-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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const handleFinalYes = () => {
|
||||
const handleFinalOk = () => {
|
||||
document.body.removeChild(dialog);
|
||||
performReset();
|
||||
};
|
||||
|
||||
const handleFinalNo = () => {
|
||||
const handleFinalCancel = () => {
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
|
||||
dialog.querySelector('#reset-final-yes')?.addEventListener('click', handleFinalYes);
|
||||
dialog.querySelector('#reset-final-no')?.addEventListener('click', handleFinalNo);
|
||||
dialog
|
||||
.querySelector("#reset-final-ok")
|
||||
?.addEventListener("click", handleFinalOk);
|
||||
dialog
|
||||
.querySelector("#reset-final-cancel")
|
||||
?.addEventListener("click", handleFinalCancel);
|
||||
|
||||
dialog.addEventListener('click', (e) => {
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
document.body.removeChild(dialog);
|
||||
}
|
||||
@@ -149,13 +201,10 @@ const SettingsView = memo(function SettingsView() {
|
||||
};
|
||||
|
||||
const performReset = () => {
|
||||
// Clear all localStorage data
|
||||
localStorage.clear();
|
||||
|
||||
// Set setup as not completed
|
||||
localStorage.setItem('lce-setup-completed', 'false');
|
||||
localStorage.setItem("lce-setup-completed", "false");
|
||||
|
||||
// Force reload to show setup screen
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -163,11 +212,9 @@ const SettingsView = memo(function SettingsView() {
|
||||
if (tracks && tracks.length > 0) {
|
||||
const fullPath = tracks[currentTrack];
|
||||
if (fullPath) {
|
||||
trackName = fullPath
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(".ogg", "")
|
||||
.replace(".wav", "") || "Unknown";
|
||||
trackName =
|
||||
fullPath.split("/").pop()?.replace(".ogg", "").replace(".wav", "") ||
|
||||
"Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,20 +223,20 @@ const SettingsView = memo(function SettingsView() {
|
||||
|
||||
type SettingsItem =
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "slider";
|
||||
value: number;
|
||||
onChange: (val: any) => void;
|
||||
}
|
||||
id: string;
|
||||
label: string;
|
||||
type: "slider";
|
||||
value: number;
|
||||
onChange: (val: any) => void;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "button";
|
||||
onClick: () => void;
|
||||
small?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
id: string;
|
||||
label: string;
|
||||
type: "button";
|
||||
onClick: () => void;
|
||||
small?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const settingsItems = useMemo<SettingsItem[]>(() => {
|
||||
const items: SettingsItem[] = [];
|
||||
@@ -199,25 +246,41 @@ const SettingsView = memo(function SettingsView() {
|
||||
id: "audio_menu",
|
||||
label: "Audio",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("audio"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("audio");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "video_menu",
|
||||
label: "Video",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("video"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("video");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "controls_menu",
|
||||
label: "Controls",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("controls"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("controls");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "launcher_menu",
|
||||
label: "Launcher",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("launcher"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("launcher");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
} else if (currentSubMenu === "audio") {
|
||||
items.push({
|
||||
@@ -288,6 +351,12 @@ const SettingsView = memo(function SettingsView() {
|
||||
type: "button",
|
||||
onClick: handleRunnerToggle,
|
||||
});
|
||||
items.push({
|
||||
id: "mangohud",
|
||||
label: `MangoHud: ${mangohudEnabled ? "ON" : "OFF"}`,
|
||||
type: "button",
|
||||
onClick: handleMangohudToggle,
|
||||
});
|
||||
items.push({
|
||||
id: "download_runner",
|
||||
label: isRunnerDownloading
|
||||
@@ -296,7 +365,10 @@ const SettingsView = memo(function SettingsView() {
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
if (!isRunnerDownloading) {
|
||||
downloadRunner("GE-Proton9-25", "https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz");
|
||||
downloadRunner(
|
||||
"GE-Proton9-25",
|
||||
"https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz",
|
||||
);
|
||||
}
|
||||
},
|
||||
small: true,
|
||||
@@ -349,6 +421,7 @@ const SettingsView = memo(function SettingsView() {
|
||||
animationsEnabled,
|
||||
layout,
|
||||
isLinux,
|
||||
mangohudEnabled,
|
||||
selectedRunnerName,
|
||||
isRunnerDownloading,
|
||||
runnerDownloadProgress,
|
||||
@@ -363,6 +436,7 @@ const SettingsView = memo(function SettingsView() {
|
||||
handleLayoutToggle,
|
||||
handleRunnerToggle,
|
||||
handlePerfToggle,
|
||||
handleMangohudToggle,
|
||||
handleResetSetup,
|
||||
stopGame,
|
||||
downloadRunner,
|
||||
@@ -435,11 +509,16 @@ const SettingsView = memo(function SettingsView() {
|
||||
backgroundImage: "url('/images/Button_Background2.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated" as const,
|
||||
color: focusIndex === index ? "#FFFF55" : "white",
|
||||
color: focusIndex === index ? "#ffff00" : "white",
|
||||
});
|
||||
|
||||
const isToggleOption = (label: string): boolean => {
|
||||
return label.includes("ON") || label.includes("OFF") || label.includes("Enabled") || label.includes("Disabled");
|
||||
return (
|
||||
label.includes("ON") ||
|
||||
label.includes("OFF") ||
|
||||
label.includes("Enabled") ||
|
||||
label.includes("Disabled")
|
||||
);
|
||||
};
|
||||
|
||||
const getToggleState = (label: string): boolean => {
|
||||
@@ -454,10 +533,18 @@ const SettingsView = memo(function SettingsView() {
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-2xl outline-none"
|
||||
className="flex flex-col items-center w-full max-w-3xl outline-none"
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
|
||||
{currentSubMenu === "main" ? "Settings" : currentSubMenu === "audio" ? "Audio" : currentSubMenu === "video" ? "Video" : currentSubMenu === "controls" ? "Controls" : "Launcher"}
|
||||
{currentSubMenu === "main"
|
||||
? "Settings"
|
||||
: currentSubMenu === "audio"
|
||||
? "Audio"
|
||||
: currentSubMenu === "video"
|
||||
? "Video"
|
||||
: currentSubMenu === "controls"
|
||||
? "Controls"
|
||||
: "Launcher"}
|
||||
</h2>
|
||||
|
||||
{currentSubMenu === "main" ? (
|
||||
@@ -472,11 +559,11 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
|
||||
style={getSliderStyle(index)}
|
||||
>
|
||||
<span
|
||||
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : "text-white"}`}
|
||||
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : "text-white"}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -505,14 +592,15 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
onClick={item.onClick}
|
||||
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${isRed
|
||||
? focusIndex === index
|
||||
? "text-red-400"
|
||||
: "text-red-200"
|
||||
: focusIndex === index
|
||||
? "text-[#FFFF55]"
|
||||
: "text-white"
|
||||
} ${isRed ? "hover:text-red-500" : "hover:text-[#FFFF55]"}`}
|
||||
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${
|
||||
isRed
|
||||
? focusIndex === index
|
||||
? "text-red-400"
|
||||
: "text-red-200"
|
||||
: focusIndex === index
|
||||
? "text-[#ffff00]"
|
||||
: "text-white"
|
||||
} ${isRed ? "hover:text-red-500" : "hover:text-[#ffff00]"}`}
|
||||
style={getItemStyle(index)}
|
||||
>
|
||||
<span
|
||||
@@ -525,15 +613,7 @@ const SettingsView = memo(function SettingsView() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="min-w-[420px] w-fit p-6 flex flex-col items-center"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-[420px] w-fit p-4 flex flex-col items-center mc-options-bg">
|
||||
<div className="w-full space-y-3 flex flex-col items-center overflow-y-auto max-h-[50vh] py-2">
|
||||
{settingsItems.map((item, index) => {
|
||||
if (item.id === "back") return null;
|
||||
@@ -545,11 +625,11 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
|
||||
style={getSliderStyle(index)}
|
||||
>
|
||||
<span
|
||||
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
|
||||
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -560,7 +640,9 @@ const SettingsView = memo(function SettingsView() {
|
||||
max="100"
|
||||
step="1"
|
||||
value={item.value}
|
||||
onChange={(e) => item.onChange(parseInt(e.target.value))}
|
||||
onChange={(e) =>
|
||||
item.onChange(parseInt(e.target.value))
|
||||
}
|
||||
onMouseUp={playPressSound}
|
||||
className="mc-slider-custom w-[calc(100%+16px)] h-full opacity-100 cursor-pointer z-20 outline-none m-0"
|
||||
/>
|
||||
@@ -580,21 +662,35 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
onClick={item.onClick}
|
||||
className={`w-[360px] h-10 flex items-center justify-between px-4 relative z-30 transition-all outline-none border-none shrink-0 rounded ${focusIndex === index ? "bg-black/10" : "bg-transparent"} ${isRed ? "text-red-600" : "text-[#2a2a2a]"} hover:bg-black/15`}
|
||||
className={`w-[360px] h-10 flex items-center pl-1.5 pr-4 relative z-30 outline-none border-none shrink-0 rounded ${focusIndex === index ? "text-[#ffff00]" : isRed ? "text-red-600" : "text-[#333333]"}`}
|
||||
>
|
||||
{isToggle && (
|
||||
<div className="relative w-6 h-6 mr-3 shrink-0">
|
||||
<img
|
||||
src={
|
||||
focusIndex === index
|
||||
? "/images/checkbox_highlighted.png"
|
||||
: "/images/checkbox.png"
|
||||
}
|
||||
alt="checkbox"
|
||||
className="w-full h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{toggleState && (
|
||||
<img
|
||||
src="/images/check.png"
|
||||
alt="checked"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`tracking-widest uppercase ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left flex-1`}
|
||||
className={`tracking-widest text-xl mc-text-shadow ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left`}
|
||||
>
|
||||
{isToggle ? item.label.split(":")[0] : item.label}
|
||||
</span>
|
||||
{isToggle && (
|
||||
<img
|
||||
src={toggleState ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt={toggleState ? "ON" : "OFF"}
|
||||
className="w-12 h-6 object-contain shrink-0 ml-2"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -612,11 +708,12 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={backIndex}
|
||||
onMouseEnter={() => setFocusIndex(backIndex)}
|
||||
onClick={backItem.onClick}
|
||||
className={`w-72 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] mt-4 ${focusIndex === backIndex ? "text-[#FFFF55]" : "text-white"}`}
|
||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#ffff00] mt-4 ${focusIndex === backIndex ? "text-[#ffff00]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === backIndex
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundImage:
|
||||
focusIndex === backIndex
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { TauriService, Runner } from "../../services/TauriService";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
import { useConfig, useAudio, useGame } from "../../context/LauncherContext";
|
||||
|
||||
interface SetupViewProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
@@ -20,12 +19,11 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
linuxRunner: configLinuxRunner,
|
||||
vfxEnabled: configVfx,
|
||||
rpcEnabled: configRpc,
|
||||
animationsEnabled,
|
||||
} = useConfig();
|
||||
const { playPressSound, playSfx } = useAudio();
|
||||
|
||||
const { editions } = useGame();
|
||||
const titleImage = editions.find(e => e.id === profile)?.titleImage || "/images/MenuTitle.png";
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [tempUsername, setTempUsername] = useState(username);
|
||||
@@ -34,12 +32,9 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
const [isSettingUpRuntime, setIsSettingUpRuntime] = useState(false);
|
||||
const [setupProgress, setSetupProgress] = useState<{ stage: string; message: string; percent?: number } | null>(null);
|
||||
const [runtimeAlreadyInstalled, setRuntimeAlreadyInstalled] = useState(false);
|
||||
|
||||
const [enableVfx, setEnableVfx] = useState(configVfx);
|
||||
const [enableDiscordRPC, setEnableDiscordRPC] = useState(configRpc);
|
||||
|
||||
const totalSteps = isLinux ? 4 : 4;
|
||||
|
||||
const totalSteps = 4;
|
||||
useEffect(() => {
|
||||
if (isLinux || isMac) {
|
||||
TauriService.getAvailableRunners().then(availableRunners => {
|
||||
@@ -52,9 +47,7 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
|
||||
if (isMac) {
|
||||
checkMacOSRuntime();
|
||||
|
||||
const unlisten = TauriService.onMacosProgress((progress) => {
|
||||
console.log("[macOS Setup Progress]", progress);
|
||||
setSetupProgress(progress);
|
||||
});
|
||||
|
||||
@@ -66,33 +59,16 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
|
||||
const checkMacOSRuntime = async () => {
|
||||
try {
|
||||
const localStorageInstalled = localStorage.getItem('lce-macos-runtime-installed') === 'true';
|
||||
|
||||
if (localStorageInstalled) {
|
||||
console.log("[macOS Runtime] Using cached installation status");
|
||||
try {
|
||||
const runtimeCheck = await TauriService.checkMacOSRuntimeInstalledFast();
|
||||
if (runtimeCheck) {
|
||||
setRuntimeAlreadyInstalled(true);
|
||||
return;
|
||||
} else {
|
||||
console.log("[macOS Runtime] Cache was wrong, clearing");
|
||||
localStorage.removeItem('lce-macos-runtime-installed');
|
||||
setRuntimeAlreadyInstalled(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[macOS Runtime] Fast check failed, using cache");
|
||||
setRuntimeAlreadyInstalled(true);
|
||||
return;
|
||||
}
|
||||
const runtimeCheck = await TauriService.checkMacOSRuntimeInstalledFast();
|
||||
setRuntimeAlreadyInstalled(runtimeCheck);
|
||||
if (runtimeCheck) {
|
||||
localStorage.setItem('lce-macos-runtime-installed', 'true');
|
||||
} else {
|
||||
console.log("[macOS Runtime] No installation detected");
|
||||
setRuntimeAlreadyInstalled(false);
|
||||
localStorage.removeItem('lce-macos-runtime-installed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[macOS Runtime] Error checking:", error);
|
||||
} catch {
|
||||
setRuntimeAlreadyInstalled(false);
|
||||
localStorage.removeItem('lce-macos-runtime-installed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,21 +79,17 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
|
||||
const handleNext = async () => {
|
||||
playPressSound();
|
||||
|
||||
if (currentStep === 0) {
|
||||
setUsername(tempUsername);
|
||||
setCurrentStep(1);
|
||||
setFocusIndex(0);
|
||||
} else if (currentStep === 1) {
|
||||
if (isLinux && selectedRunner) {
|
||||
setLinuxRunner(selectedRunner);
|
||||
}
|
||||
if (isLinux && selectedRunner) setLinuxRunner(selectedRunner);
|
||||
setCurrentStep(2);
|
||||
setFocusIndex(0);
|
||||
} else if (currentStep === 2) {
|
||||
setConfigVfx(enableVfx);
|
||||
setConfigRpc(enableDiscordRPC);
|
||||
|
||||
setCurrentStep(3);
|
||||
setFocusIndex(0);
|
||||
} else if (currentStep === 3) {
|
||||
@@ -137,16 +109,14 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
// Elements count per step
|
||||
let count = 0;
|
||||
if (currentStep === 0) count = 2; // Input, Next
|
||||
if (currentStep === 0) count = 2;
|
||||
else if (currentStep === 1) {
|
||||
if (isLinux) count = runners.length + 2; // Runners, Back, Next
|
||||
else if (isMac) count = 3; // Install, Back, Next
|
||||
else count = 2; // Back, Next
|
||||
} else if (currentStep === 2) count = 4; // 2 Toggles, Back, Next
|
||||
else if (currentStep === 3) count = 2; // Back, Finish
|
||||
|
||||
if (isLinux) count = runners.length + 2;
|
||||
else if (isMac) count = 3;
|
||||
else count = 2;
|
||||
} else if (currentStep === 2) count = 4;
|
||||
else if (currentStep === 3) count = 2;
|
||||
if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev + 1) % count);
|
||||
@@ -154,10 +124,9 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev - 1 + count) % count);
|
||||
} else if (e.key === "Enter") {
|
||||
// Handle enter based on focusIndex and step
|
||||
if (currentStep === 0) {
|
||||
if (focusIndex === 0) handleNext(); // For input field
|
||||
else if (focusIndex === 1) handleNext(); // Next button
|
||||
if (focusIndex === 0) handleNext();
|
||||
else if (focusIndex === 1) handleNext();
|
||||
} else if (currentStep === 1) {
|
||||
if (isLinux) {
|
||||
if (focusIndex < runners.length) handleRunnerSelect(runners[focusIndex].id);
|
||||
@@ -190,38 +159,37 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
playPressSound();
|
||||
setIsSettingUpRuntime(true);
|
||||
setSetupProgress({ stage: "preparing", message: "Preparing macOS runtime setup...", percent: 0 });
|
||||
|
||||
try {
|
||||
console.log("[macOS Setup] Starting runtime installation...");
|
||||
await TauriService.setupMacosRuntime();
|
||||
console.log("[macOS Setup] Runtime installation completed successfully!");
|
||||
setSetupProgress({ stage: "completed", message: "Setup completed successfully!", percent: 100 });
|
||||
|
||||
localStorage.setItem('lce-macos-runtime-installed', 'true');
|
||||
setRuntimeAlreadyInstalled(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentStep(2);
|
||||
setIsSettingUpRuntime(false);
|
||||
setSetupProgress(null);
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
console.error("[macOS Setup] Error:", e);
|
||||
setSetupProgress({ stage: "error", message: `Setup failed: ${e}`, percent: 0 });
|
||||
setIsSettingUpRuntime(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = () => {
|
||||
if (currentStep === 0) {
|
||||
return tempUsername.trim().length > 0;
|
||||
}
|
||||
if (currentStep === 1 && isMac) {
|
||||
return runtimeAlreadyInstalled;
|
||||
}
|
||||
if (currentStep === 0) return tempUsername.trim().length > 0;
|
||||
if (currentStep === 1 && isMac) return runtimeAlreadyInstalled;
|
||||
return true;
|
||||
};
|
||||
|
||||
const stepTitles = ["Welcome", "Compatibility", "Preferences", "Ready"];
|
||||
const navBtnStyle = (isFocused: boolean) => ({
|
||||
backgroundImage: isFocused
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated" as const,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black">
|
||||
<div className="relative w-full h-full flex items-center justify-center p-8">
|
||||
@@ -240,300 +208,327 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: useConfig().animationsEnabled ? 0.2 : 0 }}
|
||||
className="max-w-2xl w-full mx-auto flex flex-col"
|
||||
transition={{ duration: animationsEnabled ? 0.2 : 0 }}
|
||||
className="max-w-xl w-full mx-auto flex flex-col items-center"
|
||||
>
|
||||
<div className="relative p-8 flex flex-col"
|
||||
<div
|
||||
className="relative p-8 flex flex-col w-full"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
imageRendering: "pixelated",
|
||||
transformOrigin: "center center",
|
||||
maxHeight: "85vh",
|
||||
}}>
|
||||
<div className="overflow-y-auto flex-1" style={{ scrollbarWidth: "thin", scrollbarColor: "#555 transparent" }}>
|
||||
<div className="flex justify-center space-x-2 mb-8">
|
||||
{Array.from({ length: totalSteps }, (_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: useConfig().animationsEnabled ? i * 0.05 : 0 }}
|
||||
className={`h-2 w-16 transition-all ${i <= currentStep ? "bg-white" : "bg-white/20"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center gap-3 mb-6">
|
||||
{Array.from({ length: totalSteps }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 w-12 transition-all duration-300 ${i <= currentStep ? "bg-white" : "bg-white/20"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mb-1 border-b-2 border-[#373737] pb-2 text-center tracking-widest uppercase opacity-80 font-bold">
|
||||
{stepTitles[currentStep]}
|
||||
</h2>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`content-${currentStep}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: animationsEnabled ? 0.2 : 0, delay: animationsEnabled ? 0.05 : 0 }}
|
||||
className="mt-4 overflow-y-auto flex-1"
|
||||
style={{ scrollbarWidth: "thin", scrollbarColor: "#555 transparent" }}
|
||||
>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`content-${currentStep}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0, delay: useConfig().animationsEnabled ? 0.1 : 0 }}
|
||||
>
|
||||
{currentStep === 0 && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
|
||||
Welcome to Emerald Launcher
|
||||
</h2>
|
||||
<p className="text-lg mb-8 text-white/80">Let's configure your launcher</p>
|
||||
{currentStep === 0 && (
|
||||
<div
|
||||
className="p-5 flex flex-col gap-4"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<p className="text-white/70 text-sm tracking-widest text-center uppercase">
|
||||
Let's configure your launcher
|
||||
</p>
|
||||
<label className="block">
|
||||
<span className="text-white font-bold uppercase tracking-widest text-sm mc-text-shadow block mb-2">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
value={tempUsername}
|
||||
onChange={(e) => setTempUsername(e.target.value)}
|
||||
onFocus={() => setFocusIndex(0)}
|
||||
className={`w-full px-4 py-2 bg-black/60 focus:outline-none transition-colors text-white tracking-widest
|
||||
${focusIndex === 0 ? "border-2 border-[#FFFF55] text-[#FFFF55]" : "border-2 border-white/30"}`}
|
||||
style={{ imageRendering: "pixelated", fontFamily: "'Mojangles', monospace" }}
|
||||
placeholder="Enter your username"
|
||||
maxLength={16}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{tempUsername.trim().length === 0 && (
|
||||
<p className="text-white/40 text-xs text-center uppercase tracking-widest">A username is required to continue</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block text-left">
|
||||
<span className="text-white font-bold mb-2 block">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
value={tempUsername}
|
||||
onChange={(e) => setTempUsername(e.target.value)}
|
||||
onFocus={() => setFocusIndex(0)}
|
||||
className={`w-full px-4 py-3 bg-black/50 border-2 focus:outline-none transition-colors ${focusIndex === 0 ? "border-yellow-400" : "border-white"}`}
|
||||
placeholder="Enter your username"
|
||||
maxLength={16}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{currentStep === 1 && isMac && (
|
||||
<div
|
||||
className="p-5 flex flex-col gap-4"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<p className="text-white/70 text-xs tracking-widest text-center uppercase">
|
||||
{runtimeAlreadyInstalled
|
||||
? "Compatibility runtime is already installed"
|
||||
: "Emerald needs a compatibility runtime to run on macOS"
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className={`flex items-center gap-3 p-3 border-2 ${runtimeAlreadyInstalled ? "border-green-400/60 bg-green-900/10" : "border-yellow-400/60 bg-yellow-900/10"}`}>
|
||||
<span className={`text-xl ${runtimeAlreadyInstalled ? "text-green-400" : "text-yellow-400"}`}>
|
||||
{runtimeAlreadyInstalled ? "✓" : "⚠"}
|
||||
</span>
|
||||
<div>
|
||||
<p className={`font-bold text-sm uppercase tracking-widest ${runtimeAlreadyInstalled ? "text-green-400" : "text-yellow-400"}`}>
|
||||
{runtimeAlreadyInstalled ? "Runtime Detected" : "Runtime Not Detected"}
|
||||
</p>
|
||||
<p className="text-white/60 text-xs mt-0.5">
|
||||
{runtimeAlreadyInstalled
|
||||
? "Ready to use — you can proceed."
|
||||
: "You must install the runtime before proceeding."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && isMac && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
|
||||
macOS Compatibility
|
||||
</h2>
|
||||
<p className="text-lg mb-6 text-white/80">
|
||||
{runtimeAlreadyInstalled
|
||||
? "Emerald compatibility runtime is already installed"
|
||||
: "Emerald needs compatibility runtime for macOS"
|
||||
}
|
||||
</p>
|
||||
|
||||
{setupProgress && (
|
||||
<div className="mb-4 p-4 bg-black/50 border border-white/20 rounded">
|
||||
<p className="text-sm font-bold text-yellow-400 mb-2">{setupProgress.stage.toUpperCase()}</p>
|
||||
<p className="text-xs opacity-80">{setupProgress.message}</p>
|
||||
{setupProgress.percent !== undefined && (
|
||||
<div className="w-full bg-white/20 h-2 rounded-full mt-3">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${setupProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-lg ${runtimeAlreadyInstalled
|
||||
? "bg-green-600/20 border-2 border-green-400"
|
||||
: "bg-yellow-600/20 border-2 border-yellow-400"
|
||||
}`}>
|
||||
<p className={`font-bold mb-2 ${runtimeAlreadyInstalled ? "text-green-400" : "text-yellow-400"
|
||||
}`}>
|
||||
{runtimeAlreadyInstalled ? "✓ Runtime Detected" : "⚠ Runtime Not Detected"}
|
||||
</p>
|
||||
<p className="text-xs text-white/80">
|
||||
{runtimeAlreadyInstalled
|
||||
? "The compatibility runtime is properly installed and ready to use."
|
||||
: "You must install the compatibility runtime before proceeding to the next step."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleMacosSetup}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
disabled={isSettingUpRuntime}
|
||||
className={`px-6 py-3 text-white font-bold bg-green-600 hover:bg-green-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 active:scale-95 border-4 ${focusIndex === 0 ? "border-yellow-400" : "border-green-400"}`}
|
||||
style={{
|
||||
fontFamily: "'Mojangles', monospace",
|
||||
imageRendering: "pixelated",
|
||||
textShadow: "2px 2px 0px rgba(0,0,0,0.8)",
|
||||
boxShadow: "4px 4px 0px rgba(0,0,0,0.3)",
|
||||
fontSize: "16px",
|
||||
letterSpacing: "1px"
|
||||
}}
|
||||
>
|
||||
{isSettingUpRuntime ? "Installing..." : runtimeAlreadyInstalled ? "Reinstall Runtime" : "Install Runtime"}
|
||||
</button>
|
||||
|
||||
{!runtimeAlreadyInstalled && (
|
||||
<p className="text-xs text-red-400 font-bold">
|
||||
⚠ Installation required before proceeding to next step
|
||||
</p>
|
||||
{setupProgress && (
|
||||
<div className="p-3 bg-black/40 border border-white/10">
|
||||
<p className="text-yellow-400 text-xs font-bold uppercase tracking-widest mb-1">{setupProgress.stage}</p>
|
||||
<p className="text-white/70 text-xs">{setupProgress.message}</p>
|
||||
{setupProgress.percent !== undefined && (
|
||||
<div className="w-full bg-white/10 h-1.5 mt-2">
|
||||
<div
|
||||
className="h-full bg-green-400 transition-all duration-300"
|
||||
style={{ width: `${setupProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleMacosSetup}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
disabled={isSettingUpRuntime}
|
||||
className={`w-[260px] h-10 flex items-center justify-center transition-colors mc-text-shadow outline-none border-none
|
||||
${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
style={navBtnStyle(focusIndex === 0)}
|
||||
>
|
||||
<span className="tracking-widest uppercase text-lg">
|
||||
{isSettingUpRuntime ? "Installing..." : runtimeAlreadyInstalled ? "Reinstall Runtime" : "Install Runtime"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && isLinux && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
|
||||
Linux Compatibility
|
||||
</h2>
|
||||
<p className="text-lg mb-6 text-white/80">Choose your preferred compatibility layer</p>
|
||||
{currentStep === 1 && isLinux && (
|
||||
<div
|
||||
className="p-5 flex flex-col gap-3"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<p className="text-white/70 text-xs tracking-widest text-center uppercase">
|
||||
Choose your preferred compatibility layer
|
||||
</p>
|
||||
{runners.length === 0 ? (
|
||||
<div className="p-3 border-2 border-yellow-400/50 bg-yellow-900/10">
|
||||
<p className="text-yellow-400 text-sm text-center">No compatible runners found. Please install Wine or Proton.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{runners.map((runner, idx) => (
|
||||
<button
|
||||
key={runner.id}
|
||||
onClick={() => handleRunnerSelect(runner.id)}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className={`w-full h-10 flex items-center justify-between px-4 transition-all outline-none border-none
|
||||
${selectedRunner === runner.id ? "bg-white/10" : "bg-transparent"}
|
||||
${focusIndex === idx ? "text-[#FFFF55]" : "text-white/80"} hover:text-[#FFFF55] hover:bg-black/10`}
|
||||
style={navBtnStyle(focusIndex === idx)}
|
||||
>
|
||||
<span className="tracking-widest uppercase text-lg mc-text-shadow">{runner.name}</span>
|
||||
{selectedRunner === runner.id && (
|
||||
<span className="text-[#FFFF55] text-sm">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-white/40 text-center uppercase tracking-widest mt-1">You can change this later in settings</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runners.length === 0 ? (
|
||||
<div className="p-4 bg-yellow-500/20 border-2 border-yellow-500/50">
|
||||
<p className="text-yellow-400">No compatible runners found. Please install Wine or Proton.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{runners.map((runner, idx) => (
|
||||
<button
|
||||
key={runner.id}
|
||||
onClick={() => handleRunnerSelect(runner.id)}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className={`w-full p-4 text-left border-2 transition-all duration-200 ${selectedRunner === runner.id
|
||||
? "bg-white/20 border-white shadow-[0_0_15px_rgba(255,255,255,0.2)]"
|
||||
: "bg-black/50 border-white/20"
|
||||
} ${focusIndex === idx ? "border-yellow-400" : ""}`}
|
||||
>
|
||||
<p className="font-bold text-white">{runner.name}</p>
|
||||
<p className="text-xs text-white/60 mt-1">{runner.type}</p>
|
||||
</button>
|
||||
))}
|
||||
{currentStep === 1 && !isMac && !isLinux && (
|
||||
<div
|
||||
className="p-5 flex flex-col gap-4"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<p className="text-white/70 text-xs tracking-widest text-center uppercase">
|
||||
Everything is ready to go!
|
||||
</p>
|
||||
<div className="flex items-center gap-3 p-3 border-2 border-green-400/60 bg-green-900/10">
|
||||
<span className="text-green-400 text-xl">✓</span>
|
||||
<div>
|
||||
<p className="text-green-400 font-bold text-sm uppercase tracking-widest">Windows Native Support</p>
|
||||
<p className="text-white/60 text-xs mt-0.5">Emerald Legacy runs natively on Windows without additional requirements.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div
|
||||
className="p-5 flex flex-col gap-2"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<p className="text-white/70 text-xs tracking-widest text-center uppercase mb-2">
|
||||
Choose your preferred launcher settings
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => { playPressSound(); setEnableVfx(!enableVfx); }}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
className={`w-full h-10 flex items-center justify-between px-4 transition-all outline-none border-none rounded
|
||||
${focusIndex === 0 ? "bg-black/10" : "bg-transparent"} hover:bg-black/15`}
|
||||
>
|
||||
<span className={`tracking-widest uppercase text-lg mc-text-shadow ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white/80"}`}>
|
||||
Click effects
|
||||
</span>
|
||||
<img
|
||||
src={enableVfx ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt={enableVfx ? "ON" : "OFF"}
|
||||
className="w-12 h-6 object-contain shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setEnableDiscordRPC(!enableDiscordRPC); }}
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
className={`w-full h-10 flex items-center justify-between px-4 transition-all outline-none border-none rounded
|
||||
${focusIndex === 1 ? "bg-black/10" : "bg-transparent"} hover:bg-black/15`}
|
||||
>
|
||||
<span className={`tracking-widest uppercase text-lg mc-text-shadow ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white/80"}`}>
|
||||
Discord RPC
|
||||
</span>
|
||||
<img
|
||||
src={enableDiscordRPC ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt={enableDiscordRPC ? "ON" : "OFF"}
|
||||
className="w-12 h-6 object-contain shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-white/40 text-center uppercase tracking-widest mt-2">You can change these later in settings</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div
|
||||
className="p-5 flex flex-col gap-3"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<p className="text-white/70 text-xs tracking-widest text-center uppercase">
|
||||
Emerald Launcher is now configured and ready to use!
|
||||
</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-white/60 text-sm uppercase tracking-widest">Username</span>
|
||||
<span className="text-[#FFFF55] font-bold mc-text-shadow">{tempUsername}</span>
|
||||
</div>
|
||||
{isMac && (
|
||||
<div className="flex items-center justify-between px-4 h-10 border-b border-white/10">
|
||||
<span className="text-white/60 text-sm uppercase tracking-widest">Runtime</span>
|
||||
<span className="text-green-400 font-bold">Ready</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs mt-4 text-white/60">You can change this later in settings</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && !isMac && !isLinux && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
|
||||
Windows Setup
|
||||
</h2>
|
||||
<p className="text-lg mb-6 text-white/80">Everything is ready to go!</p>
|
||||
<div className="text-green-400 font-bold">✓ Native compatibility</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-green-600/20 border-2 border-green-400 rounded-lg">
|
||||
<p className="text-green-400 font-bold mb-2">✓ Windows Native Support</p>
|
||||
<p className="text-xs text-white/80">Emerald Legacy runs natively on Windows without additional requirements.</p>
|
||||
{isLinux && selectedRunner && (
|
||||
<div className="flex items-center justify-between px-4 h-10 border-b border-white/10">
|
||||
<span className="text-white/60 text-sm uppercase tracking-widest">Runner</span>
|
||||
<span className="text-green-400 font-bold">{runners.find(r => r.id === selectedRunner)?.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between px-4 h-10 border-b border-white/10">
|
||||
<span className="text-white/60 text-sm uppercase tracking-widest">Click Effects</span>
|
||||
<img
|
||||
src={enableVfx ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt={enableVfx ? "ON" : "OFF"}
|
||||
className="w-10 h-5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 h-10">
|
||||
<span className="text-white/60 text-sm uppercase tracking-widest">Discord RPC</span>
|
||||
<img
|
||||
src={enableDiscordRPC ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt={enableDiscordRPC ? "ON" : "OFF"}
|
||||
className="w-10 h-5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
|
||||
Customize Your Experience
|
||||
</h2>
|
||||
<p className="text-lg mb-8 text-white/80">Choose your preferred launcher settings</p>
|
||||
|
||||
<div className="space-y-4 max-w-md mx-auto">
|
||||
<div className="bg-black/50 border-2 border-white/20 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-left">
|
||||
<p className="text-white font-bold">Click effects</p>
|
||||
<p className="text-xs text-white/60">Click particles and animations</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setEnableVfx(!enableVfx);
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 0 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
<img
|
||||
src={enableVfx ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt="Toggle"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/50 border-2 border-white/20 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-left">
|
||||
<p className="text-white font-bold">Discord Rich Presence</p>
|
||||
<p className="text-xs text-white/60">Show your Emerald Launcher status on Discord</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setEnableDiscordRPC(!enableDiscordRPC);
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 1 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
<img
|
||||
src={enableDiscordRPC ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt="Toggle"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-6 text-white/60">You can change these later in settings</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
|
||||
Setup Complete!
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="text-left bg-black/50 border-2 border-white/20 p-4">
|
||||
<p className="text-white">Username: <span className="font-bold text-green-400">{tempUsername}</span></p>
|
||||
{isMac && (
|
||||
<p className="text-white">
|
||||
Runtime: <span className="font-bold text-green-400">Ready</span>
|
||||
</p>
|
||||
)}
|
||||
{isLinux && selectedRunner && (
|
||||
<p className="text-white">
|
||||
Runner: <span className="font-bold text-green-400">{runners.find(r => r.id === selectedRunner)?.name}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-white/20">
|
||||
<p className="text-white text-sm">Customization:</p>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{enableVfx && <span className="text-xs bg-green-600/30 px-2 py-1 border border-green-400">Visual Effects</span>}
|
||||
{enableDiscordRPC && <span className="text-xs bg-green-600/30 px-2 py-1 border border-green-400">Discord RPC</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-white/80">Emerald Launcher is now configured and ready to use!</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
{currentStep > 0 && (
|
||||
<div className="flex justify-between mt-5 gap-3">
|
||||
{currentStep > 0 ? (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
onMouseEnter={() => {
|
||||
if (currentStep === 0) setFocusIndex(1);
|
||||
if (currentStep === 3) setFocusIndex(0);
|
||||
else if (currentStep === 2) setFocusIndex(2);
|
||||
else if (currentStep === 1) setFocusIndex(isLinux ? runners.length : (isMac ? 1 : 0));
|
||||
else if (currentStep === 2) setFocusIndex(4);
|
||||
else if (currentStep === 3) setFocusIndex(0);
|
||||
}}
|
||||
disabled={currentStep === 0}
|
||||
className={`mc-setup-nav-btn px-6 py-3 text-white font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_10px_rgba(250,204,21,0.3)] ${(currentStep === 1 && ((isLinux && focusIndex === runners.length) || (isMac && focusIndex === 1) || (!isLinux && !isMac && focusIndex === 0))) ||
|
||||
(currentStep === 2 && focusIndex === 4) ||
|
||||
(currentStep === 3 && focusIndex === 0)
|
||||
? "border-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.3)]" : ""
|
||||
}`}
|
||||
className={`w-36 h-10 flex items-center justify-center transition-colors mc-text-shadow outline-none border-none
|
||||
${(currentStep === 3 && focusIndex === 0) || (currentStep === 2 && focusIndex === 2) ||
|
||||
(currentStep === 1 && ((isLinux && focusIndex === runners.length) || (isMac && focusIndex === 1) || (!isLinux && !isMac && focusIndex === 0)))
|
||||
? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={navBtnStyle(
|
||||
(currentStep === 3 && focusIndex === 0) ||
|
||||
(currentStep === 2 && focusIndex === 2) ||
|
||||
(currentStep === 1 && ((isLinux && focusIndex === runners.length) || (isMac && focusIndex === 1) || (!isLinux && !isMac && focusIndex === 0)))
|
||||
)}
|
||||
>
|
||||
Back
|
||||
<span className="tracking-widest uppercase text-xl">Back</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-36" />
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -541,18 +536,27 @@ const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
|
||||
onMouseEnter={() => {
|
||||
if (currentStep === 0) setFocusIndex(1);
|
||||
else if (currentStep === 1) setFocusIndex(isLinux ? runners.length + 1 : (isMac ? 2 : 1));
|
||||
else if (currentStep === 2) setFocusIndex(5);
|
||||
else if (currentStep === 2) setFocusIndex(3);
|
||||
else if (currentStep === 3) setFocusIndex(1);
|
||||
}}
|
||||
disabled={!canProceed()}
|
||||
className={`${currentStep > 0 ? '' : 'ml-auto'} mc-setup-nav-btn px-6 py-3 text-white font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_10px_rgba(250,204,21,0.3)] ${(currentStep === 0 && focusIndex === 1) ||
|
||||
className={`w-36 h-10 flex items-center justify-center transition-colors mc-text-shadow outline-none border-none
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${(currentStep === 0 && focusIndex === 1) ||
|
||||
(currentStep === 1 && ((isLinux && focusIndex === runners.length + 1) || (isMac && focusIndex === 2) || (!isLinux && !isMac && focusIndex === 1))) ||
|
||||
(currentStep === 2 && focusIndex === 3) ||
|
||||
(currentStep === 3 && focusIndex === 1)
|
||||
? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={navBtnStyle(
|
||||
(currentStep === 0 && focusIndex === 1) ||
|
||||
(currentStep === 1 && ((isLinux && focusIndex === runners.length + 1) || (isMac && focusIndex === 2) || (!isLinux && !isMac && focusIndex === 1))) ||
|
||||
(currentStep === 2 && focusIndex === 5) ||
|
||||
(currentStep === 2 && focusIndex === 3) ||
|
||||
(currentStep === 3 && focusIndex === 1)
|
||||
? "border-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.3)]" : ""
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{currentStep === totalSteps - 1 ? "Finish" : "Next"}
|
||||
<span className="tracking-widest uppercase text-xl">
|
||||
{currentStep === totalSteps - 1 ? "Finish" : "Next"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
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 { animationsEnabled } = useConfig();
|
||||
const { playBackSound, playPressSound } = useAudio();
|
||||
const { setActiveView } = useUI();
|
||||
const [swfData, setSwfData] = useState<{ version: number, compressed: boolean, frameHeader: Uint8Array, tags: SwfTag[] } | null>(null);
|
||||
const [images, setImages] = useState<SwfImage[]>([]);
|
||||
const [selectedImageId, setSelectedImageId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [imageUrls, setImageUrls] = useState<Record<number, string>>({});
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const filteredImages = useMemo(() => {
|
||||
return images.filter(img =>
|
||||
img.id.toString().includes(searchTerm) ||
|
||||
(img.name && img.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
}, [images, searchTerm]);
|
||||
|
||||
const selectedImage = useMemo(() => {
|
||||
return images.find(img => img.id === selectedImageId) || null;
|
||||
}, [images, selectedImageId]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
playBackSound();
|
||||
setActiveView("devtools");
|
||||
}, [playBackSound, setActiveView]);
|
||||
|
||||
const loadUrl = async (img: SwfImage) => {
|
||||
if (imageUrls[img.id]) return imageUrls[img.id];
|
||||
let url = "";
|
||||
if (img.type === "jpeg") {
|
||||
const blob = new Blob([img.data as any], { type: "image/jpeg" });
|
||||
url = URL.createObjectURL(blob);
|
||||
} else if (img.type === "lossless") {
|
||||
const rgba = await SwfService.decodeLosslessToRGBA(img);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width!;
|
||||
canvas.height = img.height!;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const imageData = new ImageData(new Uint8ClampedArray(rgba), img.width!, img.height!);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
url = canvas.toDataURL();
|
||||
}
|
||||
}
|
||||
if (url) {
|
||||
setImageUrls(prev => ({ ...prev, [img.id]: url }));
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedImage) {
|
||||
loadUrl(selectedImage);
|
||||
}
|
||||
}, [selectedImage]);
|
||||
|
||||
const processFile = async (file: File) => {
|
||||
setFileName(file.name);
|
||||
setImageUrls({});
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const swf = SwfService.parse(bytes);
|
||||
setSwfData(swf);
|
||||
const extracted = SwfService.extractImages(swf.tags);
|
||||
setImages(extracted);
|
||||
if (extracted.length > 0) {
|
||||
setSelectedImageId(extracted[0].id);
|
||||
}
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
showNotification("Failed to process SWF", "error");
|
||||
setImages([]);
|
||||
setSwfData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
processFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (img: SwfImage) => {
|
||||
playPressSound();
|
||||
let blob: Blob;
|
||||
let ext = "png";
|
||||
if (img.type === "jpeg") {
|
||||
blob = new Blob([img.data as any], { type: "image/jpeg" });
|
||||
ext = "jpg";
|
||||
} else if (img.type === "lossless") {
|
||||
const rgba = await SwfService.decodeLosslessToRGBA(img);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width!;
|
||||
canvas.height = img.height!;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const imageData = new ImageData(new Uint8ClampedArray(rgba), img.width!, img.height!);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const res = await fetch(dataUrl);
|
||||
blob = await res.blob();
|
||||
} else {
|
||||
blob = new Blob([rgba as any], { type: "application/octet-stream" });
|
||||
ext = "bin";
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([img.data as any], { type: "application/octet-stream" });
|
||||
ext = "bin";
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${img.name || `image_${img.id}`}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification(`Exported: ${img.name || img.id}`);
|
||||
};
|
||||
|
||||
const handleReplace = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!swfData || !selectedImageId || !e.target.files?.[0]) return;
|
||||
const file = e.target.files[0];
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const newData = new Uint8Array(buffer);
|
||||
const newTags = SwfService.updateImageTag(
|
||||
swfData.tags,
|
||||
selectedImageId,
|
||||
newData,
|
||||
selectedImage?.type || "unknown"
|
||||
);
|
||||
|
||||
const newSwfData = { ...swfData, tags: newTags };
|
||||
setSwfData(newSwfData);
|
||||
const extracted = SwfService.extractImages(newTags);
|
||||
setImages(extracted);
|
||||
setImageUrls(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedImageId];
|
||||
return next;
|
||||
});
|
||||
|
||||
showNotification("Image Replaced", "success");
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleSaveSwf = () => {
|
||||
if (!swfData) return;
|
||||
playPressSound();
|
||||
const result = SwfService.serialize(swfData.version, swfData.compressed, swfData.frameHeader, swfData.tags);
|
||||
const blob = new Blob([result as any], { type: "application/x-shockwave-flash" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName || "output.swf";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("SWF Saved Successfully");
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-6xl h-[85vh] outline-none"
|
||||
>
|
||||
<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
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open SWF
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveSwf}
|
||||
disabled={!swfData}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} className="hidden" accept=".swf" />
|
||||
<input type="file" ref={replaceInputRef} onChange={handleReplace} className="hidden" />
|
||||
|
||||
{!swfData ? (
|
||||
<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>
|
||||
</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">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar space-y-1">
|
||||
{filteredImages.map(img => (
|
||||
<div
|
||||
key={img.id}
|
||||
onClick={() => { playPressSound(); setSelectedImageId(img.id); }}
|
||||
className={`flex items-center gap-3 p-3 cursor-pointer transition-all border-l-4 ${selectedImageId === img.id
|
||||
? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]"
|
||||
: "border-transparent hover:bg-white/5 text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<img src="/images/tools/arc.png" className={`w-5 h-5 object-contain ${selectedImageId === img.id ? "" : "grayscale"}`} style={{ imageRendering: "pixelated" }} />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-bold truncate">ID: {img.id} {img.name ? `- ${img.name}` : ""}</span>
|
||||
<span className="text-[10px] uppercase opacity-60">{img.type} {img.width ? `(${img.width}x${img.height})` : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-2/3 flex flex-col p-6 overflow-y-auto" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={selectedImage.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-6 border-b border-[#373737] pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-[#FFFF55] text-2xl mc-text-shadow">
|
||||
{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>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-black/40 border-2 border-[#373737] mb-6 flex items-center justify-center overflow-hidden relative group pattern-checkerboard">
|
||||
{imageUrls[selectedImage.id] ? (
|
||||
<img
|
||||
src={imageUrls[selectedImage.id]}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
alt={`Bitmap ${selectedImage.id}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-white/20 italic">Loading Preview...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-auto">
|
||||
<button
|
||||
onClick={() => handleDownload(selectedImage)}
|
||||
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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-6 outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{notification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 50, scale: 0.9 }}
|
||||
className="fixed top-12 right-12 z-[110] p-6 flex flex-col items-center justify-center min-w-[240px]"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
|
||||
>
|
||||
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
|
||||
{notification.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<style>{`
|
||||
.pattern-checkerboard {
|
||||
background-color: #2a2a2a;
|
||||
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;
|
||||
}
|
||||
`}</style>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import { useState, useEffect, useRef, memo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import CustomTUModal from "../modals/CustomTUModal";
|
||||
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
|
||||
|
||||
import SetUidModal from "../modals/SetUidModal";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
import { ScreenshotImage } from "../common/ScreenshotImage";
|
||||
interface DeleteConfirmButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -40,25 +46,48 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
|
||||
|
||||
const VersionsView = memo(function VersionsView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled } = useConfig();
|
||||
const {
|
||||
profile: selectedProfile,
|
||||
setProfile: setSelectedProfile,
|
||||
animationsEnabled,
|
||||
} = useConfig();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { editions, installs: installedVersions, toggleInstall, handleUninstall, handleCancelDownload, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingId, downloadProgress } = useGame();
|
||||
|
||||
const {
|
||||
editions,
|
||||
installs: installedVersions,
|
||||
toggleInstall,
|
||||
handleUninstall,
|
||||
handleCancelDownload,
|
||||
deleteCustomEdition: onDeleteEdition,
|
||||
addCustomEdition: onAddEdition,
|
||||
updateCustomEdition: onUpdateEdition,
|
||||
downloadingId,
|
||||
downloadProgress,
|
||||
updatesAvailable,
|
||||
addToSteam,
|
||||
cycleBranch,
|
||||
} = useGame();
|
||||
const { isDayTime } = useConfig();
|
||||
const [focusIndex, setFocusIndex] = useState<number>(0);
|
||||
const [focusBtn, setFocusBtn] = useState<number>(0);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isSetUidModalOpen, setIsSetUidModalOpen] = useState(false);
|
||||
const [setUidTargetId, setSetUidTargetId] = useState("");
|
||||
const [editingEdition, setEditingEdition] = useState<any>(null);
|
||||
const [hoveredBtn, setHoveredBtn] = useState<{row: number, btn: string} | null>(null);
|
||||
const [initialPath, setInitialPath] = useState<string>("");
|
||||
const [hoveredBtn, setHoveredBtn] = useState<{
|
||||
row: number;
|
||||
btn: string;
|
||||
} | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [deleteConfirmEdition, setDeleteConfirmEdition] = useState<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const ITEM_COUNT = editions.length + 2; // +1 for "+" button, +1 for Done button
|
||||
|
||||
const ITEM_COUNT = editions.length + 3;
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.activeElement?.tagName === "INPUT") return;
|
||||
|
||||
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
@@ -79,7 +108,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
const edition = editions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1;
|
||||
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
|
||||
setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1));
|
||||
}
|
||||
} else if (e.key === "ArrowRight") {
|
||||
@@ -88,57 +117,37 @@ const VersionsView = memo(function VersionsView() {
|
||||
const edition = editions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1;
|
||||
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
|
||||
setFocusBtn((prev) => (prev >= maxBtn ? 0 : prev + 1));
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex < editions.length) {
|
||||
const edition = editions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const isDownloading = downloadingId === edition.id;
|
||||
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
const isDownloading = downloadingId === edition.instanceId;
|
||||
if (focusBtn === 0) {
|
||||
if (isInstalled) {
|
||||
playPressSound();
|
||||
setSelectedProfile(edition.id);
|
||||
}
|
||||
} else if (isInstalled) {
|
||||
if (focusBtn === 1) {
|
||||
if (!downloadingId) {
|
||||
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
|
||||
} else {
|
||||
if (!isDownloading && !downloadingId) {
|
||||
playPressSound();
|
||||
toggleInstall(edition.id);
|
||||
}
|
||||
} else if (focusBtn === 2) {
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.id);
|
||||
} else if (focusBtn === 3) {
|
||||
if (isCustom) {
|
||||
playBackSound();
|
||||
onDeleteEdition(edition.id);
|
||||
} else {
|
||||
playPressSound();
|
||||
setDeleteConfirmEdition(edition);
|
||||
}
|
||||
} else if (focusBtn === 4 && isCustom) {
|
||||
playPressSound();
|
||||
setEditingEdition(edition);
|
||||
setIsImportModalOpen(true);
|
||||
}
|
||||
} else {
|
||||
if (focusBtn === 1) {
|
||||
if (isDownloading) {
|
||||
toggleInstall(edition.instanceId);
|
||||
} else if (isDownloading) {
|
||||
handleCancelDownload();
|
||||
} else if (!downloadingId) {
|
||||
playPressSound();
|
||||
toggleInstall(edition.id);
|
||||
}
|
||||
}
|
||||
} else if (focusBtn === 2) {
|
||||
playPressSound();
|
||||
cycleBranch(edition.id);
|
||||
}
|
||||
} else if (focusIndex === editions.length) {
|
||||
playPressSound();
|
||||
setIsImportModalOpen(true);
|
||||
} else if (focusIndex === editions.length + 1) {
|
||||
playPressSound();
|
||||
handleImportFolder();
|
||||
} else {
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
@@ -161,11 +170,15 @@ const VersionsView = memo(function VersionsView() {
|
||||
setActiveView,
|
||||
toggleInstall,
|
||||
handleCancelDownload,
|
||||
addToSteam,
|
||||
isDayTime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIndex < editions.length && listRef.current) {
|
||||
const el = listRef.current.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement;
|
||||
const el = listRef.current.querySelector(
|
||||
`[data-index="${focusIndex}"]`,
|
||||
) as HTMLElement;
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
@@ -173,15 +186,26 @@ const VersionsView = memo(function VersionsView() {
|
||||
}, [focusIndex]);
|
||||
|
||||
const handleEditionClick = (edition: any, index: number) => {
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
if (isInstalled) {
|
||||
playPressSound();
|
||||
setSelectedProfile(edition.id);
|
||||
setSelectedProfile(edition.instanceId);
|
||||
}
|
||||
setFocusIndex(index);
|
||||
};
|
||||
|
||||
const handleImportFolder = async () => {
|
||||
try {
|
||||
const folder = await TauriService.pickFolder();
|
||||
if (folder) {
|
||||
setInitialPath(folder);
|
||||
setIsImportModalOpen(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e !== "CANCELED") console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
@@ -189,13 +213,13 @@ const VersionsView = memo(function VersionsView() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: animationsEnabled ? 0.25 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-2xl outline-none"
|
||||
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
|
||||
<div
|
||||
className="w-full min-w-[480px] p-6 mb-4"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
@@ -210,12 +234,15 @@ const VersionsView = memo(function VersionsView() {
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{editions.map((edition: any, i: number) => {
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isInstalled = installedVersions.includes(
|
||||
edition.instanceId,
|
||||
);
|
||||
const hasAnyInstall = installedVersions.length > 0;
|
||||
const isSelected = hasAnyInstall && selectedProfile === edition.id;
|
||||
const isSelected =
|
||||
hasAnyInstall && selectedProfile === edition.instanceId;
|
||||
const isFocused = focusIndex === i;
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const isDownloading = downloadingId === edition.id;
|
||||
const isDownloading = downloadingId === edition.instanceId;
|
||||
const isComingSoon = edition.comingSoon;
|
||||
|
||||
return (
|
||||
@@ -226,7 +253,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
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"}`}
|
||||
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
|
||||
>
|
||||
<div className="w-6 flex items-center justify-center flex-shrink-0">
|
||||
@@ -258,22 +285,34 @@ const VersionsView = memo(function VersionsView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => !isComingSoon && handleEditionClick(edition, i)}
|
||||
disabled={isComingSoon}
|
||||
className={`flex-1 text-left min-w-0 outline-none rounded ${
|
||||
focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : ""
|
||||
<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" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{edition.logo && (
|
||||
<img
|
||||
src={edition.logo}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain flex-shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
{edition.logo &&
|
||||
(edition.logo.startsWith("http") ||
|
||||
edition.logo.startsWith("/images") ? (
|
||||
<img
|
||||
src={edition.logo}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain flex-shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenshotImage
|
||||
path={edition.logo}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain flex-shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
className={`text-xl tracking-wide truncate ${
|
||||
isSelected ? "text-white" : "text-black"
|
||||
@@ -282,248 +321,353 @@ const VersionsView = memo(function VersionsView() {
|
||||
>
|
||||
{edition.name}
|
||||
</span>
|
||||
{isCustom && (
|
||||
{edition.category &&
|
||||
edition.category.map((cat: string) => (
|
||||
<span
|
||||
key={cat}
|
||||
className="text-[9px] px-1.5 py-0.5 bg-[#444] text-[#aaa] font-bold uppercase border border-[#555] mc-text-shadow"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
{isCustom && !edition.category && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-[#777] text-[#222] font-bold uppercase">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-base font-medium leading-tight ${
|
||||
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
|
||||
}`}>
|
||||
<p
|
||||
className={`text-base font-medium leading-tight ${
|
||||
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
|
||||
}`}
|
||||
>
|
||||
{edition.desc}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 relative">
|
||||
{!isInstalled ? (
|
||||
isDownloading ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isDownloading && !downloadingId) {
|
||||
toggleInstall(edition.instanceId);
|
||||
} else if (isDownloading) {
|
||||
handleCancelDownload();
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'cancel') || (focusIndex === i && focusBtn === 1)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "main" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-9 h-9 flex items-center justify-center ${
|
||||
isDownloading || (!!downloadingId && !isInstalled)
|
||||
? "opacity-50"
|
||||
: ""
|
||||
}`}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
) : edition.comingSoon ? (
|
||||
<div className="w-8 h-8" />
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!downloadingId) toggleInstall(edition.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'download'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
isDownloading
|
||||
? "/images/Trash_Bin_Icon.png"
|
||||
: "/images/Download_Icon.png"
|
||||
}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'download') || (focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: downloadingId ? 0.5 : 1,
|
||||
filter: isDownloading
|
||||
? "hue-rotate(300deg)"
|
||||
: "none",
|
||||
}}
|
||||
disabled={!!downloadingId}
|
||||
>
|
||||
<img
|
||||
src="/images/Download_Icon.png"
|
||||
alt="Download"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{isDownloading ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setOpenMenuId(
|
||||
openMenuId === edition.id ? null : edition.id,
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
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"
|
||||
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"}`}
|
||||
/>
|
||||
</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"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{updatesAvailable?.[edition.instanceId] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelDownload();
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'cancel') || (focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
playPressSound();
|
||||
toggleInstall(edition.instanceId);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
<img
|
||||
src="/images/Download_Icon.png"
|
||||
alt=""
|
||||
className="w-3 h-3 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Update Available!
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!downloadingId) toggleInstall(edition.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'update'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'update') || (focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: downloadingId ? 0.5 : 1,
|
||||
}}
|
||||
disabled={!!downloadingId}
|
||||
>
|
||||
<img
|
||||
src="/images/Update_Icon.png"
|
||||
alt="Update"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{Array.isArray(edition.branches) &&
|
||||
edition.branches.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.id);
|
||||
cycleBranch(edition.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'folder'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'folder') || (focusIndex === i && focusBtn === 2)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow hover:bg-white/10 flex items-center justify-between group transition-colors"
|
||||
>
|
||||
<span className="text-[#AAAAAA] group-hover:text-white font-bold">
|
||||
Channel
|
||||
</span>
|
||||
<span className="text-[#ffff55] font-bold">
|
||||
{edition.selectedBranch ?? "Latest"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="h-[1px] bg-white/5 my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.instanceId);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<img
|
||||
src="/images/Folder_Icon.png"
|
||||
alt="Folder"
|
||||
className="w-6 h-6 object-contain"
|
||||
alt=""
|
||||
className="w-3.5 h-3.5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Open Folder
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
const PANORAMA_PROFILES = [
|
||||
"legacy_evolved",
|
||||
"vanilla_tu19",
|
||||
"360revived",
|
||||
"vanilla_tu24",
|
||||
];
|
||||
const panoId = PANORAMA_PROFILES.includes(
|
||||
edition.id,
|
||||
)
|
||||
? edition.id
|
||||
: "vanilla_tu19";
|
||||
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`;
|
||||
addToSteam(
|
||||
edition.instanceId,
|
||||
edition.name,
|
||||
edition.titleImage,
|
||||
panoramaUrl,
|
||||
);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<img
|
||||
src="/images/steam.png"
|
||||
alt=""
|
||||
className="w-3.5 h-3.5 object-contain invert brightness-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Add to Steam
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setSetUidTargetId(edition.instanceId);
|
||||
setIsSetUidModalOpen(true);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<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>
|
||||
{isCustom ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setEditingEdition(edition);
|
||||
setIsImportModalOpen(true);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#aaaaaa] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
) : null}
|
||||
<div className="h-[2px] bg-[#555] my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playBackSound();
|
||||
setDeleteConfirmEdition(edition);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'delete'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
if (isCustom) {
|
||||
onDeleteEdition(edition.id);
|
||||
} else {
|
||||
setDeleteConfirmEdition(edition);
|
||||
}
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors mc-text-shadow font-bold"
|
||||
>
|
||||
<img
|
||||
src="/images/Trash_Bin_Icon.png"
|
||||
alt="Delete"
|
||||
className="w-6 h-6 object-contain"
|
||||
alt=""
|
||||
className="w-3.5 h-3.5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{isCustom ? "Remove Custom" : "Uninstall"}
|
||||
</button>
|
||||
{isCustom && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setEditingEdition(edition);
|
||||
setIsImportModalOpen(true);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'edit'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'edit') || (focusIndex === i && focusBtn === 4)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playBackSound();
|
||||
onDeleteEdition(edition.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: i, btn: 'delete'})}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="w-full flex items-center justify-center p-2 mt-1">
|
||||
<div className="w-full flex items-center justify-center gap-4 p-2 mt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setInitialPath("");
|
||||
setIsImportModalOpen(true);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({row: editions.length, btn: 'add'})}
|
||||
onMouseEnter={() => setFocusIndex(editions.length)}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === editions.length && hoveredBtn?.btn === 'add') || focusIndex === editions.length
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === editions.length &&
|
||||
hoveredBtn?.btn === "add") ||
|
||||
focusIndex === editions.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
|
||||
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(editions.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 === editions.length &&
|
||||
hoveredBtn?.btn === "folder_import") ||
|
||||
focusIndex === editions.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>
|
||||
</div>
|
||||
@@ -531,17 +675,18 @@ const VersionsView = memo(function VersionsView() {
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
data-index={editions.length + 1}
|
||||
onMouseEnter={() => setFocusIndex(editions.length + 1)}
|
||||
data-index={editions.length + 2}
|
||||
onMouseEnter={() => setFocusIndex(editions.length + 2)}
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
}}
|
||||
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white"
|
||||
style={{
|
||||
backgroundImage: focusIndex === editions.length + 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundImage:
|
||||
focusIndex === editions.length + 2
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
@@ -555,6 +700,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
onClose={() => {
|
||||
setIsImportModalOpen(false);
|
||||
setEditingEdition(null);
|
||||
setInitialPath("");
|
||||
}}
|
||||
onImport={(ed: any) => {
|
||||
if (editingEdition) {
|
||||
@@ -567,6 +713,17 @@ const VersionsView = memo(function VersionsView() {
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
editingEdition={editingEdition}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
|
||||
<SetUidModal
|
||||
isOpen={isSetUidModalOpen}
|
||||
onClose={() => setIsSetUidModalOpen(false)}
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
instances={editions}
|
||||
installedVersions={installedVersions}
|
||||
targetInstanceId={setUidTargetId}
|
||||
/>
|
||||
|
||||
{deleteConfirmEdition && (
|
||||
@@ -584,7 +741,8 @@ const VersionsView = memo(function VersionsView() {
|
||||
Delete {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!
|
||||
Warning: All your saves and worlds for this version will be
|
||||
permanently deleted!
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<DeleteConfirmButton
|
||||
|
||||
@@ -26,15 +26,14 @@ interface UIContextType {
|
||||
onNavigateToMenu: () => void;
|
||||
connected: boolean;
|
||||
updateMessage: string | null;
|
||||
updateUrl: string | null;
|
||||
clearUpdateMessage: () => void;
|
||||
}
|
||||
const UIContext = createContext<UIContextType | undefined>(undefined);
|
||||
|
||||
export const ConfigContext = createContext<ReturnType<typeof useAppConfig> | undefined>(undefined);
|
||||
export const AudioContext = createContext<ReturnType<typeof useAudioController> | undefined>(undefined);
|
||||
export const GameContext = createContext<ReturnType<typeof useGameManager> | undefined>(undefined);
|
||||
export const SkinContext = createContext<ReturnType<typeof useSkinSync> | undefined>(undefined);
|
||||
|
||||
export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showIntro, setShowIntro] = useState(true);
|
||||
const [logoAnimDone, setLogoAnimDone] = useState(false);
|
||||
@@ -44,7 +43,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showCredits, setShowCredits] = useState(false);
|
||||
const [focusSection, setFocusSection] = useState<"menu" | "skin">("menu");
|
||||
|
||||
const { updateMessage, clearUpdateMessage } = useUpdateCheck();
|
||||
const { updateMessage, updateUrl, clearUpdateMessage } = useUpdateCheck();
|
||||
|
||||
const configRaw = useAppConfig();
|
||||
const gameRaw = useGameManager({
|
||||
@@ -53,7 +52,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
customEditions: configRaw.customEditions,
|
||||
setCustomEditions: configRaw.setCustomEditions,
|
||||
});
|
||||
const skinSync = useSkinSync({ profile: configRaw.profile, editions: gameRaw.editions });
|
||||
const skinSync = useSkinSync({ username: configRaw.username, profile: configRaw.profile, editions: gameRaw.editions });
|
||||
const audioRaw = useAudioController({
|
||||
musicVol: configRaw.musicVol,
|
||||
sfxVol: configRaw.sfxVol,
|
||||
@@ -66,14 +65,18 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
|
||||
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
|
||||
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
|
||||
configRaw.legacyMode, configRaw.animationsEnabled
|
||||
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled
|
||||
]);
|
||||
|
||||
const game = useMemo(() => gameRaw, [
|
||||
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
|
||||
gameRaw.downloadingId, gameRaw.editions, gameRaw.isRunnerDownloading,
|
||||
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
|
||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, configRaw.profile
|
||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
|
||||
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
|
||||
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
|
||||
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
|
||||
gameRaw.deleteCustomEdition, gameRaw.downloadRunner
|
||||
]);
|
||||
|
||||
const audio = useMemo(() => audioRaw, [
|
||||
@@ -126,13 +129,15 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
musicVol: config.musicVol,
|
||||
sfxVol: config.sfxVol,
|
||||
legacyMode: config.legacyMode,
|
||||
mangohudEnabled: config.mangohudEnabled,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [
|
||||
config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
|
||||
config.perfBoost, config.customEditions, config.profile,
|
||||
config.vfxEnabled, config.animationsEnabled,
|
||||
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, config.isLoaded
|
||||
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
|
||||
config.mangohudEnabled, config.isLoaded
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,8 +185,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
isWindowVisible,
|
||||
showCredits, setShowCredits, focusSection, setFocusSection,
|
||||
onNavigateToSkin, onNavigateToMenu, connected,
|
||||
updateMessage, clearUpdateMessage
|
||||
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, clearUpdateMessage]);
|
||||
updateMessage, updateUrl, clearUpdateMessage
|
||||
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]);
|
||||
|
||||
return (
|
||||
<UIContext.Provider value={uiValue}>
|
||||
|
||||
@@ -1,132 +1,312 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: 'Mojangles';
|
||||
src: url('/fonts/Mojangles.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.gamepad-connected-indicator {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
color: #FFFF55;
|
||||
font-size: 10px;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn {
|
||||
border: 4px solid transparent;
|
||||
border-image: url('/images/Button_Background.png') 4 stretch;
|
||||
image-rendering: pixelated;
|
||||
background: rgba(0,0,0,0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #000;
|
||||
font-family: 'Mojangles', monospace;
|
||||
-webkit-font-smoothing: none;
|
||||
}
|
||||
|
||||
.mc-button {
|
||||
background-image: url('/images/button.png');
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mc-button:hover:not(:disabled) {
|
||||
background-image: url('/images/button_highlighted.png');
|
||||
}
|
||||
|
||||
.mc-text-shadow {
|
||||
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
|
||||
}
|
||||
|
||||
.mc-text-shadow-hover {
|
||||
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
|
||||
}
|
||||
|
||||
|
||||
@keyframes sga-burst {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY))) scale(1.2) rotate(var(--rot));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.particle-burst {
|
||||
animation: sga-burst 0.8s ease-out forwards;
|
||||
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: url('/images/SliderHandlerBackground.png') center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: url('/images/Slider_Handle.png') no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: url('/images/SliderHandlerBackground.png') center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: url('/images/Slider_Handle.png') no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.no-animations * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "Mojangles";
|
||||
src: url("/fonts/Mojangles.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.gamepad-connected-indicator {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
color: #ffff55;
|
||||
font-size: 10px;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn {
|
||||
border: 4px solid transparent;
|
||||
border-image: url("/images/Button_Background.png") 4 stretch;
|
||||
image-rendering: pixelated;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mc-options-bg {
|
||||
border-image: url("/images/options_background.png") 4 fill;
|
||||
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;
|
||||
border-bottom: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
box-shadow:
|
||||
inset 2px 2px 0 #e0e0e0,
|
||||
inset -2px 0 0 #808080,
|
||||
0 -2px 0 #606060;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
transform: translateY(2px);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.mc-lce-tab.active {
|
||||
background: linear-gradient(180deg, #d0d0d0 0%, #b0b0b0 100%);
|
||||
transform: translateY(0);
|
||||
z-index: 10;
|
||||
box-shadow:
|
||||
inset 2px 2px 0 #f0f0f0,
|
||||
inset -2px 0 0 #909090,
|
||||
0 -3px 0 #707070;
|
||||
}
|
||||
|
||||
.mc-lce-content {
|
||||
background: linear-gradient(180deg, #b0b0b0 0%, #909090 100%);
|
||||
border: 3px solid #808080;
|
||||
border-radius: 0 4px 4px 4px;
|
||||
box-shadow:
|
||||
inset 3px 3px 0 #d0d0d0,
|
||||
inset -3px -3px 0 #606060;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mc-lce-content::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
border: 2px solid #ffffff;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mc-lce-tab-bg {
|
||||
border: 4px solid transparent;
|
||||
border-image: url("/images/options_background.png") 4 fill;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
transform: translateY(4px);
|
||||
z-index: 1;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 40px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.mc-lce-tab-bg.active {
|
||||
transform: translateY(-4px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.mc-lce-content-bg {
|
||||
border-image: url("/images/options_background.png") 4 fill;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
* {
|
||||
cursor:
|
||||
url("/images/pointer.png") 16 16,
|
||||
auto;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #000;
|
||||
font-family: "Mojangles", monospace;
|
||||
-webkit-font-smoothing: none;
|
||||
}
|
||||
|
||||
.mc-button {
|
||||
background-image: url("/images/button.png");
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mc-button:hover:not(:disabled) {
|
||||
background-image: url("/images/button_highlighted.png");
|
||||
}
|
||||
|
||||
.mc-text-shadow {
|
||||
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
|
||||
}
|
||||
|
||||
.mc-text-shadow-hover {
|
||||
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
|
||||
}
|
||||
|
||||
@keyframes sga-burst {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY)))
|
||||
scale(1.2) rotate(var(--rot));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.particle-burst {
|
||||
animation: sga-burst 0.8s ease-out forwards;
|
||||
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: url("/images/SliderHandlerBackground.png") center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: url("/images/Slider_Handle.png") no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: url("/images/SliderHandlerBackground.png") center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: url("/images/Slider_Handle.png") no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.no-animations * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.workshop-markdown h1,
|
||||
.workshop-markdown h2,
|
||||
.workshop-markdown h3 {
|
||||
color: #ffff55;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workshop-markdown strong {
|
||||
color: #ffff55;
|
||||
}
|
||||
|
||||
.workshop-markdown ul,
|
||||
.workshop-markdown ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.workshop-markdown li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.workshop-markdown a {
|
||||
color: #55ffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workshop-markdown code {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ff55ff;
|
||||
}
|
||||
|
||||
.workshop-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid #555;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.workshop-markdown th {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #ffff55;
|
||||
padding: 8px;
|
||||
border: 1px solid #444;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.workshop-markdown td {
|
||||
padding: 8px;
|
||||
border: 1px solid #333;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.workshop-markdown tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.workshop-markdown blockquote {
|
||||
border-left: 4px solid #555555;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #a0a0a0;
|
||||
font-style: italic;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.workshop-markdown del {
|
||||
color: #aa0000;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.workshop-markdown input[type="checkbox"] {
|
||||
accent-color: #ffff55;
|
||||
margin-right: 0.5rem;
|
||||
transform: scale(1.2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.workshop-markdown hr {
|
||||
border: none;
|
||||
border-top: 2px solid #373737;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocalStorage } from "./useLocalStorage";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
|
||||
export function useAppConfig() {
|
||||
const [username, setUsername] = useLocalStorage("lce-username", "Steve");
|
||||
const [theme, setTheme] = useLocalStorage("lce-theme", "Modern");
|
||||
@@ -15,12 +14,11 @@ export function useAppConfig() {
|
||||
const [profile, setProfile] = useLocalStorage("lce-profile", "legacy_evolved");
|
||||
const [legacyMode, setLegacyMode] = useLocalStorage("lce-legacy-mode", false);
|
||||
const [hasCompletedSetup, setHasCompletedSetup] = useLocalStorage("lce-setup-completed", false);
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [linuxRunner, setLinuxRunner] = useState<string | undefined>();
|
||||
const [perfBoost, setPerfBoost] = useState(false);
|
||||
const [customEditions, setCustomEditions] = useState<any[]>([]);
|
||||
|
||||
const [mangohudEnabled, setMangohudEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
TauriService.loadConfig().then((config) => {
|
||||
if (config.username) setUsername(config.username);
|
||||
@@ -36,6 +34,7 @@ export function useAppConfig() {
|
||||
if (config.musicVol !== undefined && config.musicVol !== null) setMusicVol(config.musicVol);
|
||||
if (config.sfxVol !== undefined && config.sfxVol !== null) setSfxVol(config.sfxVol);
|
||||
if (config.legacyMode !== undefined) setLegacyMode(config.legacyMode);
|
||||
if (config.mangohudEnabled !== undefined) setMangohudEnabled(config.mangohudEnabled);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
@@ -55,9 +54,10 @@ export function useAppConfig() {
|
||||
musicVol,
|
||||
sfxVol,
|
||||
legacyMode,
|
||||
mangohudEnabled,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, isLoaded]);
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, isLoaded]);
|
||||
|
||||
return {
|
||||
username,
|
||||
@@ -91,5 +91,7 @@ export function useAppConfig() {
|
||||
isLoaded,
|
||||
hasCompletedSetup,
|
||||
setHasCompletedSetup,
|
||||
mangohudEnabled,
|
||||
setMangohudEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,50 +1,165 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
const TRACKS = [
|
||||
"/music/Blind Spots.ogg",
|
||||
"/music/Key.ogg",
|
||||
"/music/Living Mice.ogg",
|
||||
"/music/Oxygene.ogg",
|
||||
"/music/Subwoofer Lullaby.ogg",
|
||||
"music/Blind Spots.ogg",
|
||||
"music/Key.ogg",
|
||||
"music/Living Mice.ogg",
|
||||
"music/Oxygene.ogg",
|
||||
"music/Subwoofer Lullaby.ogg",
|
||||
];
|
||||
|
||||
const resolveAudioUrl = (path: string) => {
|
||||
const relativePath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relativePath, window.location.origin).href;
|
||||
};
|
||||
|
||||
const SPLASHES = [
|
||||
"Legacy is back!", "Pixelated goodness!", "Console Edition vibe!", "100% Not Microsoft!",
|
||||
"Symmetry is key!", "Does anyone even read these?", "Task failed successfully.",
|
||||
"Hardware accelerated!", "It's a feature, not a bug.", "Look behind you.",
|
||||
"Works on my machine.", "Now gluten-free!", "Mom, get the camera!", "Batteries not included.",
|
||||
"May contain nuts.", "Press Alt+F4 for diamonds!", "Downloading more RAM...",
|
||||
"Reinventing the wheel!", "The cake is a lie.", "Powered by copious amounts of coffee.",
|
||||
"I'm running out of ideas.", "That's no moon...", "Now with 100% more nostalgia!",
|
||||
"Legacy is the new modern.", "No microtransactions!", "As seen on TV!", "Ironic, isn't it?",
|
||||
"Creeper? Aww man.", "Technoblade never dies!", "is smartcmd dead ?", "NO BUILT IN MS AUTH !",
|
||||
"Mr_Anilex wasn't here!", "Who's Jack ?", "This text is blue!", "Bonjour!", "Hola!",
|
||||
"Salam!", "Hi!", "Reverse engineering WiiU version", "Try Terraria", "Don't try Valorant",
|
||||
"This could never be a sad place!", "Made without microslop", "Thank you C418", "Bread is pain",
|
||||
"From the star!", "Never gonna give you up!", "9+10=21", ".party() was successful", "Not Kogama",
|
||||
"You can be proud of you!", "Let's drink Orange Joe", "Kirater is a great singer!",
|
||||
"Mirkette My beloved", "Started in Bordeaux", "Oui Oui Baguette", "Milk In The Microwave",
|
||||
"8-3: DISINTEGRATION LOOP", "Turn the light OFF", "Not written by Mr_Anilex",
|
||||
"The One Who's Running the Show!", "Playing Forever", "The World looks cubic!", "huh?",
|
||||
"Sybau", "Available on Toaster", "Try ArchLinux", "67% Accurate", "A molecule of meow",
|
||||
"http://localhost:3000", "uuhhhh...", "Oyasumi", "XDDCC", "I don't want to set the world on fire",
|
||||
"Directed by Michael Bay", "We see you, Opal!", "A Cool Cat in Town", "pikmin",
|
||||
"Not BrainRotted!", "Farting is Natural -Leon", "93/100 on metacritic", "Not (anymore) on Steam",
|
||||
"Sudo apt install EmeraldLauncher", "Sudo pacman -S EmeraldLauncher", "Kay-Chan my beloved! <3",
|
||||
"Peak!", "OpenSource!", "made by human with bone and flesh", "Made with hate against microslop",
|
||||
"Steelorse :fire:", "It's Minecraft but i'm not sure", "Look at you!", "You're beautiful",
|
||||
"Mr_Anilex has a big ego", "Traduis-moi !", "May contains Mr_Anilex", "Neoapps didn't write this splash",
|
||||
"Where's Kinger ?", "KayJann, Breakcore and code", "Hey Goku!", "Vegeta is a DZ mashallah",
|
||||
"Bogos Binted? Vorp", "YOU SHALL NOT PASS !", "Bready, Steady, GO !", "Not-so-Empty-house",
|
||||
"We'll Meet Again", "idk", "wdym", "Not making sense", "Dw!", "i forgor", "Remember to be patient!",
|
||||
"NOW'S YOUR CHANCE TO BE A.", "BIG SHOT", "A burning memory", "FREE MONEY!",
|
||||
"Legacy is back!",
|
||||
"Pixelated goodness!",
|
||||
"Console Edition vibe!",
|
||||
"100% Not Microsoft!",
|
||||
"Symmetry is key!",
|
||||
"Does anyone even read these?",
|
||||
"Task failed successfully.",
|
||||
"Hardware accelerated!",
|
||||
"It's a feature, not a bug.",
|
||||
"Look behind you.",
|
||||
"Works on my machine.",
|
||||
"Now gluten-free!",
|
||||
"Mom, get the camera!",
|
||||
"Batteries not included.",
|
||||
"May contain nuts.",
|
||||
"Press Alt+F4 for diamonds!",
|
||||
"Downloading more RAM...",
|
||||
"Reinventing the wheel!",
|
||||
"The cake is a lie.",
|
||||
"Powered by copious amounts of coffee.",
|
||||
"I'm running out of ideas.",
|
||||
"That's no moon...",
|
||||
"Now with 100% more nostalgia!",
|
||||
"Legacy is the new modern.",
|
||||
"No microtransactions!",
|
||||
"As seen on TV!",
|
||||
"Ironic, isn't it?",
|
||||
"Creeper? Aww man.",
|
||||
"Technoblade never dies!",
|
||||
"is smartcmd dead ?",
|
||||
"NO BUILT IN MS AUTH !",
|
||||
"Mr_Anilex wasn't here!",
|
||||
"Who's Jack ?",
|
||||
"This text is blue!",
|
||||
"Bonjour!",
|
||||
"Salam!",
|
||||
"Reverse engineering Wii U version",
|
||||
"Don't try Valorant!",
|
||||
"This could never be a sad place!",
|
||||
"Made without microslop",
|
||||
"Thank you C418",
|
||||
"Bread is pain",
|
||||
"From the star!",
|
||||
"Never gonna give you up!",
|
||||
"9+10=21",
|
||||
".party() was successful",
|
||||
"Not Kogama",
|
||||
"You can be proud of you!",
|
||||
"Let's drink Orange Joe",
|
||||
"Kirater is a great singer!",
|
||||
"Mirkette My beloved",
|
||||
"Started in Bordeaux",
|
||||
"Oui Oui Baguette",
|
||||
"Milk In The Microwave",
|
||||
"8-3: DISINTEGRATION LOOP",
|
||||
"Turn the light OFF",
|
||||
"Not written by Mr_Anilex",
|
||||
"The One Who's Running the Show!",
|
||||
"Playing Forever",
|
||||
"The World looks cubic!",
|
||||
"huh?",
|
||||
"Sybau",
|
||||
"Available on Toaster",
|
||||
"Try ArchLinux",
|
||||
"69% Accurate",
|
||||
"A molecule of meow",
|
||||
"http://localhost:3000",
|
||||
"uuhhhh...",
|
||||
"Oyasumi",
|
||||
"I don't want to set the world on fire",
|
||||
"Directed by Michael Bay",
|
||||
"We see you, Opal!",
|
||||
"A Cool Cat in Town",
|
||||
"Not BrainRotted!",
|
||||
"Farting is Natural -Leon",
|
||||
"93/100 on metacritic",
|
||||
"Not (anymore) on Steam",
|
||||
"Sudo apt install EmeraldLauncher",
|
||||
"Sudo pacman -S EmeraldLauncher",
|
||||
"Kay-Chan my beloved! <3",
|
||||
"Peak!",
|
||||
"OpenSource!",
|
||||
"made by human with bone and flesh",
|
||||
"Made with hate against microslop",
|
||||
"Steelorse :fire:",
|
||||
"It's Minecraft but i'm not sure",
|
||||
"Look at you!",
|
||||
"You're beautiful",
|
||||
"Mr_Anilex has a big ego",
|
||||
"Traduis-moi !",
|
||||
"May contains Mr_Anilex",
|
||||
"Neoapps didn't write this splash",
|
||||
"Where's Kinger?",
|
||||
"KayJann, Breakcore and code",
|
||||
"Hey Goku!",
|
||||
"Vegeta is a DZ mashallah",
|
||||
"Bogos Binted? Vorp",
|
||||
"YOU SHALL NOT PASS !",
|
||||
"Bready, Steady, GO !",
|
||||
"Not-so-Empty-house",
|
||||
"We'll Meet Again",
|
||||
"idk",
|
||||
"wdym",
|
||||
"Not making sense",
|
||||
"Dw!",
|
||||
"i forgor",
|
||||
"Remember to be patient!",
|
||||
"NOW'S YOUR CHANCE TO BE A.",
|
||||
"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",
|
||||
"Try Indie Game", "SHARK WITH LEGS!", "it's a seal!", "Shrimp.", "Limited edition!",
|
||||
"Fat free!", "GOTY!", "Water proof!", "LALALA-LAVA", "CHICHICHI-CHICKEN", "Tasty ah hell",
|
||||
"1% sugar!", "150% hyperbole!", "Hotter than the sun!", "Woo, reddit!",
|
||||
"J'ai fait une blague à un poisson il m'a dit que c'était trop marin !",
|
||||
"piebot was here!", "Legacy in an evolved manner.", "neoapps is cool!", "Also try neoLegacy!",
|
||||
"$20 is $20", "no RenderDragon!", "Iggy Jiggy", "Arch, btw!", "Bedrock bad!"
|
||||
"Try Indie Game",
|
||||
"SHARK WITH LEGS!",
|
||||
"it's a seal!",
|
||||
"Shrimp.",
|
||||
"Limited edition!",
|
||||
"Fat free!",
|
||||
"GOTY!",
|
||||
"Water proof!",
|
||||
"LALALA-LAVA",
|
||||
"CHICHICHI-CHICKEN",
|
||||
"Tasty ah hell",
|
||||
"1% sugar!",
|
||||
"150% hyperbole!",
|
||||
"Hotter than the sun!",
|
||||
"Woo, reddit!",
|
||||
"piebot was here!",
|
||||
"Legacy in an evolved manner.",
|
||||
"neoapps is cool!",
|
||||
"neoapps has put a self insert into this program!",
|
||||
"Also try neoLegacy!",
|
||||
"$20 is $20",
|
||||
"no RenderDragon!",
|
||||
"Iggy Jiggy",
|
||||
"Arch, btw!",
|
||||
"Bedrock bad!",
|
||||
"Bedrock Linux not bad",
|
||||
"Also try Terraria!",
|
||||
"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.",
|
||||
];
|
||||
|
||||
interface AudioControllerProps {
|
||||
@@ -55,70 +170,215 @@ interface AudioControllerProps {
|
||||
isWindowVisible: boolean;
|
||||
}
|
||||
|
||||
export function useAudioController({ musicVol, sfxVol, showIntro, isGameRunning, isWindowVisible }: AudioControllerProps) {
|
||||
export function useAudioController({
|
||||
musicVol,
|
||||
sfxVol,
|
||||
showIntro,
|
||||
isGameRunning,
|
||||
isWindowVisible,
|
||||
}: AudioControllerProps) {
|
||||
const [currentTrack, setCurrentTrack] = useState(0);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||
const [splashIndex, setSplashIndex] = useState(-1);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const musicSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const musicGainRef = useRef<GainNode | null>(null);
|
||||
const trackBuffersRef = useRef<Map<number, AudioBuffer>>(new Map());
|
||||
const sfxBufferCacheRef = useRef<Map<string, AudioBuffer>>(new Map());
|
||||
const musicPausedRef = useRef<{ at: number; track: number } | null>(null);
|
||||
const fadeIntervalRef = useRef<any>(null);
|
||||
const fadeIntervalRef = useRef<number | null>(null);
|
||||
const targetVolumeRef = useRef(musicVol / 100);
|
||||
const getAudioContext = useCallback(() => {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new AudioContext();
|
||||
}
|
||||
if (audioContextRef.current.state === "suspended") {
|
||||
audioContextRef.current.resume();
|
||||
}
|
||||
return audioContextRef.current;
|
||||
}, []);
|
||||
|
||||
const playSfx = useCallback((file: string) => {
|
||||
const a = new Audio(`/sounds/${file}`);
|
||||
a.volume = sfxVol / 100;
|
||||
a.play().catch(() => { });
|
||||
}, [sfxVol]);
|
||||
const loadAudioBuffer = useCallback(
|
||||
async (url: string): Promise<AudioBuffer | undefined> => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const ctx = getAudioContext();
|
||||
return await ctx.decodeAudioData(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio:", url, error);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[getAudioContext],
|
||||
);
|
||||
|
||||
const playSfx = useCallback(
|
||||
async (file: string) => {
|
||||
const url = resolveAudioUrl(`/sounds/${file}`);
|
||||
let buffer = sfxBufferCacheRef.current.get(file);
|
||||
if (!buffer) {
|
||||
buffer = await loadAudioBuffer(url);
|
||||
if (buffer) {
|
||||
sfxBufferCacheRef.current.set(file, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
const ctx = getAudioContext();
|
||||
const source = ctx.createBufferSource();
|
||||
const gainNode = ctx.createGain();
|
||||
source.buffer = buffer;
|
||||
gainNode.gain.value = sfxVol / 100;
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
source.start();
|
||||
}
|
||||
},
|
||||
[sfxVol, loadAudioBuffer, getAudioContext],
|
||||
);
|
||||
|
||||
const playPressSound = useCallback(() => playSfx("press.wav"), [playSfx]);
|
||||
const playBackSound = useCallback(() => playSfx("back.ogg"), [playSfx]);
|
||||
const playSplashSound = useCallback(() => playSfx("orb.ogg"), [playSfx]);
|
||||
|
||||
const fadeOut = useCallback((audio: HTMLAudioElement, duration: number = 500) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
|
||||
const initialVolume = audio.volume;
|
||||
const steps = 5;
|
||||
const stepDuration = duration / steps;
|
||||
let currentStep = 0;
|
||||
fadeIntervalRef.current = setInterval(() => {
|
||||
currentStep++;
|
||||
const progress = currentStep / steps;
|
||||
audio.volume = initialVolume * (1 - progress);
|
||||
if (currentStep >= steps) {
|
||||
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
|
||||
fadeIntervalRef.current = null;
|
||||
audio.pause();
|
||||
audio.volume = initialVolume;
|
||||
resolve();
|
||||
}
|
||||
}, stepDuration);
|
||||
});
|
||||
const stopMusic = useCallback(() => {
|
||||
if (musicSourceRef.current) {
|
||||
try {
|
||||
musicSourceRef.current.stop();
|
||||
} catch (e) {}
|
||||
musicSourceRef.current.disconnect();
|
||||
musicSourceRef.current = null;
|
||||
}
|
||||
if (fadeIntervalRef.current) {
|
||||
clearInterval(fadeIntervalRef.current);
|
||||
fadeIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fadeIn = useCallback((audio: HTMLAudioElement, targetVolume: number, duration: number = 500) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
|
||||
audio.volume = 0;
|
||||
const playPromise = audio.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(() => { });
|
||||
}
|
||||
const playMusicBuffer = useCallback(
|
||||
(buffer: AudioBuffer, startTime: number = 0) => {
|
||||
stopMusic();
|
||||
const ctx = getAudioContext();
|
||||
const source = ctx.createBufferSource();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
source.buffer = buffer;
|
||||
gainNode.gain.value = 0;
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
const offset = startTime % buffer.duration;
|
||||
source.start(0, offset);
|
||||
|
||||
musicSourceRef.current = source;
|
||||
musicGainRef.current = gainNode;
|
||||
|
||||
const steps = 5;
|
||||
const stepDuration = 100;
|
||||
let currentStep = 0;
|
||||
fadeIntervalRef.current = window.setInterval(() => {
|
||||
currentStep++;
|
||||
const progress = currentStep / steps;
|
||||
if (musicGainRef.current) {
|
||||
musicGainRef.current.gain.value = targetVolumeRef.current * progress;
|
||||
}
|
||||
if (currentStep >= steps) {
|
||||
clearInterval(fadeIntervalRef.current || undefined);
|
||||
fadeIntervalRef.current = null;
|
||||
if (musicGainRef.current) {
|
||||
musicGainRef.current.gain.value = targetVolumeRef.current;
|
||||
}
|
||||
}
|
||||
}, stepDuration);
|
||||
|
||||
source.onended = () => {
|
||||
if (musicSourceRef.current) {
|
||||
setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
|
||||
}
|
||||
};
|
||||
},
|
||||
[stopMusic, getAudioContext],
|
||||
);
|
||||
|
||||
const fadeOutMusic = useCallback(
|
||||
(duration: number = 500): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
|
||||
if (!musicGainRef.current) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = 5;
|
||||
const stepDuration = duration / steps;
|
||||
const initialVolume = musicGainRef.current.gain.value;
|
||||
let currentStep = 0;
|
||||
|
||||
fadeIntervalRef.current = window.setInterval(() => {
|
||||
currentStep++;
|
||||
const progress = currentStep / steps;
|
||||
if (musicGainRef.current) {
|
||||
musicGainRef.current.gain.value = initialVolume * (1 - progress);
|
||||
}
|
||||
if (currentStep >= steps) {
|
||||
clearInterval(fadeIntervalRef.current || undefined);
|
||||
fadeIntervalRef.current = null;
|
||||
stopMusic();
|
||||
if (musicGainRef.current) {
|
||||
musicGainRef.current.gain.value = initialVolume;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
}, stepDuration);
|
||||
});
|
||||
},
|
||||
[stopMusic],
|
||||
);
|
||||
|
||||
const fadeInMusic = useCallback(
|
||||
(buffer: AudioBuffer, targetVolume: number, duration: number = 500) => {
|
||||
stopMusic();
|
||||
const ctx = getAudioContext();
|
||||
const source = ctx.createBufferSource();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
source.buffer = buffer;
|
||||
gainNode.gain.value = 0;
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
source.start();
|
||||
|
||||
musicSourceRef.current = source;
|
||||
musicGainRef.current = gainNode;
|
||||
targetVolumeRef.current = targetVolume;
|
||||
|
||||
const steps = 5;
|
||||
const stepDuration = duration / steps;
|
||||
let currentStep = 0;
|
||||
fadeIntervalRef.current = setInterval(() => {
|
||||
|
||||
fadeIntervalRef.current = window.setInterval(() => {
|
||||
currentStep++;
|
||||
const progress = currentStep / steps;
|
||||
audio.volume = targetVolume * progress;
|
||||
if (musicGainRef.current) {
|
||||
musicGainRef.current.gain.value = targetVolume * progress;
|
||||
}
|
||||
if (currentStep >= steps) {
|
||||
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
|
||||
clearInterval(fadeIntervalRef.current || undefined);
|
||||
fadeIntervalRef.current = null;
|
||||
audio.volume = targetVolume;
|
||||
resolve();
|
||||
if (musicGainRef.current) {
|
||||
musicGainRef.current.gain.value = targetVolume;
|
||||
}
|
||||
}
|
||||
}, stepDuration);
|
||||
});
|
||||
}, []);
|
||||
|
||||
source.onended = () => {
|
||||
if (musicSourceRef.current) {
|
||||
setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
|
||||
}
|
||||
};
|
||||
},
|
||||
[stopMusic, getAudioContext],
|
||||
);
|
||||
|
||||
const cycleSplash = useCallback(() => {
|
||||
playSplashSound();
|
||||
@@ -131,69 +391,105 @@ export function useAudioController({ musicVol, sfxVol, showIntro, isGameRunning,
|
||||
|
||||
useEffect(() => {
|
||||
if (showIntro) return;
|
||||
if (audioElement) return;
|
||||
|
||||
const audio = new Audio(TRACKS[currentTrack]);
|
||||
audio.volume = musicVol / 100;
|
||||
const handleEnded = () => setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
|
||||
audio.addEventListener("ended", handleEnded);
|
||||
|
||||
const playPromise = audio.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(() => {
|
||||
console.log("Autoplay prevented, waiting for user interaction");
|
||||
const startMusic = () => {
|
||||
audio.play().catch(() => { });
|
||||
document.removeEventListener("click", startMusic);
|
||||
document.removeEventListener("keydown", startMusic);
|
||||
};
|
||||
document.addEventListener("click", startMusic, { once: true });
|
||||
document.addEventListener("keydown", startMusic, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
setAudioElement(audio);
|
||||
return () => {
|
||||
audio.removeEventListener("ended", handleEnded);
|
||||
audio.pause();
|
||||
const loadAndPlay = async () => {
|
||||
let buffer = trackBuffersRef.current.get(currentTrack);
|
||||
if (!buffer) {
|
||||
buffer = await loadAudioBuffer(resolveAudioUrl(TRACKS[currentTrack]));
|
||||
if (buffer) {
|
||||
trackBuffersRef.current.set(currentTrack, buffer);
|
||||
}
|
||||
}
|
||||
if (buffer) {
|
||||
playMusicBuffer(buffer);
|
||||
}
|
||||
};
|
||||
}, [showIntro, audioElement, currentTrack, musicVol]);
|
||||
|
||||
loadAndPlay();
|
||||
|
||||
return () => {
|
||||
stopMusic();
|
||||
};
|
||||
}, [showIntro, currentTrack, loadAudioBuffer, playMusicBuffer, stopMusic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioElement) return;
|
||||
audioElement.src = TRACKS[currentTrack];
|
||||
audioElement.play().catch(() => { });
|
||||
}, [currentTrack]);
|
||||
if (!audioContextRef.current || showIntro) return;
|
||||
|
||||
const loadAndPlay = async () => {
|
||||
let buffer = trackBuffersRef.current.get(currentTrack);
|
||||
if (!buffer) {
|
||||
buffer = await loadAudioBuffer(resolveAudioUrl(TRACKS[currentTrack]));
|
||||
if (buffer) {
|
||||
trackBuffersRef.current.set(currentTrack, buffer);
|
||||
}
|
||||
}
|
||||
if (buffer) {
|
||||
fadeInMusic(buffer, musicVol / 100, 500);
|
||||
}
|
||||
};
|
||||
|
||||
loadAndPlay();
|
||||
}, [currentTrack, showIntro, musicVol, loadAudioBuffer, fadeInMusic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioElement) return;
|
||||
const shouldPause = isGameRunning || !isWindowVisible;
|
||||
|
||||
if (shouldPause) {
|
||||
if (!audioElement.paused || fadeIntervalRef.current) {
|
||||
if (musicSourceRef.current || fadeIntervalRef.current) {
|
||||
if (!musicPausedRef.current) {
|
||||
musicPausedRef.current = {
|
||||
at: audioElement.currentTime,
|
||||
track: currentTrack,
|
||||
};
|
||||
const ctx = getAudioContext();
|
||||
if (musicGainRef.current) {
|
||||
musicPausedRef.current = {
|
||||
at: ctx.currentTime,
|
||||
track: currentTrack,
|
||||
};
|
||||
} else {
|
||||
musicPausedRef.current = { at: 0, track: currentTrack };
|
||||
}
|
||||
}
|
||||
fadeOut(audioElement, 500);
|
||||
fadeOutMusic(500);
|
||||
}
|
||||
} else if (musicPausedRef.current) {
|
||||
const { at, track } = musicPausedRef.current;
|
||||
const { track } = musicPausedRef.current;
|
||||
musicPausedRef.current = null;
|
||||
targetVolumeRef.current = musicVol / 100;
|
||||
|
||||
const playWithPos = async () => {
|
||||
let buffer = trackBuffersRef.current.get(currentTrack);
|
||||
if (!buffer) {
|
||||
buffer = await loadAudioBuffer(resolveAudioUrl(TRACKS[currentTrack]));
|
||||
if (buffer) {
|
||||
trackBuffersRef.current.set(currentTrack, buffer);
|
||||
}
|
||||
}
|
||||
if (buffer) {
|
||||
fadeInMusic(buffer, musicVol / 100, 500);
|
||||
}
|
||||
};
|
||||
|
||||
if (track === currentTrack) {
|
||||
audioElement.currentTime = at;
|
||||
playWithPos();
|
||||
} else {
|
||||
setCurrentTrack(track);
|
||||
}
|
||||
fadeIn(audioElement, musicVol / 100, 500);
|
||||
}
|
||||
}, [isGameRunning, isWindowVisible, audioElement, currentTrack, musicVol, fadeOut, fadeIn]);
|
||||
}, [
|
||||
isGameRunning,
|
||||
isWindowVisible,
|
||||
currentTrack,
|
||||
musicVol,
|
||||
fadeOutMusic,
|
||||
fadeInMusic,
|
||||
loadAudioBuffer,
|
||||
getAudioContext,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioElement) {
|
||||
audioElement.volume = musicVol / 100;
|
||||
targetVolumeRef.current = musicVol / 100;
|
||||
if (musicGainRef.current && !fadeIntervalRef.current) {
|
||||
musicGainRef.current.gain.value = musicVol / 100;
|
||||
}
|
||||
}, [musicVol, audioElement]);
|
||||
}, [musicVol]);
|
||||
|
||||
return {
|
||||
currentTrack,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import RpcService from "../services/RpcService";
|
||||
|
||||
interface DiscordRPCProps {
|
||||
rpcEnabled: boolean;
|
||||
showIntro: boolean;
|
||||
@@ -29,7 +28,6 @@ export function useDiscordRPC({
|
||||
useEffect(() => {
|
||||
const updateRPC = async () => {
|
||||
if (!rpcEnabled || showIntro || !username) return;
|
||||
|
||||
if (!isWindowVisible && !isGameRunning && downloadProgress === null)
|
||||
return;
|
||||
|
||||
@@ -54,6 +52,15 @@ export function useDiscordRPC({
|
||||
devtools: "Developing for LCE",
|
||||
skins: "Changing Skins",
|
||||
workshop: "Browsing Workshop",
|
||||
lcelive: "Browsing Friends",
|
||||
"pck-editor": "Editing a PCK file",
|
||||
"options-editor": "Editing Options Files",
|
||||
"arc-editor": "Editing an ARC file",
|
||||
"loc-editor": "Editing Localisation Files",
|
||||
screenshots: "Browsing Screenshots",
|
||||
"col-editor": "Editing Color Files",
|
||||
"grf-editor": "Editing Game Rules",
|
||||
"swf-editor": "Editing Game UI",
|
||||
};
|
||||
details = tabNames[activeView] || "In Menus";
|
||||
}
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
async function imageUrlToBase64(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
const BASE_EDITIONS = [
|
||||
{
|
||||
id: "legacy_evolved", //neo: yes. we're not changing the ID. that will break user data.
|
||||
id: "legacy_evolved",
|
||||
name: "neoLegacy",
|
||||
desc: "Backporting newer title updates and Minigames back to LCE",
|
||||
url: "https://codeberg.org/piebot/LegacyEvolved/releases/download/nightly/LCEWindows64.zip",
|
||||
url: "https://github.com/pieeebot/neoLegacy/releases/download/latest/neoLegacyWindows64.zip",
|
||||
titleImage: "/images/minecraft_title_neoLegacy.png",
|
||||
supportsSlimSkins: true,
|
||||
logo: "/images/neoLegacy.png"
|
||||
logo: "/images/neoLegacy.png",
|
||||
panorama: "legacy_evolved",
|
||||
},
|
||||
{
|
||||
id: "revelations",
|
||||
name: "Legacy Revelations",
|
||||
desc: "QoL, performance, hardcore mode, & security features for LCE.",
|
||||
url: "https://github.com/LCE-Hub/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
|
||||
url: "https://github.com/itsRevela/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
|
||||
titleImage: "/images/minecraft_title_revelations.png",
|
||||
supportsSlimSkins: false,
|
||||
panorama: "vanilla_tu24",
|
||||
},
|
||||
{
|
||||
id: "360revived",
|
||||
@@ -26,8 +43,19 @@ const BASE_EDITIONS = [
|
||||
url: "https://github.com/BluTac10/360Revived/releases/download/nightly/LCEWindows64.zip",
|
||||
titleImage: "/images/minecraft_title_360revived.png",
|
||||
supportsSlimSkins: false,
|
||||
logo: "/images/360_revived.png"
|
||||
}
|
||||
logo: "/images/360_revived.png",
|
||||
panorama: "360revived",
|
||||
},
|
||||
{
|
||||
id: "legacy_nether_fork",
|
||||
name: "Hellish Ends",
|
||||
desc: "QoL, Random additions, and Nether/End dimensions overhaul (Modded build !)",
|
||||
url: "https://github.com/deadvoxelx/HellishEnds/releases/download/nightly/LCEWindows64.zip",
|
||||
titleImage: "/images/minecraft_title_hellishends.png",
|
||||
supportsSlimSkins: false,
|
||||
logo: "/images/netherrack_0.png",
|
||||
panorama: "vanilla_tu19",
|
||||
},
|
||||
];
|
||||
|
||||
const PARTNERSHIP_SERVERS = [
|
||||
@@ -45,6 +73,23 @@ interface GameManagerProps {
|
||||
setCustomEditions: (editions: any[]) => void;
|
||||
}
|
||||
|
||||
function compareVersions(v1: string, v2: string) {
|
||||
const parts1 = v1.replace(/^v/, "").split(/[.-]/);
|
||||
const parts2 = v2.replace(/^v/, "").split(/[.-]/);
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const p1 = parts1[i] || "0";
|
||||
const p2 = parts2[i] || "0";
|
||||
const n1 = parseInt(p1);
|
||||
const n2 = parseInt(p2);
|
||||
if (!isNaN(n1) && !isNaN(n2)) {
|
||||
if (n1 !== n2) return n1 - n2;
|
||||
} else {
|
||||
if (p1 !== p2) return p1 > p2 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function useGameManager({
|
||||
profile,
|
||||
setProfile,
|
||||
@@ -60,22 +105,216 @@ export function useGameManager({
|
||||
number | null
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const editions = useMemo(
|
||||
() => [...BASE_EDITIONS, ...customEditions],
|
||||
[customEditions],
|
||||
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [steamSuccessMessage, setSteamSuccessMessage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [dynamicUrls, setDynamicUrls] = useState<Record<string, string>>({});
|
||||
const [branches, setBranches] = useState<Record<string, string[]>>({});
|
||||
const [selectedBranches, setSelectedBranches] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const branchesFetched = useRef<Set<string>>(new Set());
|
||||
const initialBranchesSet = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialBranchesSet.current || !profile) return;
|
||||
BASE_EDITIONS.forEach((e) => {
|
||||
if (profile.startsWith(e.id + "_")) {
|
||||
const branch = profile.replace(e.id + "_", "");
|
||||
setSelectedBranches((prev) => ({ ...prev, [e.id]: branch }));
|
||||
} else if (profile === e.id) {
|
||||
setSelectedBranches((prev) => ({ ...prev, [e.id]: "Stable" }));
|
||||
}
|
||||
});
|
||||
initialBranchesSet.current = true;
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestReleases() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/pieeebot/neoLegacy/releases/latest",
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const asset = data.assets.find(
|
||||
(a: any) => a.name === "neoLegacyWindows64.zip",
|
||||
);
|
||||
if (asset) {
|
||||
setDynamicUrls((prev) => ({
|
||||
...prev,
|
||||
legacy_evolved: asset.browser_download_url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch latest releases:", e);
|
||||
}
|
||||
}
|
||||
fetchLatestReleases();
|
||||
}, []);
|
||||
|
||||
const fetchBranchesForEdition = useCallback(
|
||||
async (editionId: string, url: string) => {
|
||||
if (branchesFetched.current.has(editionId)) return;
|
||||
if (!url.includes("github.com")) return;
|
||||
const parts = url.split("github.com/")[1].split("/");
|
||||
const owner = parts[0];
|
||||
const repo = parts[1];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
let tags: string[] = data
|
||||
.map((r: any) => r.tag_name)
|
||||
.filter((t: string) => !t.toLowerCase().includes("server"));
|
||||
|
||||
const vTags = tags
|
||||
.filter((t) => t.startsWith("v"))
|
||||
.sort(compareVersions);
|
||||
const bestVTag = vTags[vTags.length - 1];
|
||||
if (!tags.includes("Stable")) tags.unshift("Stable");
|
||||
if (bestVTag) {
|
||||
setDynamicUrls((prev) => ({
|
||||
...prev,
|
||||
[`${editionId}_Stable`]: bestVTag,
|
||||
}));
|
||||
}
|
||||
|
||||
setBranches((prev) => ({ ...prev, [editionId]: tags }));
|
||||
branchesFetched.current.add(editionId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch branches for ${editionId}:`, e);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
BASE_EDITIONS.forEach((e) => fetchBranchesForEdition(e.id, e.url));
|
||||
}, [fetchBranchesForEdition]);
|
||||
|
||||
const cycleBranch = useCallback(
|
||||
(editionId: string) => {
|
||||
const available = branches[editionId] || ["Stable"];
|
||||
if (available.length <= 1) return;
|
||||
setSelectedBranches((prev) => {
|
||||
const current = prev[editionId] || available[0];
|
||||
let currentIndex = available.indexOf(current);
|
||||
let nextIndex = currentIndex;
|
||||
let nextBranch = current;
|
||||
do {
|
||||
nextIndex = (nextIndex + 1) % available.length;
|
||||
nextBranch = available[nextIndex];
|
||||
} while (nextBranch.startsWith("v") && nextIndex !== currentIndex);
|
||||
const oldInstanceId =
|
||||
current === "Stable" ? editionId : `${editionId}_${current}`;
|
||||
const newInstanceId =
|
||||
nextBranch === "Stable" ? editionId : `${editionId}_${nextBranch}`;
|
||||
if (profile === oldInstanceId) {
|
||||
setProfile(newInstanceId);
|
||||
}
|
||||
return { ...prev, [editionId]: nextBranch };
|
||||
});
|
||||
},
|
||||
[branches, profile, setProfile],
|
||||
);
|
||||
|
||||
const editions = useMemo(() => {
|
||||
return [
|
||||
...BASE_EDITIONS.map((e) => {
|
||||
const availableBranches = branches[e.id] || ["Stable"];
|
||||
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
|
||||
let url = dynamicUrls[e.id] || e.url;
|
||||
const defaultBranchFromUrl = e.url.includes("/releases/download/")
|
||||
? e.url.split("/releases/download/")[1].split("/")[0]
|
||||
: "nightly";
|
||||
const branchToUse =
|
||||
selectedBranch === "Stable"
|
||||
? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl
|
||||
: selectedBranch;
|
||||
if (e.url.includes("github.com")) {
|
||||
const baseUrl = e.url.split("/releases/download/")[0];
|
||||
const filename = e.url.split("/").pop();
|
||||
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
...e,
|
||||
url,
|
||||
branches: availableBranches,
|
||||
selectedBranch,
|
||||
instanceId:
|
||||
selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`,
|
||||
};
|
||||
}),
|
||||
...customEditions.map((e) => ({ ...e, instanceId: e.id })),
|
||||
];
|
||||
}, [customEditions, dynamicUrls, branches, selectedBranches]);
|
||||
|
||||
const checkInstalls = useCallback(async () => {
|
||||
const results = await Promise.all(
|
||||
editions.map(async (e) => {
|
||||
const isInstalled = await TauriService.checkGameInstalled(e.id);
|
||||
return isInstalled ? e.id : null;
|
||||
const isInstalled = await TauriService.checkGameInstalled(e.instanceId);
|
||||
return isInstalled ? e.instanceId : null;
|
||||
}),
|
||||
);
|
||||
setInstalls(results.filter((id): id is string => id !== null));
|
||||
}, [editions]);
|
||||
|
||||
const [updatesAvailable, setUpdatesAvailable] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const checkForGameUpdates = useCallback(async () => {
|
||||
const checks = await Promise.all(
|
||||
editions.map(async (edition) => {
|
||||
if (!installs.includes(edition.instanceId))
|
||||
return [edition.instanceId, false] as const;
|
||||
try {
|
||||
const isUpdate = await TauriService.checkGameUpdate(
|
||||
edition.instanceId,
|
||||
edition.url,
|
||||
);
|
||||
return [edition.instanceId, isUpdate] as const;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [edition.instanceId, false] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const newUpdates: Record<string, boolean> = {};
|
||||
for (const [id, hasUpdate] of checks) {
|
||||
newUpdates[id as string] = hasUpdate as boolean;
|
||||
}
|
||||
setUpdatesAvailable(newUpdates);
|
||||
|
||||
const updatedGames = editions.filter((e) => newUpdates[e.id]);
|
||||
if (updatedGames.length > 0) {
|
||||
if (updatedGames.length === 1) {
|
||||
setGameUpdateMessage(
|
||||
`An update is available for ${updatedGames[0].name}!`,
|
||||
);
|
||||
} else {
|
||||
setGameUpdateMessage(
|
||||
`Updates are available for ${updatedGames.length} versions!`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setGameUpdateMessage(null);
|
||||
}
|
||||
}, [editions, installs]);
|
||||
|
||||
useEffect(() => {
|
||||
checkForGameUpdates();
|
||||
}, [profile, installs, checkForGameUpdates]);
|
||||
|
||||
useEffect(() => {
|
||||
checkInstalls();
|
||||
const unlistenDownload = TauriService.onDownloadProgress((p) =>
|
||||
@@ -114,7 +353,7 @@ export function useGameManager({
|
||||
const toggleInstall = useCallback(
|
||||
async (id: string) => {
|
||||
if (downloadingId) return;
|
||||
const edition = editions.find((e) => e.id === id);
|
||||
const edition = editions.find((e) => e.instanceId === id);
|
||||
if (!edition) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -186,12 +425,20 @@ export function useGameManager({
|
||||
}, [profile]);
|
||||
|
||||
const addCustomEdition = useCallback(
|
||||
(edition: { name: string; desc: string; url: string }) => {
|
||||
const id = `custom_${Date.now()}`;
|
||||
(edition: {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
path?: string;
|
||||
category?: string[];
|
||||
logo?: string;
|
||||
id?: string;
|
||||
}) => {
|
||||
const id = edition.id || `custom_${Date.now()}`;
|
||||
const newEdition = {
|
||||
...edition,
|
||||
id,
|
||||
titleImage: "/images/minecraft_title_tucustom.png",
|
||||
titleImage: "/images/MenuTitle.png",
|
||||
};
|
||||
setCustomEditions([...customEditions, newEdition]);
|
||||
return id;
|
||||
@@ -208,7 +455,10 @@ export function useGameManager({
|
||||
);
|
||||
|
||||
const updateCustomEdition = useCallback(
|
||||
(id: string, updated: { name: string; desc: string; url: string }) => {
|
||||
(
|
||||
id: string,
|
||||
updated: { name: string; desc: string; url: string; path?: string },
|
||||
) => {
|
||||
setCustomEditions(
|
||||
customEditions.map((e) => (e.id === id ? { ...e, ...updated } : e)),
|
||||
);
|
||||
@@ -216,6 +466,30 @@ export function useGameManager({
|
||||
[customEditions, setCustomEditions],
|
||||
);
|
||||
|
||||
const addToSteam = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
name: string,
|
||||
titleImage: string,
|
||||
panoramaImage: string,
|
||||
) => {
|
||||
try {
|
||||
const titleBase64 = await imageUrlToBase64(titleImage);
|
||||
const panoramaBase64 = await imageUrlToBase64(panoramaImage);
|
||||
await TauriService.addToSteam(id, name, titleBase64, panoramaBase64);
|
||||
setSteamSuccessMessage(
|
||||
`Added ${name} to Steam! (Restart Steam to see it)`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(
|
||||
typeof e === "string" ? e : e.message || "Failed to add to Steam",
|
||||
);
|
||||
}
|
||||
},
|
||||
[setError, setSteamSuccessMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
installs,
|
||||
isGameRunning,
|
||||
@@ -236,5 +510,12 @@ export function useGameManager({
|
||||
updateCustomEdition,
|
||||
downloadRunner,
|
||||
checkInstalls,
|
||||
gameUpdateMessage,
|
||||
setGameUpdateMessage,
|
||||
steamSuccessMessage,
|
||||
setSteamSuccessMessage,
|
||||
updatesAvailable,
|
||||
addToSteam,
|
||||
cycleBranch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
export interface UseGamepadProps {
|
||||
playSfx: (file: string) => void;
|
||||
isWindowVisible: boolean;
|
||||
@@ -57,18 +56,15 @@ export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => {
|
||||
return typeof btn === "object" ? btn.value : (btn as any) ?? 0;
|
||||
};
|
||||
const justPressed = (i: number) => btnVal(i) > 0.5 && !lastButtons.current[i];
|
||||
|
||||
if (justPressed(1)) dispatchKey('Enter');
|
||||
if (justPressed(2)) dispatchKey('Escape');
|
||||
if (justPressed(4)) dispatchKey('Tab', true);
|
||||
if (justPressed(5)) dispatchKey('Tab');
|
||||
|
||||
const newButtons: Record<number, boolean> = {};
|
||||
gp.buttons.forEach((btn, i) => {
|
||||
newButtons[i] = (typeof btn === "object" ? btn.value : btn) > 0.5;
|
||||
});
|
||||
lastButtons.current = newButtons;
|
||||
|
||||
const deadzone = 0.5;
|
||||
const axisY = gp.axes[2] ?? 0;
|
||||
const prevY = lastAxes.current[2] ?? 0;
|
||||
@@ -76,7 +72,6 @@ export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => {
|
||||
dispatchKey(axisY < 0 ? 'ArrowDown' : 'ArrowUp');
|
||||
}
|
||||
lastAxes.current[2] = axisY;
|
||||
|
||||
const axisX = gp.axes[1] ?? 0;
|
||||
const prevX = lastAxes.current[1] ?? 0;
|
||||
if (Math.abs(axisX) > deadzone && Math.abs(prevX) <= deadzone) {
|
||||
@@ -94,17 +89,16 @@ export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => {
|
||||
useEffect(() => {
|
||||
const handleConnect = () => setConnected(true);
|
||||
const handleDisconnect = () => {
|
||||
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
const hasGamepads = Array.from(gamepads).some(gp => gp !== null);
|
||||
setConnected(hasGamepads);
|
||||
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
const hasGamepads = Array.from(gamepads).some(gp => gp !== null);
|
||||
setConnected(hasGamepads);
|
||||
};
|
||||
|
||||
window.addEventListener("gamepadconnected", handleConnect);
|
||||
window.addEventListener("gamepaddisconnected", handleDisconnect);
|
||||
|
||||
const initialGamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
if (Array.from(initialGamepads).some(gp => gp !== null)) {
|
||||
setConnected(true);
|
||||
setConnected(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { lceLiveService } from "../services/LceLiveService";
|
||||
export function useLceLiveNotifications() {
|
||||
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
|
||||
const [gameInviteMessage, setGameInviteMessage] = useState<string | null>(null);
|
||||
const seenRequests = useRef<Set<string>>(new Set());
|
||||
const seenInvites = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let pollInterval: any;
|
||||
const init = async () => {
|
||||
if (lceLiveService.signedIn) {
|
||||
try {
|
||||
await lceLiveService.refreshSession();
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (lceLiveService.signedIn) {
|
||||
try {
|
||||
const [reqs, invs] = await Promise.all([
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites()
|
||||
]);
|
||||
reqs.incoming.forEach((r: any) => seenRequests.current.add(r.accountId));
|
||||
invs.filter((i: any) => i.status === "pending").forEach((i: any) => seenInvites.current.add(i.inviteId));
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
pollInterval = setInterval(async () => {
|
||||
if (!lceLiveService.signedIn) return;
|
||||
try {
|
||||
const [reqs, invs] = await Promise.all([
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites()
|
||||
]);
|
||||
|
||||
reqs.incoming.forEach((r: any) => {
|
||||
if (!seenRequests.current.has(r.accountId)) {
|
||||
seenRequests.current.add(r.accountId);
|
||||
setFriendRequestMessage(`New request from ${r.displayName}`);
|
||||
}
|
||||
});
|
||||
|
||||
invs.filter((i: any) => i.status === "pending").forEach((i: any) => {
|
||||
if (!seenInvites.current.has(i.inviteId)) {
|
||||
seenInvites.current.add(i.inviteId);
|
||||
const fromName = typeof i.from === 'string' ? "Unknown" : i.from.displayName;
|
||||
setGameInviteMessage(`${fromName} invited you to play!`);
|
||||
}
|
||||
});
|
||||
} catch (e) { }
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
init();
|
||||
return () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
friendRequestMessage,
|
||||
gameInviteMessage,
|
||||
clearFriendRequestMessage: () => setFriendRequestMessage(null),
|
||||
clearGameInviteMessage: () => setGameInviteMessage(null),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
|
||||
export function usePlatform() {
|
||||
const platform = useMemo(() => {
|
||||
if (typeof window === 'undefined') return { isLinux: false, isMac: false, isWindows: false };
|
||||
|
||||
const ua = window.navigator.userAgent.toLowerCase();
|
||||
const plat = window.navigator.platform.toLowerCase();
|
||||
|
||||
const isLinux = plat.includes('linux') || ua.includes('linux');
|
||||
const isMac = plat.includes('mac') || ua.includes('mac');
|
||||
const isWindows = plat.includes('win') || ua.includes('win');
|
||||
|
||||
return { isLinux, isMac, isWindows };
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,53 +1,199 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocalStorage } from "./useLocalStorage";
|
||||
|
||||
import { PckService } from "../services/PckService";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import { PCKAssetType, PCKProperty } from "../types/pck";
|
||||
interface Edition {
|
||||
id: string;
|
||||
supportsSlimSkins?: boolean;
|
||||
}
|
||||
|
||||
interface UseSkinSyncProps {
|
||||
username: string;
|
||||
profile: string;
|
||||
editions: Edition[];
|
||||
}
|
||||
|
||||
export function useSkinSync({ profile, editions }: UseSkinSyncProps) {
|
||||
const [skinUrl, setSkinUrl] = useLocalStorage("lce-skin", "/images/Default.png");
|
||||
export function useSkinSync({ username, profile, editions }: UseSkinSyncProps) {
|
||||
const [skinUrl, setSkinUrl] = useLocalStorage(
|
||||
"lce-skin",
|
||||
"/images/Default.png",
|
||||
);
|
||||
const [skinIsSlim, setSkinIsSlim] = useLocalStorage("lce-skin-slim", false);
|
||||
const [skinBase64, setSkinBase64] = useState<string | null>(null);
|
||||
|
||||
const [capeUrl, setCapeUrl] = useLocalStorage<string | null>(
|
||||
"lce-cape",
|
||||
null,
|
||||
);
|
||||
const [capeBase64, setCapeBase64] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const syncSkin = async () => {
|
||||
if (!skinUrl) return;
|
||||
const edition = editions.find((e) => e.id === profile);
|
||||
const supportsSlim = edition?.supportsSlimSkins ?? false;
|
||||
if (!supportsSlim) {
|
||||
let cancelled = false;
|
||||
if (!skinUrl) return;
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = async () => {
|
||||
if (cancelled) return;
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = 64;
|
||||
cvs.height = 32;
|
||||
const ctx = cvs.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const b64 = cvs.toDataURL("image/png");
|
||||
setSkinBase64(b64);
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = 64;
|
||||
cvs.height = 32;
|
||||
const ctx = cvs.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, 64, 32, 0, 0, 64, 32);
|
||||
setSkinBase64(cvs.toDataURL("image/png"));
|
||||
const res = await fetch(b64);
|
||||
const skinBuf = await res.arrayBuffer();
|
||||
const isModernHeight = img.height === 64;
|
||||
const animValue = skinIsSlim
|
||||
? "0x00041800"
|
||||
: isModernHeight
|
||||
? "0x00040000"
|
||||
: "0x00000000";
|
||||
let boxes: PCKProperty[] = [];
|
||||
if (skinIsSlim) {
|
||||
boxes.push({
|
||||
key: "BOX",
|
||||
value: "ARM0 -2 -2 -2 3 12 4 40 16 0 0 0",
|
||||
});
|
||||
boxes.push({
|
||||
key: "BOX",
|
||||
value: "ARM1 -1 -2 -2 3 12 4 40 16 0 1 0",
|
||||
});
|
||||
}
|
||||
|
||||
let capeBuf: ArrayBuffer | null = null;
|
||||
if (capeUrl) {
|
||||
const capeImg = new Image();
|
||||
capeImg.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve) => {
|
||||
capeImg.onload = () => resolve();
|
||||
capeImg.onerror = () => resolve();
|
||||
capeImg.src = capeUrl;
|
||||
});
|
||||
if (!cancelled && capeImg.complete && capeImg.naturalWidth > 0) {
|
||||
const capeCanvas = document.createElement("canvas");
|
||||
capeCanvas.width = capeImg.naturalWidth;
|
||||
capeCanvas.height = capeImg.naturalHeight;
|
||||
const capeCtx = capeCanvas.getContext("2d");
|
||||
if (capeCtx) {
|
||||
capeCtx.drawImage(capeImg, 0, 0);
|
||||
const capeDataUrl = capeCanvas.toDataURL("image/png");
|
||||
const capeRes = await fetch(capeDataUrl);
|
||||
capeBuf = await capeRes.arrayBuffer();
|
||||
setCapeBase64(capeDataUrl);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setCapeBase64(null);
|
||||
}
|
||||
|
||||
const getSeededId = (name: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash << 5) - hash + name.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString().padStart(8, "0").slice(-8);
|
||||
};
|
||||
img.src = skinUrl;
|
||||
|
||||
const seededId = getSeededId(username);
|
||||
const packId = seededId.slice(-4);
|
||||
const files: any[] = [
|
||||
{
|
||||
id: "0",
|
||||
path: "0",
|
||||
type: PCKAssetType.INFO,
|
||||
size: 0,
|
||||
data: new Uint8Array(0),
|
||||
properties: [
|
||||
{
|
||||
key: "PACKID",
|
||||
value: packId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: `dlcskin${seededId}`,
|
||||
path: `dlcskin${seededId}.png`,
|
||||
type: PCKAssetType.SKIN,
|
||||
size: skinBuf.byteLength,
|
||||
data: new Uint8Array(skinBuf),
|
||||
properties: [
|
||||
{
|
||||
key: "DISPLAYNAME",
|
||||
value: username,
|
||||
},
|
||||
{
|
||||
key: "GAME_FLAGS",
|
||||
value: "0x18",
|
||||
},
|
||||
{
|
||||
key: "FREE",
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
key: "ANIM",
|
||||
value: animValue,
|
||||
},
|
||||
...boxes,
|
||||
{
|
||||
key: "THEMENAME",
|
||||
value: "Emerald Launcher",
|
||||
},
|
||||
{
|
||||
key: "CAPEPATH",
|
||||
value: `dlccape${seededId}.png`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (capeBuf) {
|
||||
files.push({
|
||||
id: `dlccape${seededId}`,
|
||||
path: `dlccape${seededId}.png`,
|
||||
type: PCKAssetType.CAPE,
|
||||
size: capeBuf.byteLength,
|
||||
data: new Uint8Array(capeBuf),
|
||||
properties: [],
|
||||
});
|
||||
}
|
||||
|
||||
const pckBuf = PckService.serializePCK({
|
||||
version: 3,
|
||||
endianness: "little",
|
||||
xmlSupport: false,
|
||||
properties: [
|
||||
"ANIM",
|
||||
"DISPLAYNAME",
|
||||
"THEMENAME",
|
||||
"GAME_FLAGS",
|
||||
"FREE",
|
||||
"BOX",
|
||||
],
|
||||
files,
|
||||
});
|
||||
await TauriService.saveGlobalSkinPck(new Uint8Array(pckBuf));
|
||||
} catch (e) {
|
||||
console.error("Skin conversion failed:", e);
|
||||
console.error("Failed to generate and save Skin PCK", e);
|
||||
}
|
||||
} else {
|
||||
setSkinBase64(null);
|
||||
}
|
||||
};
|
||||
syncSkin();
|
||||
}, [skinUrl, profile, editions]);
|
||||
img.src = skinUrl;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [skinUrl, capeUrl, username, profile, editions, skinIsSlim]);
|
||||
|
||||
return {
|
||||
skinUrl,
|
||||
setSkinUrl,
|
||||
skinIsSlim,
|
||||
setSkinIsSlim,
|
||||
skinBase64,
|
||||
capeUrl,
|
||||
setCapeUrl,
|
||||
capeBase64,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
const CURRENT_VERSION = pkg.version;
|
||||
const REPO_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/latest";
|
||||
|
||||
function isNewerVersion(latest: string, current: string): boolean {
|
||||
const latestParts = latest.split('.').map(Number);
|
||||
const currentParts = current.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
|
||||
const latestPart = latestParts[i] || 0;
|
||||
const currentPart = currentParts[i] || 0;
|
||||
|
||||
if (latestPart > currentPart) return true;
|
||||
if (latestPart < currentPart) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
export function useUpdateCheck() {
|
||||
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
||||
|
||||
const [updateUrl, _setUpdateUrl] = useState<string | null>(null);
|
||||
const checkUpdates = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(REPO_URL);
|
||||
if (!response.ok) return;
|
||||
const update = await check();
|
||||
if (update) {
|
||||
setUpdateMessage(`Downloading update: ${update.version}...`);
|
||||
let downloaded = 0;
|
||||
let contentLength = 0;
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength || 0;
|
||||
setUpdateMessage(`Starting download of ${update.version}...`);
|
||||
break;
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength;
|
||||
if (contentLength > 0) {
|
||||
const percent = Math.round((downloaded / contentLength) * 100);
|
||||
setUpdateMessage(`Downloading update: ${percent}%`);
|
||||
}
|
||||
break;
|
||||
case 'Finished':
|
||||
setUpdateMessage(`Installing update...`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name.replace(/^v/, '');
|
||||
|
||||
if (isNewerVersion(latestVersion, CURRENT_VERSION)) {
|
||||
setUpdateMessage(`Version ${data.tag_name} is now available!`);
|
||||
setUpdateMessage("Update installed. Restarting...");
|
||||
try {
|
||||
await relaunch();
|
||||
} catch (e) {
|
||||
console.warn("Could not relaunch immediately:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to check for updates:", e);
|
||||
@@ -44,6 +48,7 @@ export function useUpdateCheck() {
|
||||
|
||||
return {
|
||||
updateMessage,
|
||||
updateUrl,
|
||||
clearUpdateMessage: () => setUpdateMessage(null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ import WorkshopView from "../components/views/WorkshopView";
|
||||
import SetupView from "../components/views/SetupView";
|
||||
import PckEditorView from "../components/views/PckEditorView";
|
||||
import { ArcEditorView } from "../components/views/ArcEditorView";
|
||||
import LocEditorView from "../components/views/LocEditorView";
|
||||
import GrfEditorView from "../components/views/GrfEditorView";
|
||||
import ColEditorView from "../components/views/ColEditorView";
|
||||
import OptionsEditorView from "../components/views/OptionsEditorView";
|
||||
import ScreenshotsView from "../components/views/ScreenshotsView";
|
||||
import SwfView from "../components/views/SwfView";
|
||||
import LceLiveView from "../components/views/LceLiveView";
|
||||
import SkinViewer from "../components/common/SkinViewer";
|
||||
import TeamModal from "../components/modals/TeamModal";
|
||||
import PanoramaBackground from "../components/common/PanoramaBackground";
|
||||
@@ -23,8 +30,8 @@ import {
|
||||
useSkin,
|
||||
} from "../context/LauncherContext";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
showIntro,
|
||||
@@ -40,29 +47,32 @@ export default function App() {
|
||||
focusSection,
|
||||
onNavigateToMenu,
|
||||
updateMessage,
|
||||
updateUrl,
|
||||
clearUpdateMessage,
|
||||
} = useUI();
|
||||
|
||||
const config = useConfig();
|
||||
const audio = useAudio();
|
||||
const game = useGame();
|
||||
const { skinUrl, setSkinUrl } = useSkin();
|
||||
|
||||
const { skinUrl, setSkinUrl, capeUrl } = useSkin();
|
||||
const {
|
||||
friendRequestMessage,
|
||||
gameInviteMessage,
|
||||
clearFriendRequestMessage,
|
||||
clearGameInviteMessage,
|
||||
} = useLceLiveNotifications();
|
||||
const [showSetup, setShowSetup] = useState(true);
|
||||
const [displayIsDay, setDisplayIsDay] = useState(config.isDayTime);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayIsDay(config.isDayTime);
|
||||
}, [config.isDayTime]);
|
||||
|
||||
const selectedEdition = game.editions.find(
|
||||
(e: any) => e.id === config.profile,
|
||||
(e: any) => e.instanceId === config.profile,
|
||||
);
|
||||
const selectedVersionName = selectedEdition?.name || "";
|
||||
const hasAnyInstall = game.installs.length > 0;
|
||||
|
||||
const titleImage = hasAnyInstall
|
||||
? (selectedEdition?.titleImage || "/images/MenuTitle.png")
|
||||
? selectedEdition?.titleImage || "/images/MenuTitle.png"
|
||||
: "/images/MenuTitle.png";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,7 +132,7 @@ export default function App() {
|
||||
{...backgroundFade}
|
||||
>
|
||||
<PanoramaBackground
|
||||
profile={config.profile}
|
||||
profile={selectedEdition.panorama}
|
||||
isDay={displayIsDay}
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -158,12 +168,33 @@ export default function App() {
|
||||
message={updateMessage}
|
||||
onClose={clearUpdateMessage}
|
||||
onClick={() =>
|
||||
TauriService.openUrl("https://lce-hub.github.io/")
|
||||
TauriService.openUrl(
|
||||
updateUrl ||
|
||||
"https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest",
|
||||
)
|
||||
}
|
||||
title="Update Available!"
|
||||
variant="update"
|
||||
/>
|
||||
|
||||
<AchievementToast
|
||||
message={game.gameUpdateMessage}
|
||||
onClose={() => game.setGameUpdateMessage(null)}
|
||||
onClick={() => {
|
||||
game.setGameUpdateMessage(null);
|
||||
setActiveView("versions");
|
||||
}}
|
||||
title="Game Update Available!"
|
||||
variant="update"
|
||||
/>
|
||||
|
||||
<AchievementToast
|
||||
message={game.steamSuccessMessage}
|
||||
onClose={() => game.setSteamSuccessMessage(null)}
|
||||
title="Steam Integration"
|
||||
variant="steam"
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSetup ? (
|
||||
<SetupView
|
||||
@@ -254,6 +285,40 @@ export default function App() {
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isUiHidden &&
|
||||
!displayIsDay &&
|
||||
activeView == "devtools" && (
|
||||
<motion.div
|
||||
key="secret-swf-btn"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
audio.playPressSound();
|
||||
setIsUiHidden(false);
|
||||
setActiveView("swf-editor");
|
||||
}}
|
||||
className="pointer-events-auto hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none flex flex-col items-center gap-2 group"
|
||||
>
|
||||
<img
|
||||
src="/images/tools/pck.png"
|
||||
className="w-16 h-16 cursor-pointer object-contain opacity-50 group-hover:opacity-100 drop-shadow-[0_4px_4px_rgba(0,0,0,1)] grayscale group-hover:grayscale-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"/images/Button_Background.png";
|
||||
}}
|
||||
/>
|
||||
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
SWF Editor
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -318,6 +383,7 @@ export default function App() {
|
||||
setUsername={config.setUsername}
|
||||
playPressSound={audio.playPressSound}
|
||||
skinUrl={skinUrl}
|
||||
capeUrl={config.legacyMode ? null : capeUrl}
|
||||
setSkinUrl={setSkinUrl}
|
||||
setActiveView={setActiveView}
|
||||
isFocusedSection={focusSection === "skin"}
|
||||
@@ -326,7 +392,7 @@ export default function App() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="w-full max-w-4xl relative flex justify-center items-center">
|
||||
<div className="w-full h-full max-w-4xl relative flex justify-center items-center overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeView === "main" && <HomeView key="main-view" />}
|
||||
{activeView === "settings" && (
|
||||
@@ -347,7 +413,28 @@ export default function App() {
|
||||
{activeView === "arc-editor" && (
|
||||
<ArcEditorView key="arc-editor-view" />
|
||||
)}
|
||||
{activeView === "loc-editor" && (
|
||||
<LocEditorView key="loc-editor-view" />
|
||||
)}
|
||||
{activeView === "grf-editor" && (
|
||||
<GrfEditorView key="grf-editor-view" />
|
||||
)}
|
||||
{activeView === "col-editor" && (
|
||||
<ColEditorView key="col-editor-view" />
|
||||
)}
|
||||
{activeView === "options-editor" && (
|
||||
<OptionsEditorView key="options-editor-view" />
|
||||
)}
|
||||
{activeView === "swf-editor" && (
|
||||
<SwfView key="swf-editor-view" />
|
||||
)}
|
||||
{activeView === "lcelive" && (
|
||||
<LceLiveView key="lcelive-view" />
|
||||
)}
|
||||
{activeView === "skins" && <SkinsView key="skins-view" />}
|
||||
{activeView === "screenshots" && (
|
||||
<ScreenshotsView key="screenshots-view" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +449,7 @@ export default function App() {
|
||||
style={{ fontWeight: "normal" }}
|
||||
>
|
||||
<div className="flex-1 text-left whitespace-nowrap">
|
||||
Version: {pkg.version}
|
||||
Version: {pkg.version} ({__BUILD_DATE__})
|
||||
</div>
|
||||
<div className="flex-[2] text-center whitespace-nowrap">
|
||||
Not affiliated with Mojang AB or Microsoft. "Minecraft" is
|
||||
@@ -377,6 +464,28 @@ export default function App() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AchievementToast
|
||||
message={friendRequestMessage}
|
||||
onClose={clearFriendRequestMessage}
|
||||
onClick={() => {
|
||||
clearFriendRequestMessage();
|
||||
setActiveView("lcelive");
|
||||
}}
|
||||
title="Friend Request"
|
||||
variant="update"
|
||||
/>
|
||||
|
||||
<AchievementToast
|
||||
message={gameInviteMessage}
|
||||
onClose={clearGameInviteMessage}
|
||||
onClick={() => {
|
||||
clearGameInviteMessage();
|
||||
setActiveView("lcelive");
|
||||
}}
|
||||
title="Game Invite"
|
||||
variant="update"
|
||||
/>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ArcEntry, ArcFile, LocFile, LocLanguage, LocString } from "../types/arc";
|
||||
|
||||
export class ArcService {
|
||||
private static enc = new TextEncoder();
|
||||
private static dec = new TextDecoder('utf-8');
|
||||
|
||||
private static readUTF(view: DataView, off: number): { str: string; next: number } {
|
||||
const len = view.getUint16(off, false);
|
||||
const bytes = new Uint8Array(view.buffer, view.byteOffset + off + 2, len);
|
||||
@@ -22,12 +20,9 @@ export class ArcService {
|
||||
const view = new DataView(buffer);
|
||||
const raw = new Uint8Array(buffer);
|
||||
let off = 0;
|
||||
|
||||
const count = view.getInt32(off, false);
|
||||
off += 4;
|
||||
|
||||
if (count < 0 || count > 200000) throw new Error("Invalid file count");
|
||||
|
||||
const entries: ArcEntry[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const { str: rawName, next } = this.readUTF(view, off);
|
||||
@@ -36,7 +31,6 @@ export class ArcService {
|
||||
off += 4;
|
||||
const size = view.getInt32(off, false);
|
||||
off += 4;
|
||||
|
||||
let filename = rawName;
|
||||
let isCompressed = false;
|
||||
if (filename.charCodeAt(0) === 42) {
|
||||
@@ -69,10 +63,8 @@ export class ArcService {
|
||||
const view = new DataView(buffer);
|
||||
const out = new Uint8Array(buffer);
|
||||
let pos = 0;
|
||||
|
||||
view.setInt32(pos, arc.entries.length, false);
|
||||
pos += 4;
|
||||
|
||||
arc.entries.forEach((e, i) => {
|
||||
const nb = this.enc.encode((e.isCompressed ? "*" : "") + e.filename);
|
||||
view.setUint16(pos, nb.length, false);
|
||||
@@ -98,57 +90,53 @@ export class ArcService {
|
||||
const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||
const view = new DataView(buf);
|
||||
let off = 0;
|
||||
|
||||
const version = view.getInt32(off, false);
|
||||
off += 4;
|
||||
const langCount = view.getInt32(off, false);
|
||||
off += 4;
|
||||
|
||||
const langMeta: { id: string, size: number }[] = [];
|
||||
for (let i = 0; i < langCount; i++) {
|
||||
const { str: id, next } = this.readUTF(view, off);
|
||||
off = next;
|
||||
const size = view.getInt32(off, false);
|
||||
off += 4;
|
||||
langMeta.push({ id, size });
|
||||
const { str: id, next } = this.readUTF(view, off);
|
||||
off = next;
|
||||
const size = view.getInt32(off, false);
|
||||
off += 4;
|
||||
langMeta.push({ id, size });
|
||||
}
|
||||
|
||||
const languages: LocLanguage[] = [];
|
||||
for (let i = 0; i < langMeta.length; i++) {
|
||||
const blobView = new DataView(buf, off, langMeta[i].size);
|
||||
let boff = 0;
|
||||
|
||||
const langVer = blobView.getInt32(boff, false);
|
||||
boff += 4;
|
||||
let isStatic = false;
|
||||
if (langVer > 0) {
|
||||
isStatic = blobView.getUint8(boff) !== 0;
|
||||
boff += 1;
|
||||
const blobView = new DataView(buf, off, langMeta[i].size);
|
||||
let boff = 0;
|
||||
const langVer = blobView.getInt32(boff, false);
|
||||
boff += 4;
|
||||
let isStatic = false;
|
||||
if (langVer > 0) {
|
||||
isStatic = blobView.getUint8(boff) !== 0;
|
||||
boff += 1;
|
||||
}
|
||||
const { str: langId, next: n2 } = this.readUTF(blobView, boff);
|
||||
boff = n2;
|
||||
const total = blobView.getInt32(boff, false);
|
||||
boff += 4;
|
||||
const strings: LocString[] = [];
|
||||
if (!isStatic) {
|
||||
for (let j = 0; j < total; j++) {
|
||||
const { str: key, next: nk } = this.readUTF(blobView, boff);
|
||||
boff = nk;
|
||||
const { str: val, next: nv } = this.readUTF(blobView, boff);
|
||||
boff = nv;
|
||||
strings.push({ key, value: val });
|
||||
}
|
||||
const { str: langId, next: n2 } = this.readUTF(blobView, boff);
|
||||
boff = n2;
|
||||
const total = blobView.getInt32(boff, false);
|
||||
boff += 4;
|
||||
|
||||
const strings: LocString[] = [];
|
||||
if (!isStatic) {
|
||||
for (let j = 0; j < total; j++) {
|
||||
const { str: key, next: nk } = this.readUTF(blobView, boff);
|
||||
boff = nk;
|
||||
const { str: val, next: nv } = this.readUTF(blobView, boff);
|
||||
boff = nv;
|
||||
strings.push({ key, value: val });
|
||||
}
|
||||
} else {
|
||||
for (let j = 0; j < total; j++) {
|
||||
const { str: val, next: nv } = this.readUTF(blobView, boff);
|
||||
boff = nv;
|
||||
strings.push({ value: val });
|
||||
}
|
||||
} else {
|
||||
for (let j = 0; j < total; j++) {
|
||||
const { str: val, next: nv } = this.readUTF(blobView, boff);
|
||||
boff = nv;
|
||||
strings.push({ value: val });
|
||||
}
|
||||
}
|
||||
|
||||
languages.push({ id: langMeta[i].id, version: langVer, isStatic, langId, strings });
|
||||
off += langMeta[i].size;
|
||||
languages.push({ id: langMeta[i].id, version: langVer, isStatic, langId, strings });
|
||||
off += langMeta[i].size;
|
||||
}
|
||||
|
||||
return { version, languages };
|
||||
@@ -156,53 +144,47 @@ export class ArcService {
|
||||
|
||||
static serializeLOC(loc: LocFile): Uint8Array {
|
||||
const blobs = loc.languages.map(lang => {
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
const vh = new Uint8Array(4);
|
||||
new DataView(vh.buffer).setInt32(0, lang.version, false);
|
||||
parts.push(vh);
|
||||
const parts: Uint8Array[] = [];
|
||||
const vh = new Uint8Array(4);
|
||||
new DataView(vh.buffer).setInt32(0, lang.version, false);
|
||||
parts.push(vh);
|
||||
if (lang.version > 0) {
|
||||
parts.push(new Uint8Array([lang.isStatic ? 1 : 0]));
|
||||
}
|
||||
|
||||
if (lang.version > 0) {
|
||||
parts.push(new Uint8Array([lang.isStatic ? 1 : 0]));
|
||||
parts.push(this.writeUTF(lang.langId));
|
||||
const sc = new Uint8Array(4);
|
||||
new DataView(sc.buffer).setInt32(0, lang.strings.length, false);
|
||||
parts.push(sc);
|
||||
lang.strings.forEach(s => {
|
||||
if (!lang.isStatic && s.key) {
|
||||
parts.push(this.writeUTF(s.key));
|
||||
}
|
||||
parts.push(this.writeUTF(s.value));
|
||||
});
|
||||
|
||||
parts.push(this.writeUTF(lang.langId));
|
||||
|
||||
const sc = new Uint8Array(4);
|
||||
new DataView(sc.buffer).setInt32(0, lang.strings.length, false);
|
||||
parts.push(sc);
|
||||
|
||||
lang.strings.forEach(s => {
|
||||
if (!lang.isStatic && s.key) {
|
||||
parts.push(this.writeUTF(s.key));
|
||||
}
|
||||
parts.push(this.writeUTF(s.value));
|
||||
});
|
||||
|
||||
const total = parts.reduce((a, b) => a + b.length, 0);
|
||||
const blob = new Uint8Array(total);
|
||||
let p = 0;
|
||||
parts.forEach(b => {
|
||||
blob.set(b, p);
|
||||
p += b.length;
|
||||
});
|
||||
return blob;
|
||||
const total = parts.reduce((a, b) => a + b.length, 0);
|
||||
const blob = new Uint8Array(total);
|
||||
let p = 0;
|
||||
parts.forEach(b => {
|
||||
blob.set(b, p);
|
||||
p += b.length;
|
||||
});
|
||||
return blob;
|
||||
});
|
||||
|
||||
const headerParts: Uint8Array[] = [];
|
||||
const vh = new Uint8Array(4);
|
||||
new DataView(vh.buffer).setInt32(0, loc.version, false);
|
||||
headerParts.push(vh);
|
||||
|
||||
const lc = new Uint8Array(4);
|
||||
new DataView(lc.buffer).setInt32(0, loc.languages.length, false);
|
||||
headerParts.push(lc);
|
||||
|
||||
loc.languages.forEach((lang, i) => {
|
||||
headerParts.push(this.writeUTF(lang.id));
|
||||
const sz = new Uint8Array(4);
|
||||
new DataView(sz.buffer).setInt32(0, blobs[i].length, false);
|
||||
headerParts.push(sz);
|
||||
headerParts.push(this.writeUTF(lang.id));
|
||||
const sz = new Uint8Array(4);
|
||||
new DataView(sz.buffer).setInt32(0, blobs[i].length, false);
|
||||
headerParts.push(sz);
|
||||
});
|
||||
|
||||
const allParts = [...headerParts, ...blobs];
|
||||
@@ -210,8 +192,8 @@ export class ArcService {
|
||||
const out = new Uint8Array(total);
|
||||
let p = 0;
|
||||
allParts.forEach(b => {
|
||||
out.set(b, p);
|
||||
p += b.length;
|
||||
out.set(b, p);
|
||||
p += b.length;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ColFile, ColColor, ColWorldColor } from "../types/col";
|
||||
export class ColService {
|
||||
private static textDecoder = new TextDecoder("utf-8");
|
||||
private static textEncoder = new TextEncoder();
|
||||
public static readCOL(buffer: ArrayBuffer): ColFile {
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
const version = view.getInt32(offset, false); offset += 4;
|
||||
const colorCount = view.getInt32(offset, false); offset += 4;
|
||||
const colors: ColColor[] = [];
|
||||
for (let i = 0; i < colorCount; i++) {
|
||||
const nameLength = view.getUint16(offset, false); offset += 2;
|
||||
const name = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + offset, nameLength));
|
||||
offset += nameLength;
|
||||
const color = view.getUint32(offset, false); offset += 4;
|
||||
colors.push({ name, color });
|
||||
}
|
||||
|
||||
const worldColors: ColWorldColor[] = [];
|
||||
if (version > 0 && offset < view.byteLength) {
|
||||
const worldColorCount = view.getInt32(offset, false); offset += 4;
|
||||
for (let i = 0; i < worldColorCount; i++) {
|
||||
const nameLength = view.getUint16(offset, false); offset += 2;
|
||||
const name = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + offset, nameLength));
|
||||
offset += nameLength;
|
||||
const waterColor = view.getUint32(offset, false); offset += 4;
|
||||
const underwaterColor = view.getUint32(offset, false); offset += 4;
|
||||
const fogColor = view.getUint32(offset, false); offset += 4;
|
||||
worldColors.push({ name, waterColor, underwaterColor, fogColor });
|
||||
}
|
||||
}
|
||||
|
||||
return { version, colors, worldColors };
|
||||
}
|
||||
|
||||
public static serializeCOL(col: ColFile): ArrayBuffer {
|
||||
let size = 8;
|
||||
for (const c of col.colors) {
|
||||
const nameBytes = this.textEncoder.encode(c.name);
|
||||
size += 2 + nameBytes.length + 4;
|
||||
}
|
||||
|
||||
if (col.version > 0) {
|
||||
size += 4;
|
||||
for (const w of col.worldColors) {
|
||||
const nameBytes = this.textEncoder.encode(w.name);
|
||||
size += 2 + nameBytes.length + 12;
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = new ArrayBuffer(size);
|
||||
const view = new DataView(buffer);
|
||||
const out = new Uint8Array(buffer);
|
||||
let offset = 0;
|
||||
view.setInt32(offset, col.version, false); offset += 4;
|
||||
view.setInt32(offset, col.colors.length, false); offset += 4;
|
||||
for (const c of col.colors) {
|
||||
const nameBytes = this.textEncoder.encode(c.name);
|
||||
view.setUint16(offset, nameBytes.length, false); offset += 2;
|
||||
out.set(nameBytes, offset); offset += nameBytes.length;
|
||||
view.setUint32(offset, c.color, false); offset += 4;
|
||||
}
|
||||
|
||||
if (col.version > 0) {
|
||||
view.setInt32(offset, col.worldColors.length, false); offset += 4;
|
||||
for (const w of col.worldColors) {
|
||||
const nameBytes = this.textEncoder.encode(w.name);
|
||||
view.setUint16(offset, nameBytes.length, false); offset += 2;
|
||||
out.set(nameBytes, offset); offset += nameBytes.length;
|
||||
view.setUint32(offset, w.waterColor, false); offset += 4;
|
||||
view.setUint32(offset, w.underwaterColor, false); offset += 4;
|
||||
view.setUint32(offset, w.fogColor, false); offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import pako from "pako";
|
||||
import {
|
||||
GrfCompressionLevel,
|
||||
GrfCompressionType,
|
||||
GrfFile,
|
||||
GrfFileEntry,
|
||||
GrfHeader,
|
||||
GrfNode
|
||||
} from "../types/grf";
|
||||
export class GrfService {
|
||||
private static textDecoder = new TextDecoder("ascii");
|
||||
private static textEncoder = new TextEncoder();
|
||||
private static rleDecode(data: Uint8Array): Uint8Array {
|
||||
const output: number[] = [];
|
||||
const marker = 0xff;
|
||||
const maxLength = 0xfe;
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
const val = data[i++];
|
||||
if (val !== marker) {
|
||||
output.push(val);
|
||||
} else {
|
||||
if (i >= data.length) throw new Error("Invalid RLE");
|
||||
const next = data[i++];
|
||||
if (next === marker) {
|
||||
output.push(marker);
|
||||
} else {
|
||||
const length = next;
|
||||
if (length > 2 && length <= maxLength) {
|
||||
if (i >= data.length) throw new Error("Invalid RLE");
|
||||
const runVal = data[i++];
|
||||
for (let j = 0; j < length + 1; j++) {
|
||||
output.push(runVal);
|
||||
}
|
||||
} else {
|
||||
output.push(length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Uint8Array(output);
|
||||
}
|
||||
|
||||
private static rleEncode(data: Uint8Array): Uint8Array {
|
||||
const output: number[] = [];
|
||||
if (data.length === 0) return new Uint8Array(0);
|
||||
const marker = 0xff;
|
||||
const maxLength = 0xfe;
|
||||
let firstRunValue = data[0];
|
||||
let runLength = 1;
|
||||
const makeRun = (value: number, length: number) => {
|
||||
if ((length <= 3 && value !== marker) || length <= 1) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (value === marker) {
|
||||
output.push(marker, marker);
|
||||
} else {
|
||||
output.push(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.push(marker);
|
||||
output.push(length - 1);
|
||||
output.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const currentValue = data[i];
|
||||
if (currentValue === firstRunValue) {
|
||||
runLength++;
|
||||
} else {
|
||||
makeRun(firstRunValue, runLength);
|
||||
firstRunValue = currentValue;
|
||||
runLength = 1;
|
||||
}
|
||||
|
||||
if (runLength > maxLength) {
|
||||
makeRun(firstRunValue, maxLength);
|
||||
runLength -= maxLength;
|
||||
}
|
||||
}
|
||||
makeRun(firstRunValue, runLength);
|
||||
return new Uint8Array(output);
|
||||
}
|
||||
|
||||
private static readStringList(view: DataView, off: { p: number }): string[] {
|
||||
const count = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const list: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const len = view.getInt16(off.p, false);
|
||||
off.p += 2;
|
||||
const str = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + off.p, len));
|
||||
off.p += len;
|
||||
list.push(str);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static readGRF(buffer: ArrayBuffer): GrfFile {
|
||||
const view = new DataView(buffer);
|
||||
let off = { p: 0 };
|
||||
const firstWord = view.getUint16(off.p, false);
|
||||
off.p += 2;
|
||||
let header: GrfHeader;
|
||||
if (firstWord === 0) {
|
||||
off.p += 14;
|
||||
header = {
|
||||
compressionLevel: GrfCompressionLevel.None,
|
||||
crc: 0xffffffff,
|
||||
compressionType: GrfCompressionType.Unknown,
|
||||
unknownData: new Uint8Array([0, 0, 0, 0])
|
||||
};
|
||||
} else {
|
||||
const compressionLevel = view.getUint8(off.p++) as GrfCompressionLevel;
|
||||
const crc = view.getUint32(off.p, false);
|
||||
off.p += 4;
|
||||
const unknownData = new Uint8Array(view.buffer, view.byteOffset + off.p, 4);
|
||||
off.p += 4;
|
||||
if (unknownData[3] > 0) {
|
||||
throw new Error("World grf's are not currently supported.");
|
||||
}
|
||||
header = {
|
||||
compressionLevel,
|
||||
crc,
|
||||
compressionType: GrfCompressionType.Zlib,
|
||||
unknownData
|
||||
};
|
||||
}
|
||||
|
||||
let bodyData: Uint8Array;
|
||||
let bodyView: DataView;
|
||||
let bodyOff = { p: 0 };
|
||||
if (header.compressionLevel !== GrfCompressionLevel.None) {
|
||||
view.getInt32(off.p, false); //neo: this is decompressedSize
|
||||
off.p += 4;
|
||||
const compressedSize = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const compressedData = new Uint8Array(view.buffer, view.byteOffset + off.p, compressedSize);
|
||||
off.p += compressedSize;
|
||||
let decompressed: Uint8Array;
|
||||
if (header.compressionType === GrfCompressionType.Zlib || true) {
|
||||
try {
|
||||
decompressed = pako.inflate(compressedData);
|
||||
} catch {
|
||||
decompressed = pako.inflateRaw(compressedData); //neo: fallback to pako.inflateRaw if raw deflate
|
||||
}
|
||||
}
|
||||
|
||||
if (header.compressionLevel > GrfCompressionLevel.Compressed) {
|
||||
bodyData = this.rleDecode(decompressed!);
|
||||
} else {
|
||||
bodyData = decompressed!;
|
||||
}
|
||||
|
||||
bodyView = new DataView(bodyData.buffer, bodyData.byteOffset, bodyData.byteLength);
|
||||
} else {
|
||||
bodyData = new Uint8Array(view.buffer, view.byteOffset + off.p);
|
||||
bodyView = new DataView(bodyData.buffer, bodyData.byteOffset, bodyData.byteLength);
|
||||
}
|
||||
|
||||
const lut = this.readStringList(bodyView, bodyOff);
|
||||
const fileCount = bodyView.getInt32(bodyOff.p, false);
|
||||
bodyOff.p += 4;
|
||||
const files: GrfFileEntry[] = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const nlen = bodyView.getInt16(bodyOff.p, false);
|
||||
bodyOff.p += 2;
|
||||
const filename = this.textDecoder.decode(new Uint8Array(bodyView.buffer, bodyView.byteOffset + bodyOff.p, nlen));
|
||||
bodyOff.p += nlen;
|
||||
const size = bodyView.getInt32(bodyOff.p, false);
|
||||
bodyOff.p += 4;
|
||||
const data = new Uint8Array(bodyView.buffer, bodyView.byteOffset + bodyOff.p, size).slice();
|
||||
bodyOff.p += size;
|
||||
files.push({ filename, data });
|
||||
}
|
||||
|
||||
const root: GrfNode = { name: "__ROOT__", parameters: [], children: [] };
|
||||
this.readGameRuleHierarchy(bodyView, bodyOff, root, lut);
|
||||
return { header, files, root };
|
||||
}
|
||||
|
||||
private static readGameRuleHierarchy(view: DataView, off: { p: number }, parent: GrfNode, lut: string[]) {
|
||||
const count = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nameIdx = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const paramCount = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
|
||||
const node: GrfNode = {
|
||||
name: lut[nameIdx],
|
||||
parameters: [],
|
||||
children: []
|
||||
};
|
||||
|
||||
for (let j = 0; j < paramCount; j++) {
|
||||
const kIdx = view.getInt32(off.p, false);
|
||||
off.p += 4;
|
||||
const vLen = view.getInt16(off.p, false);
|
||||
off.p += 2;
|
||||
const value = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + off.p, vLen));
|
||||
off.p += vLen;
|
||||
node.parameters.push({ name: lut[kIdx], value });
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
this.readGameRuleHierarchy(view, off, node, lut);
|
||||
}
|
||||
}
|
||||
|
||||
private static makeString(s: string): Uint8Array {
|
||||
const enc = this.textEncoder.encode(s);
|
||||
const out = new Uint8Array(2 + enc.length);
|
||||
new DataView(out.buffer).setInt16(0, enc.length, false);
|
||||
out.set(enc, 2);
|
||||
return out;
|
||||
}
|
||||
|
||||
public static serializeGRF(grf: GrfFile): ArrayBuffer {
|
||||
const lut: string[] = [];
|
||||
const buildLut = (node: GrfNode) => {
|
||||
if (!lut.includes(node.name) && node.name !== "__ROOT__") lut.push(node.name);
|
||||
for (const p of node.parameters) {
|
||||
if (!lut.includes(p.name)) lut.push(p.name);
|
||||
}
|
||||
for (const c of node.children) buildLut(c);
|
||||
};
|
||||
buildLut(grf.root);
|
||||
let parts: Uint8Array[] = [];
|
||||
const lutCount = new Uint8Array(4);
|
||||
new DataView(lutCount.buffer).setInt32(0, lut.length, false);
|
||||
parts.push(lutCount);
|
||||
for (const s of lut) parts.push(this.makeString(s));
|
||||
const fCount = new Uint8Array(4);
|
||||
new DataView(fCount.buffer).setInt32(0, grf.files.length, false);
|
||||
parts.push(fCount);
|
||||
for (const f of grf.files) {
|
||||
parts.push(this.makeString(f.filename));
|
||||
const sz = new Uint8Array(4);
|
||||
new DataView(sz.buffer).setInt32(0, f.data.length, false);
|
||||
parts.push(sz);
|
||||
parts.push(f.data);
|
||||
}
|
||||
|
||||
const writeNode = (node: GrfNode) => {
|
||||
const cCount = new Uint8Array(4);
|
||||
new DataView(cCount.buffer).setInt32(0, node.children.length, false);
|
||||
parts.push(cCount);
|
||||
for (const c of node.children) {
|
||||
const nid = new Uint8Array(4);
|
||||
new DataView(nid.buffer).setInt32(0, lut.indexOf(c.name), false);
|
||||
parts.push(nid);
|
||||
const pCount = new Uint8Array(4);
|
||||
new DataView(pCount.buffer).setInt32(0, c.parameters.length, false);
|
||||
parts.push(pCount);
|
||||
for (const p of c.parameters) {
|
||||
const pid = new Uint8Array(4);
|
||||
new DataView(pid.buffer).setInt32(0, lut.indexOf(p.name), false);
|
||||
parts.push(pid);
|
||||
parts.push(this.makeString(p.value));
|
||||
}
|
||||
|
||||
writeNode(c);
|
||||
}
|
||||
};
|
||||
writeNode(grf.root);
|
||||
let uncompressedSize = parts.reduce((acc, p) => acc + p.length, 0);
|
||||
const uncompressedData = new Uint8Array(uncompressedSize);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
uncompressedData.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
|
||||
let bodyData = uncompressedData;
|
||||
let header = grf.header;
|
||||
if (header.compressionLevel >= GrfCompressionLevel.CompressedRle) {
|
||||
bodyData = this.rleEncode(bodyData);
|
||||
}
|
||||
|
||||
let compressedSize = 0;
|
||||
if (header.compressionLevel >= GrfCompressionLevel.Compressed) {
|
||||
bodyData = pako.deflate(bodyData, { level: 9 });
|
||||
compressedSize = bodyData.length;
|
||||
}
|
||||
|
||||
const finalBuffer = new Uint8Array(11 + (header.compressionLevel >= GrfCompressionLevel.Compressed ? 8 : 0) + bodyData.length);
|
||||
const fw = new DataView(finalBuffer.buffer);
|
||||
let poff = 0;
|
||||
fw.setInt16(poff, 1, false); poff += 2;
|
||||
fw.setUint8(poff, header.compressionLevel); poff += 1;
|
||||
fw.setUint32(poff, header.crc, false); poff += 4;
|
||||
finalBuffer.set(header.unknownData, poff); poff += 4;
|
||||
if (header.compressionLevel >= GrfCompressionLevel.Compressed) {
|
||||
fw.setInt32(poff, uncompressedSize, false); poff += 4;
|
||||
fw.setInt32(poff, compressedSize, false); poff += 4;
|
||||
}
|
||||
|
||||
finalBuffer.set(bodyData as any, poff);
|
||||
|
||||
return finalBuffer.buffer;
|
||||
}
|
||||
|
||||
public static createDefaultGRF(): GrfFile {
|
||||
return {
|
||||
header: {
|
||||
compressionLevel: GrfCompressionLevel.CompressedRle,
|
||||
crc: 0,
|
||||
compressionType: GrfCompressionType.Zlib,
|
||||
unknownData: new Uint8Array([0, 0, 0, 0])
|
||||
},
|
||||
files: [],
|
||||
root: {
|
||||
name: "__ROOT__",
|
||||
parameters: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
const LOCAL_STORAGE_KEY = "lcelive_session";
|
||||
const DEFAULT_BASE_URL = "https://api.lcelive.co.uk";
|
||||
const MAX_REFRESH_RETRIES = 1;
|
||||
import { TauriService } from "./TauriService";
|
||||
export interface LceLiveAccount {
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
account: LceLiveAccount;
|
||||
}
|
||||
|
||||
export interface DeviceLinkStartResponse {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete?: string;
|
||||
intervalSeconds: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
export interface DeviceLinkPollResponse {
|
||||
status: string;
|
||||
isLinked?: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
account?: LceLiveAccount;
|
||||
}
|
||||
|
||||
export interface GameInvite {
|
||||
inviteId: string;
|
||||
from: LceLiveAccount | string;
|
||||
hostIp: string;
|
||||
hostPort: number;
|
||||
hostName: string;
|
||||
signalingSessionId?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface PendingRequests {
|
||||
incoming: FriendRequest[];
|
||||
outgoing: FriendRequest[];
|
||||
}
|
||||
|
||||
export class LceLiveService {
|
||||
private _session: SessionData | null = null;
|
||||
private baseUrl: string = DEFAULT_BASE_URL;
|
||||
private _refreshPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadSession();
|
||||
}
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
get signedIn(): boolean {
|
||||
return this._session !== null;
|
||||
}
|
||||
|
||||
get account(): LceLiveAccount | null {
|
||||
return this._session?.account || null;
|
||||
}
|
||||
|
||||
get displayUsername(): string {
|
||||
if (!this._session) return "Not signed in";
|
||||
return (
|
||||
this._session.account.displayName ||
|
||||
this._session.account.username ||
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
get apiBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
get accessToken(): string | null {
|
||||
return this._session?.accessToken || null;
|
||||
}
|
||||
|
||||
public generateDeviceId(): string {
|
||||
let id = localStorage.getItem("lcelive_device_id");
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem("lcelive_device_id", id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public getDeviceName(): string {
|
||||
return "LCE Emerald Launcher";
|
||||
}
|
||||
|
||||
private loadSession() {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
this._session = JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load LceLive session", e);
|
||||
}
|
||||
}
|
||||
|
||||
private saveSession() {
|
||||
if (this._session) {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this._session));
|
||||
} else {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any,
|
||||
authed: boolean = true,
|
||||
retryCount: number = 0,
|
||||
): Promise<any> {
|
||||
if (authed && this._session?.refreshToken && retryCount === 0) {
|
||||
try {
|
||||
await this.refreshSession(); //neo: i do this on every request only because it doesnt always return 401
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "MCLCE-LceLive/1.0",
|
||||
};
|
||||
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (authed && this._session?.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
|
||||
}
|
||||
|
||||
const bodyStr = body ? JSON.stringify(body) : null;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await TauriService.httpProxyRequest(
|
||||
method,
|
||||
`${this.baseUrl}${path}`,
|
||||
bodyStr,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Network error when calling ${path}: ${e}`);
|
||||
}
|
||||
|
||||
if (
|
||||
res.status === 401 &&
|
||||
authed &&
|
||||
this._session?.refreshToken &&
|
||||
retryCount < MAX_REFRESH_RETRIES
|
||||
) {
|
||||
try {
|
||||
await this.refreshSession();
|
||||
return this.request(method, path, body, authed, retryCount + 1);
|
||||
} catch (err) {
|
||||
this.logoutLocal();
|
||||
throw new Error("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = res.body ? JSON.parse(res.body) : {};
|
||||
} catch {
|
||||
data = { message: res.body };
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const errorMsg =
|
||||
data.message ||
|
||||
data.detail ||
|
||||
data.title ||
|
||||
data.error ||
|
||||
`HTTP ${res.status}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async startDeviceLink(): Promise<DeviceLinkStartResponse> {
|
||||
return this.request(
|
||||
"POST",
|
||||
"/api/auth/device/start",
|
||||
{
|
||||
deviceId: this.generateDeviceId(),
|
||||
deviceName: this.getDeviceName(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async pollDeviceLink(deviceCode: string): Promise<DeviceLinkPollResponse> {
|
||||
const data = await this.request(
|
||||
"GET",
|
||||
`/api/auth/device/poll/${deviceCode}`,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
if (data.isLinked && data.accessToken) {
|
||||
this._session = {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
account: data.account,
|
||||
};
|
||||
this.saveSession();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<void> {
|
||||
if (!this._session?.refreshToken) throw new Error("No refresh token");
|
||||
|
||||
if (this._refreshPromise) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
this._refreshPromise = (async () => {
|
||||
try {
|
||||
const data = await this.request(
|
||||
"POST",
|
||||
"/api/auth/refresh",
|
||||
{
|
||||
refreshToken: this._session!.refreshToken,
|
||||
},
|
||||
false,
|
||||
);
|
||||
this._session = {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
account: data.account,
|
||||
};
|
||||
this.saveSession();
|
||||
} finally {
|
||||
this._refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (this._session?.refreshToken) {
|
||||
try {
|
||||
await this.request(
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
{
|
||||
refreshToken: this._session.refreshToken,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("Server logout failed", e);
|
||||
}
|
||||
}
|
||||
this.logoutLocal();
|
||||
}
|
||||
|
||||
logoutLocal(): void {
|
||||
this._session = null;
|
||||
this.saveSession();
|
||||
}
|
||||
|
||||
async getFriends(): Promise<LceLiveAccount[]> {
|
||||
const data = await this.request("GET", "/api/social/friends");
|
||||
return data.friends || [];
|
||||
}
|
||||
|
||||
async removeFriend(accountId: string): Promise<void> {
|
||||
await this.request("DELETE", `/api/social/friends/${accountId}`);
|
||||
}
|
||||
|
||||
async sendFriendRequest(username: string): Promise<void> {
|
||||
await this.request("POST", "/api/social/request", { username });
|
||||
}
|
||||
|
||||
async getPendingRequests(): Promise<PendingRequests> {
|
||||
const data = await this.request("GET", "/api/social/requests");
|
||||
return {
|
||||
incoming: (data.incoming || []).map((r: any) => ({
|
||||
accountId: r.requesterUserId || r.accountId || r.userId,
|
||||
username: r.requesterUsername || r.username,
|
||||
displayName: r.requesterDisplayName || r.displayName,
|
||||
})),
|
||||
outgoing: (data.outgoing || []).map((r: any) => ({
|
||||
accountId: r.targetUserId || r.accountId || r.userId,
|
||||
username: r.targetUsername || r.username,
|
||||
displayName: r.targetDisplayName || r.displayName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async acceptFriendRequest(accountId: string): Promise<void> {
|
||||
await this.request("POST", `/api/social/requests/${accountId}/accept`, {});
|
||||
}
|
||||
|
||||
async declineFriendRequest(accountId: string): Promise<void> {
|
||||
await this.request("POST", `/api/social/requests/${accountId}/decline`, {});
|
||||
}
|
||||
|
||||
async getGameInvites(): Promise<GameInvite[]> {
|
||||
const data = await this.request("GET", "/api/sessions/invites");
|
||||
const incoming = data.incoming || [];
|
||||
return incoming.map((inv: any) => ({
|
||||
inviteId: inv.inviteId,
|
||||
from: {
|
||||
accountId: inv.senderAccountId,
|
||||
username: inv.senderUsername,
|
||||
displayName: inv.senderDisplayName,
|
||||
},
|
||||
hostIp: inv.hostIp,
|
||||
hostPort: inv.hostPort,
|
||||
hostName: inv.hostName,
|
||||
status: inv.status,
|
||||
signalingSessionId: inv.signalingSessionId,
|
||||
}));
|
||||
}
|
||||
|
||||
async sendGameInvite(
|
||||
recipientAccountId: string,
|
||||
hostIp: string,
|
||||
hostPort: number,
|
||||
hostName: string,
|
||||
signalingSessionId?: string,
|
||||
): Promise<void> {
|
||||
await this.request("POST", "/api/sessions/invites", {
|
||||
recipientAccountId,
|
||||
hostIp,
|
||||
hostPort,
|
||||
hostName,
|
||||
signalingSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptGameInvite(inviteId: string): Promise<any> {
|
||||
return this.request("POST", `/api/sessions/invites/${inviteId}/accept`, {});
|
||||
}
|
||||
|
||||
async declineGameInvite(inviteId: string): Promise<void> {
|
||||
await this.request("POST", `/api/sessions/invites/${inviteId}/decline`, {});
|
||||
}
|
||||
|
||||
async deactivateGameInvites(): Promise<void> {
|
||||
await this.request("POST", "/api/sessions/invites/deactivate", {});
|
||||
}
|
||||
|
||||
async requestJoinTicket(): Promise<string> {
|
||||
const data = await this.request("POST", "/api/sessions/ticket", {});
|
||||
return data.ticket;
|
||||
}
|
||||
|
||||
async validateJoinTicket(ticket: string): Promise<LceLiveAccount> {
|
||||
return this.request("POST", "/api/sessions/validate", { ticket }, false);
|
||||
}
|
||||
}
|
||||
|
||||
export const lceLiveService = new LceLiveService();
|
||||
@@ -0,0 +1,165 @@
|
||||
import { OptionsFile } from "../types/options";
|
||||
export class OptionsService {
|
||||
public static readOptions(buffer: ArrayBuffer, endianness: "little" | "big" = "little"): OptionsFile {
|
||||
const rawData = new Uint8Array(buffer).slice();
|
||||
const view = new DataView(buffer);
|
||||
const little = endianness === "little";
|
||||
const opt: Partial<OptionsFile> = {
|
||||
endianness,
|
||||
rawData
|
||||
};
|
||||
|
||||
opt.musicVolume = view.getUint8(0x01);
|
||||
opt.soundVolume = view.getUint8(0x02);
|
||||
opt.gameSensitivity = view.getUint8(0x03);
|
||||
opt.gamma = view.getUint8(0x04);
|
||||
opt.interfaceSensitivity = view.getUint8(0x50);
|
||||
opt.interfaceOpacity = view.getUint8(0x51);
|
||||
const val06 = view.getUint16(0x06, little);
|
||||
opt.difficulty = val06 & 0x3;
|
||||
opt.viewBobbing = (val06 & (1 << 2)) !== 0;
|
||||
opt.inGameGamertags = (val06 & (1 << 3)) !== 0;
|
||||
opt.invertLook = (val06 & (1 << 6)) !== 0;
|
||||
opt.southpaw = (val06 & (1 << 7)) !== 0;
|
||||
opt.verticalSplitscreen = (val06 & (1 << 8)) !== 0;
|
||||
opt.splitscreenGamertags = (val06 & (1 << 9)) !== 0;
|
||||
opt.hints = (val06 & (1 << 10)) !== 0;
|
||||
opt.autosaveTimer = (val06 >> 11) & 0xF;
|
||||
opt.inGameTooltips = (val06 & (1 << 15)) !== 0;
|
||||
const val54 = view.getUint32(0x54, little);
|
||||
opt.renderClouds = (val54 & (1 << 0)) !== 0;
|
||||
opt.displayHud = (val54 & (1 << 7)) !== 0;
|
||||
opt.displayHand = (val54 & (1 << 8)) !== 0;
|
||||
opt.customSkinAnimation = (val54 & (1 << 9)) !== 0;
|
||||
opt.deathMessages = (val54 & (1 << 10)) !== 0;
|
||||
opt.hudSize = (val54 >> 11) & 0x3;
|
||||
opt.hudSizeSplitscreen = (val54 >> 13) & 0x3;
|
||||
opt.animatedCharacter = (val54 & (1 << 15)) !== 0;
|
||||
opt.classicCrafting = (val54 & (1 << 18)) !== 0;
|
||||
opt.caveSounds = (val54 & (1 << 19)) !== 0;
|
||||
opt.gameChat = (val54 & (1 << 20)) !== 0;
|
||||
opt.minecartSounds = (val54 & (1 << 21)) !== 0;
|
||||
opt.showGlideGhost = (val54 & (1 << 22)) !== 0;
|
||||
opt.autoJump = (val54 & (1 << 26)) !== 0;
|
||||
opt.displayGameMessages = (val54 & (1 << 28)) !== 0;
|
||||
opt.displaySaveIcon = (val54 & (1 << 29)) !== 0;
|
||||
opt.flyingViewRolling = (val54 & (1 << 30)) === 0;
|
||||
opt.showGlideGhostPath = (val54 & (1 << 31)) !== 0;
|
||||
opt.chosenSkin = view.getUint32(0x4C, little);
|
||||
opt.playerCape = view.getUint32(0x5C, little);
|
||||
opt.favoriteSkins = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
opt.favoriteSkins.push(view.getUint32(0x60 + i * 4, little));
|
||||
}
|
||||
|
||||
opt.actions = {
|
||||
jump: view.getUint8(0xA4),
|
||||
use: view.getUint8(0xA5),
|
||||
action: view.getUint8(0xA6),
|
||||
cycleHeldItemLeft: view.getUint8(0xA7),
|
||||
cycleHeldItemRight: view.getUint8(0xA8),
|
||||
inventory: view.getUint8(0xA9),
|
||||
drop: view.getUint8(0xAA),
|
||||
sneakDismount: view.getUint8(0xAB),
|
||||
crafting: view.getUint8(0xAC),
|
||||
changeCameraMode: view.getUint8(0xAD),
|
||||
flyLeft: view.getUint8(0xAE),
|
||||
flyRight: view.getUint8(0xAF),
|
||||
flyUp: view.getUint8(0xB0),
|
||||
flyDown: view.getUint8(0xB1),
|
||||
sprint: view.getUint8(0xB2),
|
||||
pickBlock: view.getUint8(0xB3),
|
||||
previousPlayer: view.getUint8(0xB4),
|
||||
nextPlayer: view.getUint8(0xB5),
|
||||
spectateNoise: view.getUint8(0xB6),
|
||||
cancelSpectating: view.getUint8(0xB7),
|
||||
confirmReady: view.getUint8(0xB8),
|
||||
vote: view.getUint8(0xB9),
|
||||
restartSection: view.getUint8(0xBA),
|
||||
restartRace: view.getUint8(0xBB),
|
||||
lookBehind: view.getUint8(0xBC)
|
||||
};
|
||||
|
||||
return opt as OptionsFile;
|
||||
}
|
||||
|
||||
public static serializeOptions(opt: OptionsFile): ArrayBuffer {
|
||||
const minSize = 0xBC + 1;
|
||||
const buffer = new Uint8Array(Math.max(opt.rawData.length, minSize));
|
||||
buffer.set(opt.rawData);
|
||||
const view = new DataView(buffer.buffer);
|
||||
const little = opt.endianness === "little";
|
||||
view.setUint8(0x01, opt.musicVolume);
|
||||
view.setUint8(0x02, opt.soundVolume);
|
||||
view.setUint8(0x03, opt.gameSensitivity);
|
||||
view.setUint8(0x04, opt.gamma);
|
||||
view.setUint8(0x50, opt.interfaceSensitivity);
|
||||
view.setUint8(0x51, opt.interfaceOpacity);
|
||||
let val06 = view.getUint16(0x06, little);
|
||||
val06 = (val06 & ~0x3) | (opt.difficulty & 0x3);
|
||||
val06 = (val06 & ~(1 << 2)) | (opt.viewBobbing ? (1 << 2) : 0);
|
||||
val06 = (val06 & ~(1 << 3)) | (opt.inGameGamertags ? (1 << 3) : 0);
|
||||
val06 = (val06 & ~(1 << 6)) | (opt.invertLook ? (1 << 6) : 0);
|
||||
val06 = (val06 & ~(1 << 7)) | (opt.southpaw ? (1 << 7) : 0);
|
||||
val06 = (val06 & ~(1 << 8)) | (opt.verticalSplitscreen ? (1 << 8) : 0);
|
||||
val06 = (val06 & ~(1 << 9)) | (opt.splitscreenGamertags ? (1 << 9) : 0);
|
||||
val06 = (val06 & ~(1 << 10)) | (opt.hints ? (1 << 10) : 0);
|
||||
val06 = (val06 & ~(0xF << 11)) | ((opt.autosaveTimer & 0xF) << 11);
|
||||
val06 = (val06 & ~(1 << 15)) | (opt.inGameTooltips ? (1 << 15) : 0);
|
||||
view.setUint16(0x06, val06, little);
|
||||
let val54 = view.getUint32(0x54, little);
|
||||
val54 = (val54 & ~(1 << 0)) | (opt.renderClouds ? (1 << 0) : 0);
|
||||
val54 = (val54 & ~(1 << 7)) | (opt.displayHud ? (1 << 7) : 0);
|
||||
val54 = (val54 & ~(1 << 8)) | (opt.displayHand ? (1 << 8) : 0);
|
||||
val54 = (val54 & ~(1 << 9)) | (opt.customSkinAnimation ? (1 << 9) : 0);
|
||||
val54 = (val54 & ~(1 << 10)) | (opt.deathMessages ? (1 << 10) : 0);
|
||||
val54 = (val54 & ~(0x3 << 11)) | ((opt.hudSize & 0x3) << 11);
|
||||
val54 = (val54 & ~(0x3 << 13)) | ((opt.hudSizeSplitscreen & 0x3) << 13);
|
||||
val54 = (val54 & ~(1 << 15)) | (opt.animatedCharacter ? (1 << 15) : 0);
|
||||
val54 = (val54 & ~(1 << 18)) | (opt.classicCrafting ? (1 << 18) : 0);
|
||||
val54 = (val54 & ~(1 << 19)) | (opt.caveSounds ? (1 << 19) : 0);
|
||||
val54 = (val54 & ~(1 << 20)) | (opt.gameChat ? (1 << 20) : 0);
|
||||
val54 = (val54 & ~(1 << 21)) | (opt.minecartSounds ? (1 << 21) : 0);
|
||||
val54 = (val54 & ~(1 << 22)) | (opt.showGlideGhost ? (1 << 22) : 0);
|
||||
val54 = (val54 & ~(1 << 26)) | (opt.autoJump ? (1 << 26) : 0);
|
||||
val54 = (val54 & ~(1 << 28)) | (opt.displayGameMessages ? (1 << 28) : 0);
|
||||
val54 = (val54 & ~(1 << 29)) | (opt.displaySaveIcon ? (1 << 29) : 0);
|
||||
val54 = (val54 & ~(1 << 30)) | (!opt.flyingViewRolling ? (1 << 30) : 0);
|
||||
val54 = (val54 & ~(1 << 31)) | (opt.showGlideGhostPath ? (1 << 31) : 0);
|
||||
view.setUint32(0x54, val54, little);
|
||||
view.setUint32(0x4C, opt.chosenSkin, little);
|
||||
view.setUint32(0x5C, opt.playerCape, little);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (opt.favoriteSkins[i] !== undefined) {
|
||||
view.setUint32(0x60 + i * 4, opt.favoriteSkins[i], little);
|
||||
}
|
||||
}
|
||||
|
||||
view.setUint8(0xA4, opt.actions.jump);
|
||||
view.setUint8(0xA5, opt.actions.use);
|
||||
view.setUint8(0xA6, opt.actions.action);
|
||||
view.setUint8(0xA7, opt.actions.cycleHeldItemLeft);
|
||||
view.setUint8(0xA8, opt.actions.cycleHeldItemRight);
|
||||
view.setUint8(0xA9, opt.actions.inventory);
|
||||
view.setUint8(0xAA, opt.actions.drop);
|
||||
view.setUint8(0xAB, opt.actions.sneakDismount);
|
||||
view.setUint8(0xAC, opt.actions.crafting);
|
||||
view.setUint8(0xAD, opt.actions.changeCameraMode);
|
||||
view.setUint8(0xAE, opt.actions.flyLeft);
|
||||
view.setUint8(0xAF, opt.actions.flyRight);
|
||||
view.setUint8(0xB0, opt.actions.flyUp);
|
||||
view.setUint8(0xB1, opt.actions.flyDown);
|
||||
view.setUint8(0xB2, opt.actions.sprint);
|
||||
view.setUint8(0xB3, opt.actions.pickBlock);
|
||||
view.setUint8(0xB4, opt.actions.previousPlayer);
|
||||
view.setUint8(0xB5, opt.actions.nextPlayer);
|
||||
view.setUint8(0xB6, opt.actions.spectateNoise);
|
||||
view.setUint8(0xB7, opt.actions.cancelSpectating);
|
||||
view.setUint8(0xB8, opt.actions.confirmReady);
|
||||
view.setUint8(0xB9, opt.actions.vote);
|
||||
view.setUint8(0xBA, opt.actions.restartSection);
|
||||
view.setUint8(0xBB, opt.actions.restartRace);
|
||||
view.setUint8(0xBC, opt.actions.lookBehind);
|
||||
return buffer.buffer;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { setActivity, start } from "tauri-plugin-drpc";
|
||||
import { Activity, ActivityType, Assets, Timestamps, Button } from "tauri-plugin-drpc/activity";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
ActivityType,
|
||||
Assets,
|
||||
Timestamps,
|
||||
Button,
|
||||
} from "tauri-plugin-drpc/activity";
|
||||
class RPC {
|
||||
private startTime: number = Date.now();
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private initialized: boolean = false;
|
||||
|
||||
public async StartRPC() {
|
||||
if (this.initialized) return;
|
||||
if (sessionStorage.getItem('lce_rpc_started') === 'true') {
|
||||
if (sessionStorage.getItem("lce_rpc_started") === "true") {
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
@@ -17,7 +21,7 @@ class RPC {
|
||||
this.initializationPromise = (async () => {
|
||||
try {
|
||||
await start("1482504445152460871");
|
||||
sessionStorage.setItem('lce_rpc_started', 'true');
|
||||
sessionStorage.setItem("lce_rpc_started", "true");
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to start RPC:", e);
|
||||
@@ -28,7 +32,11 @@ class RPC {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
public async updateActivity(details: string, state: string, isPlaying: boolean = false) {
|
||||
public async updateActivity(
|
||||
details: string,
|
||||
state: string,
|
||||
isPlaying: boolean = false,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.StartRPC();
|
||||
if (!this.initialized) return;
|
||||
@@ -38,19 +46,16 @@ class RPC {
|
||||
activity.setDetails(details);
|
||||
activity.setState(state);
|
||||
activity.setActivity(ActivityType.Playing);
|
||||
|
||||
const assets = new Assets();
|
||||
assets.setLargeImage("logo");
|
||||
assets.setLargeText("LCE Emerald Launcher");
|
||||
assets.setSmallImage("app-icon");
|
||||
assets.setSmallText(isPlaying ? "Playing" : "In Menus");
|
||||
activity.setAssets(assets);
|
||||
|
||||
activity.setTimestamps(new Timestamps(this.startTime));
|
||||
|
||||
activity.setButton([
|
||||
new Button("Discord", "https://discord.gg/vD8akRU24f"),
|
||||
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher")
|
||||
new Button("Discord", "https://discord.gg/cQVKhQXcCx"),
|
||||
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher"),
|
||||
]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
export interface ScreenshotInfo {
|
||||
path: string;
|
||||
instanceId: string;
|
||||
name: string;
|
||||
date: number;
|
||||
}
|
||||
|
||||
export class ScreenshotService {
|
||||
static async getScreenshots(): Promise<ScreenshotInfo[]> {
|
||||
return invoke("get_screenshots");
|
||||
}
|
||||
|
||||
static async deleteScreenshot(path: string): Promise<void> {
|
||||
return invoke("delete_screenshot", { path });
|
||||
}
|
||||
|
||||
static async showInFolder(path: string): Promise<void> {
|
||||
return invoke("open_screenshot_folder", { path });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import pako from "pako";
|
||||
export interface SwfTag {
|
||||
code: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface SwfImage {
|
||||
id: number;
|
||||
type: "jpeg" | "png" | "lossless" | "unknown" | "gif";
|
||||
name?: string;
|
||||
data: Uint8Array;
|
||||
alphaData?: Uint8Array;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: number;
|
||||
hasAlpha?: boolean;
|
||||
}
|
||||
|
||||
export class SwfService {
|
||||
static parse(buffer: Uint8Array): { version: number; compressed: boolean; frameHeader: Uint8Array; tags: SwfTag[] } {
|
||||
if (buffer.length < 8) throw new Error("Invalid SWF: file too small");
|
||||
const sig = String.fromCharCode(buffer[0], buffer[1], buffer[2]);
|
||||
const version = buffer[3];
|
||||
let data: Uint8Array;
|
||||
if (sig === "FWS") {
|
||||
data = buffer.slice(8);
|
||||
} else if (sig === "CWS") {
|
||||
try {
|
||||
data = pako.inflate(buffer.slice(8));
|
||||
} catch (e) {
|
||||
throw new Error("Failed to decompress CWS SWF");
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported SWF signature: ${sig}`);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const nbits = data[0] >> 3;
|
||||
const rectBytes = Math.ceil((5 + nbits * 4) / 8);
|
||||
const frameHeaderLength = rectBytes + 4;
|
||||
const frameHeader = data.slice(0, frameHeaderLength);
|
||||
offset = frameHeaderLength;
|
||||
|
||||
const tags: SwfTag[] = [];
|
||||
while (offset < data.length) {
|
||||
const tagHeader = data[offset] | (data[offset + 1] << 8);
|
||||
offset += 2;
|
||||
const code = tagHeader >> 6;
|
||||
let length = tagHeader & 0x3f;
|
||||
if (length === 0x3f) {
|
||||
length = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
|
||||
offset += 4;
|
||||
}
|
||||
const tagData = data.slice(offset, offset + length);
|
||||
tags.push({ code, data: tagData });
|
||||
offset += length;
|
||||
if (code === 0) break;
|
||||
}
|
||||
|
||||
return { version, compressed: sig === "CWS", frameHeader, tags };
|
||||
}
|
||||
|
||||
static serialize(version: number, compressed: boolean, frameHeader: Uint8Array, tags: SwfTag[]): Uint8Array {
|
||||
const payloads: Uint8Array[] = [frameHeader];
|
||||
let bodyLength = frameHeader.length;
|
||||
|
||||
for (const tag of tags) {
|
||||
const isLong = tag.data.length >= 0x3f;
|
||||
const headerLen = isLong ? 6 : 2;
|
||||
const header = new Uint8Array(headerLen);
|
||||
const h = (tag.code << 6) | (isLong ? 0x3f : tag.data.length);
|
||||
header[0] = h & 0xff;
|
||||
header[1] = (h >> 8) & 0xff;
|
||||
if (isLong) {
|
||||
header[2] = tag.data.length & 0xff;
|
||||
header[3] = (tag.data.length >> 8) & 0xff;
|
||||
header[4] = (tag.data.length >> 16) & 0xff;
|
||||
header[5] = (tag.data.length >> 24) & 0xff;
|
||||
}
|
||||
payloads.push(header, tag.data);
|
||||
bodyLength += headerLen + tag.data.length;
|
||||
}
|
||||
|
||||
const uncompressedBody = new Uint8Array(bodyLength);
|
||||
let offset = 0;
|
||||
for (const p of payloads) {
|
||||
uncompressedBody.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
|
||||
const fileLength = 8 + uncompressedBody.length;
|
||||
const finalBody = compressed ? pako.deflate(uncompressedBody) : uncompressedBody;
|
||||
const result = new Uint8Array(8 + finalBody.length);
|
||||
result.set(new TextEncoder().encode(compressed ? "CWS" : "FWS"), 0);
|
||||
result[3] = version;
|
||||
result[4] = fileLength & 0xff;
|
||||
result[5] = (fileLength >> 8) & 0xff;
|
||||
result[6] = (fileLength >> 16) & 0xff;
|
||||
result[7] = (fileLength >> 24) & 0xff;
|
||||
result.set(finalBody, 8);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static extractImages(tags: SwfTag[]): SwfImage[] {
|
||||
const images: SwfImage[] = [];
|
||||
const nameMap: Record<number, string> = {};
|
||||
let jpegTables: Uint8Array | null = null;
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.code === 8) {
|
||||
jpegTables = tag.data;
|
||||
} else if (tag.code === 56 || tag.code === 76) {
|
||||
const numAssets = tag.data[0] | (tag.data[1] << 8);
|
||||
let ptr = 2;
|
||||
for (let i = 0; i < numAssets; i++) {
|
||||
const charId = tag.data[ptr] | (tag.data[ptr + 1] << 8);
|
||||
ptr += 2;
|
||||
let end = ptr;
|
||||
while (end < tag.data.length && tag.data[end] !== 0) end++;
|
||||
nameMap[charId] = new TextDecoder().decode(tag.data.slice(ptr, end));
|
||||
ptr = end + 1;
|
||||
}
|
||||
} else if (tag.code === 6 || tag.code === 21) {
|
||||
const charId = tag.data[0] | (tag.data[1] << 8);
|
||||
const imgData = tag.data.slice(2);
|
||||
const fixed = this.fixJpeg(imgData, tag.code === 6 ? jpegTables : null);
|
||||
images.push({ id: charId, type: "jpeg", data: fixed, name: nameMap[charId] });
|
||||
} else if (tag.code === 35) {
|
||||
const charId = tag.data[0] | (tag.data[1] << 8);
|
||||
const alphaOffset = tag.data[2] | (tag.data[3] << 8) | (tag.data[4] << 16) | (tag.data[5] << 24);
|
||||
const imgData = tag.data.slice(6, 6 + alphaOffset);
|
||||
const alphaRaw = tag.data.slice(6 + alphaOffset);
|
||||
let alpha = undefined;
|
||||
try { alpha = pako.inflate(alphaRaw); } catch (e) { }
|
||||
images.push({ id: charId, type: "jpeg", data: this.fixJpeg(imgData), alphaData: alpha, name: nameMap[charId] });
|
||||
} else if (tag.code === 20 || tag.code === 36) {
|
||||
const charId = tag.data[0] | (tag.data[1] << 8);
|
||||
const format = tag.data[2];
|
||||
const width = tag.data[3] | (tag.data[4] << 8);
|
||||
const height = tag.data[5] | (tag.data[6] << 8);
|
||||
const hasAlpha = tag.code === 36;
|
||||
const raw = tag.data.slice(tag.code === 20 ? 7 : 7);
|
||||
|
||||
images.push({
|
||||
id: charId,
|
||||
type: "lossless",
|
||||
data: raw,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
hasAlpha,
|
||||
name: nameMap[charId]
|
||||
});
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
private static fixJpeg(data: Uint8Array, jtt: Uint8Array | null = null): Uint8Array {
|
||||
let combined = data;
|
||||
if (jtt && jtt.length > 0) {
|
||||
let tLen = jtt.length;
|
||||
if (jtt[tLen - 2] === 0xff && jtt[tLen - 1] === 0xd9) tLen -= 2;
|
||||
let start = 0;
|
||||
if (data[0] === 0xff && data[1] === 0xd8) start = 2;
|
||||
combined = new Uint8Array(tLen + data.length - start);
|
||||
combined.set(jtt.slice(0, tLen), 0);
|
||||
combined.set(data.slice(start), tLen);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
static async decodeLosslessToRGBA(img: SwfImage): Promise<Uint8Array> {
|
||||
if (img.type !== "lossless" || !img.width || !img.height) return new Uint8Array(0);
|
||||
const decoded = pako.inflate(img.data);
|
||||
const rgba = new Uint8Array(img.width * img.height * 4);
|
||||
if (img.format === 3) {
|
||||
const colorTableSize = decoded[0] + 1;
|
||||
let ptr = 1;
|
||||
const palette: number[][] = [];
|
||||
for (let i = 0; i < colorTableSize; i++) {
|
||||
if (img.hasAlpha) {
|
||||
const a = decoded[ptr++];
|
||||
const r_pm = decoded[ptr++];
|
||||
const g_pm = decoded[ptr++];
|
||||
const b_pm = decoded[ptr++];
|
||||
palette.push([
|
||||
a > 0 ? Math.min(255, (r_pm * 255) / a) : 0,
|
||||
a > 0 ? Math.min(255, (g_pm * 255) / a) : 0,
|
||||
a > 0 ? Math.min(255, (b_pm * 255) / a) : 0,
|
||||
a
|
||||
]);
|
||||
} else {
|
||||
palette.push([decoded[ptr++], decoded[ptr++], decoded[ptr++], 255]);
|
||||
}
|
||||
}
|
||||
const rowStride = Math.ceil(img.width / 4) * 4;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const idx = decoded[ptr + y * rowStride + x];
|
||||
const color = palette[idx] || [0, 0, 0, 0];
|
||||
const offset = (y * img.width + x) * 4;
|
||||
rgba[offset] = color[0];
|
||||
rgba[offset + 1] = color[1];
|
||||
rgba[offset + 2] = color[2];
|
||||
rgba[offset + 3] = color[3];
|
||||
}
|
||||
}
|
||||
} else if (img.format === 4) {
|
||||
const rowStride = Math.ceil((img.width * 2) / 4) * 4;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const p = decoded[y * rowStride + x * 2] | (decoded[y * rowStride + x * 2 + 1] << 8);
|
||||
const r = ((p >> 10) & 0x1f) << 3;
|
||||
const g = ((p >> 5) & 0x1f) << 3;
|
||||
const b = (p & 0x1f) << 3;
|
||||
const offset = (y * img.width + x) * 4;
|
||||
rgba[offset] = r;
|
||||
rgba[offset + 1] = g;
|
||||
rgba[offset + 2] = b;
|
||||
rgba[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
} else if (img.format === 5) {
|
||||
let ptr = 0;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const a = decoded[ptr++];
|
||||
const r_pm = decoded[ptr++];
|
||||
const g_pm = decoded[ptr++];
|
||||
const b_pm = decoded[ptr++];
|
||||
const offset = (y * img.width + x) * 4;
|
||||
if (img.hasAlpha) {
|
||||
rgba[offset] = a > 0 ? Math.min(255, (r_pm * 255) / a) : 0;
|
||||
rgba[offset + 1] = a > 0 ? Math.min(255, (g_pm * 255) / a) : 0;
|
||||
rgba[offset + 2] = a > 0 ? Math.min(255, (b_pm * 255) / a) : 0;
|
||||
rgba[offset + 3] = a;
|
||||
} else {
|
||||
rgba[offset] = r_pm;
|
||||
rgba[offset + 1] = g_pm;
|
||||
rgba[offset + 2] = b_pm;
|
||||
rgba[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
static updateImageTag(tags: SwfTag[], charId: number, newData: Uint8Array, type: SwfImage["type"]): SwfTag[] {
|
||||
return tags.map(tag => {
|
||||
if (tag.code === 6 || tag.code === 21 || tag.code === 35 || tag.code === 20 || tag.code === 36) {
|
||||
const id = tag.data[0] | (tag.data[1] << 8);
|
||||
if (id === charId) {
|
||||
let payload: Uint8Array;
|
||||
if (type === "jpeg") {
|
||||
const header = new Uint8Array(tag.code === 35 ? 6 : 2);
|
||||
header[0] = id & 0xff;
|
||||
header[1] = (id >> 8) & 0xff;
|
||||
if (tag.code === 35) {
|
||||
payload = newData;
|
||||
} else {
|
||||
payload = new Uint8Array(2 + newData.length);
|
||||
payload.set(header, 0);
|
||||
payload.set(newData, 2);
|
||||
}
|
||||
} else {
|
||||
payload = newData;
|
||||
}
|
||||
return { ...tag, data: payload };
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export interface CustomEdition {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
path?: string;
|
||||
category?: string[];
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -36,6 +39,7 @@ export interface AppConfig {
|
||||
musicVol?: number;
|
||||
sfxVol?: number;
|
||||
legacyMode?: boolean;
|
||||
mangohudEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ThemePalette {
|
||||
@@ -57,6 +61,12 @@ export interface MacOSSetupProgress {
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
export interface InstalledWorkshopPackage {
|
||||
instanceId: string;
|
||||
packageId: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export class TauriService {
|
||||
static async saveConfig(config: AppConfig): Promise<void> {
|
||||
return invoke("save_config", { config });
|
||||
@@ -124,8 +134,23 @@ export class TauriService {
|
||||
return invoke("sync_dlc", { instanceId });
|
||||
}
|
||||
|
||||
static async workshopInstall(instanceId: string, packageId: string, zips: Record<string, string>): Promise<void> {
|
||||
return invoke("workshop_install", { request: { instanceId, packageId, zips } });
|
||||
static async workshopInstall(
|
||||
instanceId: string,
|
||||
packageId: string,
|
||||
zips: Record<string, string>,
|
||||
version: string,
|
||||
): Promise<void> {
|
||||
return invoke("workshop_install", {
|
||||
request: { instanceId, packageId, zips, version },
|
||||
});
|
||||
}
|
||||
|
||||
static async workshopUninstall(instanceId: string, packageId: string): Promise<void> {
|
||||
return invoke("workshop_uninstall", { instanceId, packageId });
|
||||
}
|
||||
|
||||
static async workshopListInstalled(): Promise<InstalledWorkshopPackage[]> {
|
||||
return invoke("workshop_list_installed");
|
||||
}
|
||||
|
||||
static onDownloadProgress(callback: (percent: number) => void) {
|
||||
@@ -169,4 +194,94 @@ export class TauriService {
|
||||
static async fetchSkin(username: string): Promise<[string, string]> {
|
||||
return invoke("fetch_skin", { username });
|
||||
}
|
||||
|
||||
static async saveGlobalSkinPck(pckData: Uint8Array): Promise<void> {
|
||||
return invoke("save_global_skin_pck", { pckData: Array.from(pckData) });
|
||||
}
|
||||
|
||||
static async checkGameUpdate(instanceId: string, url: string): Promise<boolean> {
|
||||
return invoke("check_game_update", { instanceId, url });
|
||||
}
|
||||
|
||||
static async pickFolder(): Promise<string> {
|
||||
return invoke("pick_folder");
|
||||
}
|
||||
|
||||
static async pickFile(title: string, filters: string[]): Promise<string> {
|
||||
return invoke("pick_file", { title, filters });
|
||||
}
|
||||
|
||||
static async saveFileDialog(title: string, filename: string, filters: string[]): Promise<string> {
|
||||
return invoke("save_file_dialog", { title, filename, filters });
|
||||
}
|
||||
|
||||
static async writeBinaryFile(path: string, data: Uint8Array): Promise<void> {
|
||||
return invoke("write_binary_file", { path, data: Array.from(data) });
|
||||
}
|
||||
|
||||
static async readBinaryFile(path: string): Promise<Uint8Array> {
|
||||
const data: number[] = await invoke("read_binary_file", { path });
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
|
||||
static async downloadLogo(id: string, url: string): Promise<string> {
|
||||
return invoke("download_logo", { id, url });
|
||||
}
|
||||
|
||||
static async addToSteam(
|
||||
instanceId: string,
|
||||
name: string,
|
||||
titleBase64: string,
|
||||
panoramaBase64: string
|
||||
): Promise<void> {
|
||||
return invoke("add_to_steam", { instanceId, name, titleBase64, panoramaBase64 });
|
||||
}
|
||||
|
||||
static async stunDiscover(): Promise<{ ip: string; port: number }> {
|
||||
return invoke("stun_discover");
|
||||
}
|
||||
|
||||
static async startDirectProxy(targetIp: string, targetPort: number): Promise<number> {
|
||||
return invoke("start_direct_proxy", { targetIp, targetPort });
|
||||
}
|
||||
|
||||
static async startRelayProxy(apiBaseUrl: string, accessToken: string, sessionId: string): Promise<number> {
|
||||
return invoke("start_relay_proxy", { apiBaseUrl, accessToken, sessionId });
|
||||
}
|
||||
|
||||
static async startHostRelay(apiBaseUrl: string, accessToken: string, sessionId: string, gamePort: number): Promise<void> {
|
||||
return invoke("start_host_relay", { apiBaseUrl, accessToken, sessionId, gamePort });
|
||||
}
|
||||
|
||||
static async stopProxy(): Promise<void> {
|
||||
return invoke("stop_proxy");
|
||||
}
|
||||
|
||||
static async joinGame(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
hostIp: string,
|
||||
hostPort: number,
|
||||
sessionId: string,
|
||||
instanceId: string,
|
||||
): Promise<void> {
|
||||
return invoke("join_game", { apiBaseUrl, accessToken, hostIp, hostPort, sessionId, instanceId });
|
||||
}
|
||||
|
||||
static async httpProxyRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: string | null,
|
||||
headers: Record<string, string>
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return invoke("http_proxy_request", { method, url, body, headers });
|
||||
}
|
||||
|
||||
static async getInstancePath(instanceId: string): Promise<string> {
|
||||
return invoke("get_instance_path", { instanceId });
|
||||
}
|
||||
|
||||
static async readScreenshotAsDataUrl(path: string): Promise<string> {
|
||||
return invoke("read_screenshot_as_data_url", { path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// empty :P
|
||||
// what is this file lol ? - kay
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface ColColor {
|
||||
name: string;
|
||||
color: number;
|
||||
}
|
||||
|
||||
export interface ColWorldColor {
|
||||
name: string;
|
||||
waterColor: number;
|
||||
underwaterColor: number;
|
||||
fogColor: number;
|
||||
}
|
||||
|
||||
export interface ColFile {
|
||||
version: number;
|
||||
colors: ColColor[];
|
||||
worldColors: ColWorldColor[];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export enum GrfCompressionLevel {
|
||||
None = 0,
|
||||
Compressed = 1,
|
||||
CompressedRle = 2,
|
||||
CompressedRleCrc = 3,
|
||||
}
|
||||
|
||||
export enum GrfCompressionType {
|
||||
Unknown = -1,
|
||||
Zlib = 0,
|
||||
Deflate = 1,
|
||||
XMem = 2,
|
||||
}
|
||||
|
||||
export interface GrfHeader {
|
||||
compressionLevel: GrfCompressionLevel;
|
||||
crc: number;
|
||||
compressionType: GrfCompressionType;
|
||||
unknownData: Uint8Array;
|
||||
}
|
||||
|
||||
export interface GrfFileEntry {
|
||||
filename: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface GrfParameter {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GrfNode {
|
||||
name: string;
|
||||
parameters: GrfParameter[];
|
||||
children: GrfNode[];
|
||||
}
|
||||
|
||||
export interface GrfFile {
|
||||
header: GrfHeader;
|
||||
files: GrfFileEntry[];
|
||||
root: GrfNode;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface OptionsFile {
|
||||
endianness: "little" | "big";
|
||||
musicVolume: number;
|
||||
soundVolume: number;
|
||||
gamma: number;
|
||||
gameSensitivity: number;
|
||||
interfaceSensitivity: number;
|
||||
interfaceOpacity: number;
|
||||
difficulty: number;
|
||||
viewBobbing: boolean;
|
||||
invertLook: boolean;
|
||||
southpaw: boolean;
|
||||
verticalSplitscreen: boolean;
|
||||
inGameGamertags: boolean;
|
||||
autosaveTimer: number;
|
||||
splitscreenGamertags: boolean;
|
||||
hints: boolean;
|
||||
inGameTooltips: boolean;
|
||||
renderClouds: boolean;
|
||||
displayHud: boolean;
|
||||
displayHand: boolean;
|
||||
customSkinAnimation: boolean;
|
||||
deathMessages: boolean;
|
||||
hudSize: number;
|
||||
hudSizeSplitscreen: number;
|
||||
animatedCharacter: boolean;
|
||||
classicCrafting: boolean;
|
||||
caveSounds: boolean;
|
||||
gameChat: boolean;
|
||||
minecartSounds: boolean;
|
||||
displayGameMessages: boolean;
|
||||
displaySaveIcon: boolean;
|
||||
flyingViewRolling: boolean;
|
||||
showGlideGhost: boolean;
|
||||
showGlideGhostPath: boolean;
|
||||
autoJump: boolean;
|
||||
chosenSkin: number;
|
||||
playerCape: number;
|
||||
favoriteSkins: number[];
|
||||
actions: {
|
||||
jump: number;
|
||||
use: number;
|
||||
action: number;
|
||||
cycleHeldItemLeft: number;
|
||||
cycleHeldItemRight: number;
|
||||
inventory: number;
|
||||
drop: number;
|
||||
sneakDismount: number;
|
||||
crafting: number;
|
||||
changeCameraMode: number;
|
||||
flyLeft: number;
|
||||
flyRight: number;
|
||||
flyUp: number;
|
||||
flyDown: number;
|
||||
sprint: number;
|
||||
pickBlock: number;
|
||||
previousPlayer: number;
|
||||
nextPlayer: number;
|
||||
spectateNoise: number;
|
||||
cancelSpectating: number;
|
||||
confirmReady: number;
|
||||
vote: number;
|
||||
restartSection: number;
|
||||
restartRace: number;
|
||||
lookBehind: number;
|
||||
};
|
||||
|
||||
rawData: Uint8Array;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// empty :P
|
||||
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __BUILD_DATE__: string;
|
||||
|
||||
@@ -7,6 +7,9 @@ export default defineConfig({
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
define: {
|
||||
__BUILD_DATE__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
|
||||