105 Commits

Author SHA1 Message Date
/home/odd 7d530fe258 More splashes! (#23) 2026-05-08 13:27:38 +03:00
neoapps-dev 5f8f084064 fix: panorama 2026-05-06 15:21:00 +03:00
neoapps-dev a24330ae2b fix: revelations using Nightly 2026-05-06 15:12:36 +03:00
neoapps-dev b04d1f06eb fix: branch cycling logic 2026-05-05 14:12:52 +03:00
neoapps-dev 1411f78388 fix: 360revived and hellish ends downloading 2026-05-04 22:29:25 +03:00
neoapps-dev c5a05ca3e2 feat: branches support, and redesigned a bit the versions menu 2026-05-04 20:38:29 +03:00
neoapps-dev f9487ce632 feat: dynamic link for neoLegacy 2026-05-04 19:43:30 +03:00
neoapps-dev e17fbd2354 fix 2026-05-03 23:37:04 +03:00
neoapps-dev c087dbcd69 feat: use latest instead of Stable 2026-05-03 19:14:59 +03:00
neoapps-dev 5fb311859a revert steamworks, causes problems for now 2026-05-01 23:13:37 +03:00
neoapps-dev b01b1273b1 feat: initial steamworks support 2026-05-01 22:31:29 +03:00
neoapps-dev 1b02b443d7 feat: switch neoLegacy to stable release 2026-05-01 20:45:57 +03:00
neoapps-dev d874b194a2 fix: Steam integration grids 2026-05-01 15:46:34 +03:00
neoapps-dev f648cbbcc4 fix: macos build 2026-04-30 23:10:01 +03:00
neoapps-dev 212413ff1a fix: windows and macos builds 2026-04-30 23:02:38 +03:00
neoapps-dev 7414efce47 feat: initial support for steam integration 2026-04-30 22:51:28 +03:00
neoapps-dev eddba38829 fix: preserve profileX.dat for achievements 2026-04-30 20:06:11 +03:00
neoapps-dev 365a8143ff feat: enhanced update checker for game and launcher 2026-04-30 19:02:34 +03:00
neoapps-dev 1ecc47c32f feat: cleaner UI for version detection 2026-04-30 18:35:39 +03:00
neoapps-dev b589e43d3e fix(devtools/grf): offset issues 2026-04-28 13:03:57 +03:00
neoapps-dev 74d2b71084 feat(devtools/grf): fix undefined error 2026-04-28 12:53:01 +03:00
neoapps-dev 972a5958c6 feat(devtools/grf): support for editing and creating new GRF/GRH files 2026-04-28 12:28:32 +03:00
neoapps-dev f52f8b4700 fix?: windows renaming issues 2026-04-26 16:24:50 +03:00
neoapps-dev 5d38578349 fix: proper workshop handling 2026-04-26 16:17:23 +03:00
neoapps-dev 60c8e016da fix: screenshots:// on windows again 2026-04-26 15:15:36 +03:00
neoapps-dev 87e841f565 fix: audio on GNU/Linux 2026-04-25 20:24:19 +03:00
neoapps-dev 9d80eea7d7 feat: rpc for swf 2026-04-25 19:57:20 +03:00
neoapps-dev e19d311546 fix: swf editor view 2026-04-25 19:32:37 +03:00
neoapps-dev a6641065ab fix: swf editor view 2026-04-25 19:22:37 +03:00
neoapps-dev ac9fdec501 feat: SWF editor enhancements 2026-04-25 19:12:23 +03:00
neoapps-dev c25bb7cbaf feat: SWF editor enhancements 2026-04-25 18:32:56 +03:00
neoapps-dev 29f4fdff47 docs: update revelations link 2026-04-25 14:42:09 +03:00
KayJann 8080a35b16 Fix get on github button size 2026-04-25 13:40:09 +02:00
KayJann 559ee0452c Small changes in README.md 2026-04-25 13:35:00 +02:00
KayJann e1df420379 Update README.md 2026-04-25 13:12:26 +02:00
neoapps-dev 79c819a2eb fix: windows systems not loading using screenshots:// 2026-04-25 13:45:54 +03:00
neoapps-dev 79bd169439 feat: scan Windows64/GameHDD for screenshots as well 2026-04-25 13:40:42 +03:00
neoapps-dev 0bc81e5854 fix: build issues 2026-04-24 19:53:32 +03:00
neoapps-dev eb63406540 fix: DLCSync, SkinSync and Workshop items for locally imported versions 2026-04-24 19:01:00 +03:00
neoapps-dev 1b36112fde feat: versions-in-workshop! 2026-04-24 18:05:58 +03:00
neoapps-dev f960c75f9a feat: build date in the footer 2026-04-24 16:46:17 +03:00
neoapps-dev e7442ab8ad fix: download url 2026-04-24 12:09:44 +03:00
KayJann f8f9e27d67 fix: new logo and name for Legacy Nether Fork 2026-04-24 10:09:47 +02:00
KayJann 657c97a71b fix: new discord links 2026-04-23 20:16:07 +02:00
neoapps-dev 341acc9071 feat: local custom TUs support 2026-04-23 15:44:00 +03:00
neoapps-dev 754efe1195 fix: full slim skin support, all versions 2026-04-23 14:52:22 +03:00
/home/neo d9787595db ci: run at 5PM UTC rather than on push 2026-04-23 13:17:19 +03:00
KayJann 4ccae74746 fix: real macos GPTK3 detection 2026-04-22 22:02:49 +02:00
KayJann 4f307dee39 feat: add legacy nether fork edition ! (new collab) 2026-04-22 21:27:41 +02:00
neoapps-dev 3cb787fc72 fix: oops removed delta 2026-04-22 20:43:01 +03:00
neoapps-dev fcb389b86d feat: cape support to skin viewer 2026-04-22 20:40:21 +03:00
neoapps-dev 106cc736bb feat: screenshots redesign 2026-04-22 20:04:05 +03:00
neoapps-dev 31d229a48f fix: make views more responsive and fix workshop scrolling issue 2026-04-22 19:51:27 +03:00
neoapps-dev fea0d56f9d feat: autoupdate based on date rather than version 2026-04-22 16:10:31 +03:00
neoapps-dev b18dc6f898 docs: add download button to readme 2026-04-22 15:16:02 +03:00
neoapps-dev c4436b27cd docs: BUILDING.md 2026-04-22 15:08:42 +03:00
/home/neo 020ed90786 docs: add screenshot 2026-04-22 15:07:06 +03:00
neoapps-dev 3f269ea08e docs: unslopify README 2026-04-22 15:02:31 +03:00
neoapps-dev f6cac74de4 feat: SWF image viewer 2026-04-22 14:43:22 +03:00
neoapps-dev 1febea2f68 feat: update notification for game update 2026-04-21 21:27:58 +03:00
neoapps-dev 2fe2749d62 feat: redesign setup 2026-04-21 21:16:54 +03:00
neoapps-dev a691a55885 feat: remove, update support to workshop, and installed tab 2026-04-21 21:03:11 +03:00
neoapps-dev 32fa9b48a6 fix: overlapping preventing you from changing username with mouse 2026-04-20 18:33:14 +03:00
neoapps-dev 3cd8340704 fix: make seed based on username, change skin pack skin name 2026-04-20 15:26:47 +03:00
neoapps-dev 2706ad9cbc feat: use a seeded id for skin and cape 2026-04-20 11:37:53 +03:00
neoapps-dev fb52972089 feat: workshop redesign part 2 2026-04-19 22:51:50 +03:00
KayJann 86dc293b26 Update Discord link in README.md 2026-04-19 21:04:48 +02:00
neoapps-dev 394c667407 feat: workshop redesign! 2026-04-19 20:54:12 +03:00
neoapps-dev eedd9700f4 feat(discordRPC): more tabs support 2026-04-19 20:40:55 +03:00
albertbogusz 02fa714f3c fix: improve Proton directory detection on Linux (#6) 2026-04-19 17:17:59 +03:00
neoapps-dev 8d48b2f938 fix: preserve screenshots when updating 2026-04-17 20:55:31 +03:00
neoapps-dev 37a3666022 feat: ignore Common/res/mob/char.png when updating as its no longer used 2026-04-17 14:21:15 +03:00
neoapps-dev 8dfab72ae2 feat: use LCE's pointer 2026-04-16 22:47:47 +03:00
neoapps-dev 09bb57e9a6 fix: change id for cape and skin 2026-04-16 19:20:46 +03:00
neoapps-dev b13260ed05 feat: initial CAPE support! 2026-04-16 18:42:28 +03:00
neoapps-dev 7ab84170c4 feat: organize flags, and better skin sync 2026-04-16 17:29:01 +03:00
neoapps-dev afebdffced fix: PCK3, hex values and force slim skins to be wide 2026-04-16 17:04:22 +03:00
neoapps-dev 1a0f51d698 chore: react code cleanup 2026-04-16 12:34:11 +03:00
neoapps-dev 51f2a78469 fix: server port to 1026 2026-04-16 11:40:41 +03:00
neoapps-dev 912d0da76e feat: TRUE support for slim skins, produce a PCK from a skin 2026-04-15 23:11:59 +03:00
neoapps-dev 4a0a329f6b feat: game options editor 2026-04-15 22:19:27 +03:00
neoapps-dev 3f4a776600 feat: colours and game rules editors 2026-04-15 21:45:20 +03:00
neoapps-dev 3088ea0fd3 feat: localisation editor 2026-04-15 21:08:38 +03:00
neoapps-dev 718aa8f125 feat(PCK): feature parity with Quiver 2026-04-15 18:29:09 +03:00
neoapps-dev f046e06e12 feat(PCK): feature parity with Quiver 2026-04-15 14:06:26 +03:00
neoapps-dev 24a4599ecf fix: appimage
NEKI STOP MESSING WITH IT:
2026-04-15 11:30:06 +03:00
neoapps-dev 8165e6c22c fix: version number 2026-04-15 11:13:13 +03:00
/home/neo 2d425c7bd0 docs: better readme 2026-04-13 20:09:48 +03:00
FarDeath 30e4de0577 fix: Permission Issue on Release (#1) 2026-04-13 19:40:46 +03:00
neoapps-dev d51ca80202 fix: modal index 2026-04-12 18:37:46 +03:00
neoapps-dev c49f62e539 feat: screenshots tab! 2026-04-12 18:31:57 +03:00
neoapps-dev 55acb96468 fix!: remove journeyman's skin 2026-04-12 16:31:16 +03:00
neoapps-dev aa28d50a2b fix!: Emerald Team list 2026-04-12 16:22:52 +03:00
neoapps-dev 7b25f2afbc fix: skin sync for neoLegacy 2026-04-12 15:48:55 +03:00
neoapps-dev 7941e8e1da fix: neoLegacy URL 2026-04-12 14:56:53 +03:00
Santiago Fisela e2a3e4b52c Appimage test fix 2026-04-11 17:48:08 -03:00
Santiago Fisela a78064377d Reapply "Hope this doesn't break appimages on gentoo + steam deck test"
This reverts commit a7577bbd72.
2026-04-11 17:22:15 -03:00
Santiago Fisela a7577bbd72 Revert "Hope this doesn't break appimages on gentoo + steam deck test"
This reverts commit 4a0f2df6eb.
2026-04-11 17:02:26 -03:00
Santiago Fisela 4a0f2df6eb Hope this doesn't break appimages on gentoo + steam deck test
I'll likely revert this lol
2026-04-11 16:35:05 -03:00
neoapps-dev 12fdcf0335 fix(workshop): im a dumbass 2026-04-11 20:58:34 +03:00
neoapps-dev 84c9296b51 docs: neoLegacy 2026-04-11 19:47:35 +03:00
neoapps-dev 6b434699e9 docs: remove LMRP as its now merged with neoLegacy 2026-04-11 19:44:49 +03:00
neoapps-dev fc0dbae680 logo 2026-04-11 19:37:46 +03:00
neoapps-dev 331d3e1ae9 docs: readme icon 2026-04-11 19:34:36 +03:00
Santiago Fisela c534c98ad5 Revelations link update 2026-04-11 12:32:47 -03:00
72 changed files with 9521 additions and 1916 deletions
+3 -3
View File
@@ -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
View File
+33 -104
View File
@@ -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.
+43 -39
View File
@@ -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"
}
}
+890 -26
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.
+223 -2
View File
@@ -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"
+5 -2
View File
@@ -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"
+29 -25
View File
@@ -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
View File
@@ -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":"**"}]}]}}
Binary file not shown.
+679 -107
View File
File diff suppressed because it is too large Load Diff
+21 -10
View File
@@ -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));
+4 -5
View File
@@ -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"
],
+9 -4
View File
@@ -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" : ""}`}
+3 -3
View File
@@ -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);
+305
View File
@@ -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;
+66 -40
View File
@@ -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>
);
+40 -15
View File
@@ -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}
+3 -4
View File
@@ -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(() => {
+264 -98
View File
@@ -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"
+395
View File
@@ -0,0 +1,395 @@
import { useState, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
import { ColService } from "../../services/ColService";
import { ColFile } from "../../types/col";
function argbToHex(argb: number) {
return (argb >>> 0).toString(16).padStart(8, '0').toUpperCase();
}
function hexToArgb(hex: string) {
const cleanHex = hex.replace("#", "");
return parseInt(cleanHex, 16) >>> 0;
}
export default function ColEditorView() {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { animationsEnabled } = useConfig();
const [col, setCol] = useState<ColFile | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<"colors" | "worldColors">("colors");
const showNotification = (message: string, type: "success" | "error" = "success") => {
setNotification({ message, type });
setTimeout(() => setNotification(null), 3000);
};
const currentColors = useMemo(() => {
if (!col) return [];
return col.colors.map((c, i) => ({ ...c, originalIdx: i }))
.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
}, [col, searchTerm]);
const currentWorldColors = useMemo(() => {
if (!col) return [];
return col.worldColors.map((c, i) => ({ ...c, originalIdx: i }))
.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
}, [col, searchTerm]);
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
playPressSound();
const buffer = await file.arrayBuffer();
try {
const parsedCol = ColService.readCOL(buffer);
setCol(parsedCol);
showNotification(`Loaded ${file.name}`);
} catch (err: any) {
console.error("Failed to parse COL", err);
showNotification(err.message || "Failed to parse COL", "error");
}
e.target.value = "";
};
const handleSaveCol = () => {
if (!col) return;
playPressSound();
try {
const buffer = ColService.serializeCOL(col);
const blob = new Blob([buffer]);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "colours.col";
a.click();
URL.revokeObjectURL(url);
showNotification("COL Saved Successfully");
} catch (err: any) {
console.error("Failed to save COL", err);
showNotification(err.message || "Failed to save COL", "error");
}
};
const handleUpdateColor = (idx: number, field: string, val: string | number) => {
if (!col) return;
const newCol = { ...col, colors: [...col.colors] };
newCol.colors[idx] = { ...newCol.colors[idx], [field]: val };
setCol(newCol);
};
const handleUpdateWorldColor = (idx: number, field: string, val: string | number) => {
if (!col) return;
const newCol = { ...col, worldColors: [...col.worldColors] };
newCol.worldColors[idx] = { ...newCol.worldColors[idx], [field]: val };
setCol(newCol);
};
const handleAddColor = () => {
if (!col) return;
playPressSound();
setCol({
...col,
colors: [{ name: "NewColor", color: 0xFFFFFFFF }, ...col.colors]
});
showNotification("Color Added");
};
const handleAddWorldColor = () => {
if (!col) return;
playPressSound();
setCol({
...col,
worldColors: [{ name: "NewWorldColor", waterColor: 0xFFFFFFFF, underwaterColor: 0xFFFFFFFF, fogColor: 0xFFFFFFFF }, ...col.worldColors]
});
showNotification("World Color Added");
};
const handleDeleteColor = (idx: number) => {
if (!col) return;
playBackSound();
const newCol = { ...col, colors: [...col.colors] };
newCol.colors.splice(idx, 1);
setCol(newCol);
};
const handleDeleteWorldColor = (idx: number) => {
if (!col) return;
playBackSound();
const newCol = { ...col, worldColors: [...col.worldColors] };
newCol.worldColors.splice(idx, 1);
setCol(newCol);
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
>
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".col" />
<div className="flex items-center justify-between mb-6 px-4">
<div className="flex items-center gap-6">
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">COL Editor</h2>
{col && <span className="text-white/40 mc-text-shadow italic">Version: <span className="text-[#FFFF55]">{col.version}</span></span>}
</div>
<div className="flex gap-4">
<button
onClick={() => fileInputRef.current?.click()}
className="px-6 py-2 text-white mc-text-shadow text-lg"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Open COL
</button>
<button
onClick={handleSaveCol}
disabled={!col}
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!col ? "opacity-50 grayscale" : ""}`}
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Save COL
</button>
</div>
</div>
{!col ? (
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open a COL file to begin editing</h3>
</div>
) : (
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<div className="flex gap-1 p-2 pt-4 border-b-2 border-[#373737]">
<button
onClick={() => { playPressSound(); setActiveTab("colors"); }}
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "colors" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
>
<span className="text-lg">Colors ({col.colors.length})</span>
</button>
<button
onClick={() => { playPressSound(); setActiveTab("worldColors"); }}
disabled={col.version === 0}
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "worldColors" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"} ${col.version === 0 ? "opacity-20 cursor-not-allowed" : ""}`}
>
<span className="text-lg">World Colors ({col.worldColors.length})</span>
</button>
</div>
<div className="flex-1 flex flex-col p-4 overflow-hidden">
<div className="mb-4 flex gap-4">
<input
type="text"
placeholder="Search colors..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
/>
<button
onClick={activeTab === "colors" ? handleAddColor : handleAddWorldColor}
className="px-6 py-2 text-white mc-text-shadow text-sm"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Add {activeTab === "colors" ? "Color" : "World Color"}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-[#252525] z-10 w-full mb-2">
<tr className="border-b-2 border-[#373737]">
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold w-1/4">Name</th>
{activeTab === "colors" && <th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Color (ARGB)</th>}
{activeTab === "worldColors" && (
<>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Water (ARGB)</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Underwater (ARGB)</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Fog (ARGB)</th>
</>
)}
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right w-16">Remove</th>
</tr>
</thead>
<tbody>
{activeTab === "colors" && currentColors.map((c) => (
<tr key={c.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
<td className="p-2">
<input
type="text"
value={c.name}
onChange={(e) => handleUpdateColor(c.originalIdx, "name", e.target.value)}
className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm"
/>
</td>
<td className="p-2">
<div className="flex items-center gap-3">
<input
type="color"
value={`#${argbToHex(c.color).substring(2, 8)}`}
onChange={(e) => {
const rgb = e.target.value.substring(1).toUpperCase();
const alpha = argbToHex(c.color).substring(0, 2);
const newArgb = hexToArgb(`${alpha}${rgb}`);
handleUpdateColor(c.originalIdx, "color", newArgb);
}}
className="w-8 h-8 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
/>
<input
type="text"
value={argbToHex(c.color)}
onChange={(e) => {
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
if (s.length <= 8) {
const parsed = parseInt(s, 16);
if (!isNaN(parsed)) handleUpdateColor(c.originalIdx, "color", parsed >>> 0);
}
}}
className="bg-black/40 border border-[#373737] w-24 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-sm uppercase"
maxLength={8}
/>
</div>
</td>
<td className="p-2 text-right">
<button onClick={() => handleDeleteColor(c.originalIdx)} className="p-1 hover:text-red-500 opacity-60 hover:opacity-100 transition-colors">
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
</button>
</td>
</tr>
))}
{activeTab === "worldColors" && currentWorldColors.map((w) => (
<tr key={w.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
<td className="p-2">
<input
type="text"
value={w.name}
onChange={(e) => handleUpdateWorldColor(w.originalIdx, "name", e.target.value)}
className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm"
/>
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<input
type="color"
value={`#${argbToHex(w.waterColor).substring(2, 8)}`}
onChange={(e) => {
const rgb = e.target.value.substring(1).toUpperCase();
const alpha = argbToHex(w.waterColor).substring(0, 2);
handleUpdateWorldColor(w.originalIdx, "waterColor", hexToArgb(`${alpha}${rgb}`));
}}
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
/>
<input
type="text"
value={argbToHex(w.waterColor)}
onChange={(e) => {
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
if (s.length <= 8) {
const parsed = parseInt(s, 16);
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "waterColor", parsed >>> 0);
}
}}
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
maxLength={8}
/>
</div>
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<input
type="color"
value={`#${argbToHex(w.underwaterColor).substring(2, 8)}`}
onChange={(e) => {
const rgb = e.target.value.substring(1).toUpperCase();
const alpha = argbToHex(w.underwaterColor).substring(0, 2);
handleUpdateWorldColor(w.originalIdx, "underwaterColor", hexToArgb(`${alpha}${rgb}`));
}}
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
/>
<input
type="text"
value={argbToHex(w.underwaterColor)}
onChange={(e) => {
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
if (s.length <= 8) {
const parsed = parseInt(s, 16);
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "underwaterColor", parsed >>> 0);
}
}}
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
maxLength={8}
/>
</div>
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<input
type="color"
value={`#${argbToHex(w.fogColor).substring(2, 8)}`}
onChange={(e) => {
const rgb = e.target.value.substring(1).toUpperCase();
const alpha = argbToHex(w.fogColor).substring(0, 2);
handleUpdateWorldColor(w.originalIdx, "fogColor", hexToArgb(`${alpha}${rgb}`));
}}
className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent"
/>
<input
type="text"
value={argbToHex(w.fogColor)}
onChange={(e) => {
const s = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
if (s.length <= 8) {
const parsed = parseInt(s, 16);
if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "fogColor", parsed >>> 0);
}
}}
className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase"
maxLength={8}
/>
</div>
</td>
<td className="p-2 text-right">
<button onClick={() => handleDeleteWorldColor(w.originalIdx)} className="p-1 hover:text-red-500 opacity-60 hover:opacity-100 transition-colors">
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
<div className="flex justify-center mt-6 h-14">
<button
onClick={() => { playBackSound(); setActiveView("devtools"); }}
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
>
Back
</button>
</div>
<AnimatePresence>
{notification && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
className="fixed top-12 right-12 z-[100] p-6 flex flex-col items-center justify-center min-w-[240px]"
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
>
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
{notification.message}
</span>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
+4 -1
View File
@@ -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() {
+308
View File
@@ -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>
);
}
+45 -33
View File
@@ -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>
+272
View File
@@ -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>
);
}
+241
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+459
View File
@@ -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;
+336 -332
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+344
View File
@@ -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>
);
}
+303 -273
View File
@@ -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
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -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}>
+98 -3
View File
@@ -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;
}
-3
View File
@@ -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);
+422 -126
View File
@@ -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,
+8 -2
View File
@@ -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";
}
+229 -15
View File
@@ -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,
};
}
+4 -10
View File
@@ -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
View File
@@ -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 {
-5
View File
@@ -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 };
}, []);
+171 -25
View File
@@ -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,
};
}
+41 -27
View File
@@ -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),
};
}
+80 -10
View File
@@ -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
+64 -82
View File
@@ -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;
}
+78
View File
@@ -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;
}
}
+322
View File
@@ -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: []
}
};
}
}
+165
View File
@@ -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 -6
View File
@@ -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")
]);
+21
View File
@@ -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 });
}
}
+277
View File
@@ -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;
});
}
}
+68 -2
View File
@@ -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 });
}
}
-2
View File
@@ -1,2 +0,0 @@
// empty :P
// what is this file lol ? - kay
+17
View File
@@ -0,0 +1,17 @@
export interface ColColor {
name: string;
color: number;
}
export interface ColWorldColor {
name: string;
waterColor: number;
underwaterColor: number;
fogColor: number;
}
export interface ColFile {
version: number;
colors: ColColor[];
worldColors: ColWorldColor[];
}
+42
View File
@@ -0,0 +1,42 @@
export enum GrfCompressionLevel {
None = 0,
Compressed = 1,
CompressedRle = 2,
CompressedRleCrc = 3,
}
export enum GrfCompressionType {
Unknown = -1,
Zlib = 0,
Deflate = 1,
XMem = 2,
}
export interface GrfHeader {
compressionLevel: GrfCompressionLevel;
crc: number;
compressionType: GrfCompressionType;
unknownData: Uint8Array;
}
export interface GrfFileEntry {
filename: string;
data: Uint8Array;
}
export interface GrfParameter {
name: string;
value: string;
}
export interface GrfNode {
name: string;
parameters: GrfParameter[];
children: GrfNode[];
}
export interface GrfFile {
header: GrfHeader;
files: GrfFileEntry[];
root: GrfNode;
}
+69
View File
@@ -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
View File
@@ -1 +0,0 @@
// empty :P
+2
View File
@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __BUILD_DATE__: string;
+3
View File
@@ -7,6 +7,9 @@ export default defineConfig({
react(),
tailwindcss(),
],
define: {
__BUILD_DATE__: JSON.stringify(new Date().toISOString()),
},
server: {
port: 1420,
strictPort: true,