Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d530fe258 | |||
| 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
|
||||
|
||||
@@ -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,43 @@
|
||||
{
|
||||
"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",
|
||||
"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 |
|
Before Width: | Height: | Size: 7.8 KiB |
|
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: 3.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -47,6 +47,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[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 +290,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 +369,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 +530,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 +648,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"
|
||||
@@ -911,6 +960,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,15 +988,18 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "emerald-legacy-launcher"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"futures-util",
|
||||
"image 0.24.9",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"reqwest 0.11.27",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"steam_shortcuts_util",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-drpc",
|
||||
@@ -1055,6 +1113,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"
|
||||
@@ -1424,6 +1497,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 +1710,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"
|
||||
@@ -1990,6 +2084,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 +2275,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 +2347,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 +2518,12 @@ 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 = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2514,6 +2647,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"
|
||||
@@ -3212,6 +3366,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 +3530,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"
|
||||
@@ -4059,6 +4242,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"
|
||||
@@ -4279,7 +4474,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http 1.4.0",
|
||||
"image",
|
||||
"image 0.25.10",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4622,6 +4817,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"
|
||||
@@ -5440,6 +5646,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"
|
||||
@@ -6361,6 +6573,15 @@ 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"
|
||||
@@ -26,3 +26,6 @@ tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
base64 = "0.21"
|
||||
rfd = "0.15"
|
||||
libc = "0.2"
|
||||
image = "0.24"
|
||||
percent-encoding = "2.3"
|
||||
steam_shortcuts_util = "1.1.8"
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
{
|
||||
"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",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-reveal-item-in-dir",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]}]}}
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LCE Emerald Launcher",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"identifier": "com.emerald.legacy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -24,10 +24,7 @@
|
||||
"center": true,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
@@ -42,8 +39,10 @@
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -8,10 +8,10 @@ interface PanoramaProps {
|
||||
|
||||
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 PANORAMA_PROFILES = ['legacy_evolved', 'vanilla_tu19', '360revived', 'vanilla_tu24'];
|
||||
const baseId = profile;
|
||||
const profileId = PANORAMA_PROFILES.includes(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);
|
||||
|
||||
|
||||
@@ -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,9 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
} else if (focusIndex === 3) {
|
||||
playPressSound();
|
||||
setSkinUrl('/images/Default.png');
|
||||
} else if (focusIndex === 4) {
|
||||
playPressSound();
|
||||
setActiveView('screenshots');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -265,18 +281,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 +336,16 @@ 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>
|
||||
</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}
|
||||
|
||||
@@ -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";
|
||||
@@ -47,17 +48,17 @@ const HomeView = memo(function HomeView() {
|
||||
: isGameRunning
|
||||
? stopGame
|
||||
: isDownloading
|
||||
? () => {}
|
||||
? () => { }
|
||||
: isInstalled
|
||||
? handleLaunch
|
||||
: () => toggleInstall(profile),
|
||||
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 +106,51 @@ 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();
|
||||
}
|
||||
}}
|
||||
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>
|
||||
<div key={i} className="relative w-full group">
|
||||
<button
|
||||
onMouseEnter={() => isFocusedSection && !btn.disabled && setMenuFocus(i)}
|
||||
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/NPMp9JhGYH"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
@@ -180,7 +192,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,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,459 @@
|
||||
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";
|
||||
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">
|
||||
<img
|
||||
src={`screenshots://localhost/${ss.path.replace(/\\/g, "/")}`}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
alt={ss.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"/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">
|
||||
<img
|
||||
src={`screenshots://localhost/${selectedScreenshot.path.replace(/\\/g, "/")}`}
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"/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;
|
||||
@@ -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,12 @@ 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 {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
interface DeleteConfirmButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -22,9 +26,8 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${
|
||||
isDanger ? "text-red-500" : "text-white"
|
||||
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
|
||||
className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${isDanger ? "text-red-500" : "text-white"
|
||||
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
|
||||
style={{
|
||||
backgroundImage: isHovered
|
||||
? "url('/images/button_highlighted.png')"
|
||||
@@ -40,25 +43,46 @@ 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 [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 +103,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 +112,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 +165,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 +181,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}
|
||||
@@ -195,7 +214,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
Versions
|
||||
</h2>
|
||||
|
||||
<div
|
||||
<div
|
||||
className="w-full min-w-[480px] p-6 mb-4"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
@@ -210,23 +229,22 @@ 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 (
|
||||
<div
|
||||
key={edition.id}
|
||||
data-index={i}
|
||||
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${
|
||||
isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
|
||||
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${
|
||||
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
|
||||
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""
|
||||
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
|
||||
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
|
||||
>
|
||||
<div className="w-6 flex items-center justify-center flex-shrink-0">
|
||||
@@ -258,272 +276,280 @@ 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" : ""
|
||||
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
|
||||
<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}
|
||||
<img
|
||||
src={
|
||||
edition.logo.startsWith("http") ||
|
||||
edition.logo.startsWith("/images")
|
||||
? edition.logo
|
||||
: `screenshots://localhost/${edition.logo.replace(/\\/g, "/")}`
|
||||
}
|
||||
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"
|
||||
}`}
|
||||
className={`text-xl tracking-wide truncate ${isSelected ? "text-white" : "text-black"
|
||||
}`}
|
||||
style={{ textShadow: "none" }}
|
||||
>
|
||||
{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)
|
||||
? "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]"}`}
|
||||
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,
|
||||
}}
|
||||
disabled={!!downloadingId}
|
||||
>
|
||||
<img
|
||||
src="/images/Download_Icon.png"
|
||||
alt="Download"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={isDownloading ? "/images/Trash_Bin_Icon.png" : "/images/Download_Icon.png"}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain"
|
||||
style={{ imageRendering: "pixelated", filter: isDownloading ? "hue-rotate(300deg)" : "none" }}
|
||||
/>
|
||||
</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>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(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",
|
||||
)}
|
||||
{Array.isArray(edition.branches) && edition.branches.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
cycleBranch(edition.id);
|
||||
}}
|
||||
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"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<img src="/images/Folder_Icon.png" 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>
|
||||
{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"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<img src="/images/Trash_Bin_Icon.png" 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 +557,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 +582,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
onClose={() => {
|
||||
setIsImportModalOpen(false);
|
||||
setEditingEdition(null);
|
||||
setInitialPath("");
|
||||
}}
|
||||
onImport={(ed: any) => {
|
||||
if (editingEdition) {
|
||||
@@ -567,6 +595,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
editingEdition={editingEdition}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
|
||||
{deleteConfirmEdition && (
|
||||
@@ -584,7 +613,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,
|
||||
@@ -73,7 +72,11 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
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, [
|
||||
@@ -180,8 +183,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}>
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
* {
|
||||
cursor: url('/images/pointer.png') 16 16, auto;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #000;
|
||||
@@ -96,7 +100,6 @@ body {
|
||||
background: url('/images/Slider_Handle.png') no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@@ -118,7 +121,6 @@ body {
|
||||
background: url('/images/Slider_Handle.png') no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@@ -129,4 +131,97 @@ body {
|
||||
.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,10 @@ 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[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
TauriService.loadConfig().then((config) => {
|
||||
if (config.username) setUsername(config.username);
|
||||
|
||||
@@ -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,14 @@ export function useDiscordRPC({
|
||||
devtools: "Developing for LCE",
|
||||
skins: "Changing Skins",
|
||||
workshop: "Browsing Workshop",
|
||||
"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,12 +1,27 @@
|
||||
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"
|
||||
@@ -15,7 +30,7 @@ const BASE_EDITIONS = [
|
||||
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,
|
||||
},
|
||||
@@ -27,6 +42,15 @@ const BASE_EDITIONS = [
|
||||
titleImage: "/images/minecraft_title_360revived.png",
|
||||
supportsSlimSkins: false,
|
||||
logo: "/images/360_revived.png"
|
||||
},
|
||||
{
|
||||
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"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -45,6 +69,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 +101,173 @@ export function useGameManager({
|
||||
number | null
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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);
|
||||
|
||||
const editions = useMemo(
|
||||
() => [...BASE_EDITIONS, ...customEditions],
|
||||
[customEditions],
|
||||
);
|
||||
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 +306,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 +378,12 @@ 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 +400,7 @@ 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 +408,21 @@ 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 +443,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 () => {
|
||||
|
||||
@@ -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,50 @@
|
||||
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;
|
||||
}
|
||||
|
||||
declare const __BUILD_DATE__: string;
|
||||
const LATEST_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/latest";
|
||||
const NIGHTLY_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/tags/nightly";
|
||||
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 buildDate = new Date(__BUILD_DATE__);
|
||||
let isNightly = false;
|
||||
let latestData = null;
|
||||
let latestDate = new Date(0);
|
||||
try {
|
||||
const latestResponse = await fetch(LATEST_URL);
|
||||
if (latestResponse.ok) {
|
||||
latestData = await latestResponse.json();
|
||||
latestDate = new Date(latestData.published_at);
|
||||
if (buildDate > latestDate) {
|
||||
isNightly = true;
|
||||
}
|
||||
} else {
|
||||
isNightly = true;
|
||||
}
|
||||
} catch (e) {
|
||||
isNightly = true;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name.replace(/^v/, '');
|
||||
if (isNightly) {
|
||||
const nightlyResponse = await fetch(NIGHTLY_URL);
|
||||
if (!nightlyResponse.ok) return;
|
||||
const nightlyData = await nightlyResponse.json();
|
||||
let releaseDate = new Date(nightlyData.published_at || nightlyData.updated_at);
|
||||
if (nightlyData.assets && nightlyData.assets.length > 0) {
|
||||
const assetDate = new Date(nightlyData.assets[0].updated_at);
|
||||
if (assetDate > releaseDate) releaseDate = assetDate;
|
||||
}
|
||||
|
||||
if (isNewerVersion(latestVersion, CURRENT_VERSION)) {
|
||||
setUpdateMessage(`Version ${data.tag_name} is now available!`);
|
||||
if (releaseDate > buildDate) {
|
||||
setUpdateMessage(`A new Nightly build is available!`);
|
||||
setUpdateUrl("https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/tag/nightly");
|
||||
}
|
||||
} else {
|
||||
if (latestDate > buildDate && latestData) {
|
||||
setUpdateMessage(`Version ${latestData.tag_name} is now available!`);
|
||||
setUpdateUrl(latestData.html_url || LATEST_URL);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to check for updates:", e);
|
||||
@@ -44,6 +57,7 @@ export function useUpdateCheck() {
|
||||
|
||||
return {
|
||||
updateMessage,
|
||||
updateUrl,
|
||||
clearUpdateMessage: () => setUpdateMessage(null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ 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 SkinViewer from "../components/common/SkinViewer";
|
||||
import TeamModal from "../components/modals/TeamModal";
|
||||
import PanoramaBackground from "../components/common/PanoramaBackground";
|
||||
@@ -24,7 +30,6 @@ import {
|
||||
} from "../context/LauncherContext";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
showIntro,
|
||||
@@ -40,27 +45,24 @@ 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 [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")
|
||||
: "/images/MenuTitle.png";
|
||||
@@ -158,12 +160,30 @@ 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 +274,37 @@ 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 +369,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 +378,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 +399,25 @@ 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 === "skins" && <SkinsView key="skins-view" />}
|
||||
{activeView === "screenshots" && (
|
||||
<ScreenshotsView key="screenshots-view" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +432,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
|
||||
|
||||
@@ -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,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,11 +1,9 @@
|
||||
import { setActivity, start } from "tauri-plugin-drpc";
|
||||
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') {
|
||||
@@ -38,18 +36,15 @@ 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("Discord", "https://discord.gg/NPMp9JhGYH"),
|
||||
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher")
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -57,6 +60,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 +133,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 +193,46 @@ 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||