139 Commits

Author SHA1 Message Date
neoapps-dev f0e0ba69dc chore: update LCELive button logo 2026-05-13 03:07:55 +03:00
neoapps-dev 9ec65fbcc8 feat(LCELive): outgoing friend requests 2026-05-13 03:00:06 +03:00
neoapps-dev 5a0b7f1a26 chore: discord link AGAIN 2026-05-13 02:45:09 +03:00
neoapps-dev 887f0d5bd0 chore: document use of refreshSession on each request() 2026-05-13 02:29:22 +03:00
neoapps-dev 3b91e96074 feat: listen on 255.255.255.255:25566 instead of servers.db 2026-05-13 02:27:24 +03:00
neoapps-dev 76bca26143 feat: migrate from unzip to bsdtar on linux 2026-05-13 01:47:20 +03:00
neoapps-dev a4ce0686ad feat: add andi's skin 2026-05-13 01:06:05 +03:00
neoapps-dev 96eec9d979 feaat(LCELiveView): fullscreen modals 2026-05-13 00:57:49 +03:00
neoapps-dev 1600b2e942 feat: LCELive notifs everywhere 2026-05-13 00:48:00 +03:00
neoapps-dev 29916d5c8a chore: prepend underscore to unused var 2026-05-13 00:28:38 +03:00
neoapps-dev 1b5acadf99 fix(LCELive): relay joining 2026-05-12 22:05:59 +03:00
neoapps-dev 8817b1e611 fix(LCELive): relay joining 2026-05-12 21:57:56 +03:00
neoapps-dev 9d078eaf2f feat(LCELive): relay support 2026-05-12 21:46:12 +03:00
neoapps-dev 4b577b5034 fix: world hosting 2026-05-12 20:59:18 +03:00
neoapps-dev 15d40c6961 fix: typescript errors 2026-05-12 20:15:57 +03:00
neoapps-dev c10e4d20ab feat: initial implementation of LCELive networking 2026-05-12 20:12:46 +03:00
neoapps-dev 026d284644 fix: capitalization issues 2026-05-12 11:18:52 +03:00
neoapps-dev 34332e99ee feat: MangoHud support 2026-05-12 09:52:27 +03:00
neoapps-dev c5ff9c6ed7 feat: button for LCELive 2026-05-12 09:34:19 +03:00
neoapps-dev eb0e90f92b feat: cherry pick edits from ui-change 2026-05-12 07:37:13 +03:00
neoapps-dev 37b3c93604 feat: cherry pick edits from ui-change 2026-05-12 07:35:53 +03:00
neoapps-dev b2b279be38 feat: introduce ScreenshotImage and get rid of screenshots:// 2026-05-12 06:56:46 +03:00
neoapps-dev 803c1b8762 feat: refactor panorama to allow custom panorama id per instance 2026-05-12 06:49:36 +03:00
neoapps-dev 912a604971 feat: LCELive token refresh on request 2026-05-10 20:30:09 +03:00
neoapps-dev a16e3acc91 feat: LCELive notifs 2026-05-10 19:16:50 +03:00
neoapps-dev 6c591729ff feat: auto update and remove nightly updates 2026-05-10 18:46:13 +03:00
neoapps-dev f4841c3be0 fix: compiler errors 2026-05-10 18:37:08 +03:00
neoapps-dev 17353f6984 ci: sign releases 2026-05-10 18:20:56 +03:00
neoapps-dev a2021529b2 feat: initial support for AUTOupdates 2026-05-10 18:17:21 +03:00
neoapps-dev 7ec96e6a14 feat(SetUidModal): fullscreen 2026-05-10 15:16:24 +03:00
neoapps-dev 6ad97219c9 feat(VersionsView): set uid 2026-05-10 15:08:05 +03:00
neoapps-dev 14e92937e8 feat: discord rpc for LCELive 2026-05-08 14:03:37 +03:00
/home/neo c9ef1db546 Merge pull request #26 from LCE-Hub/main 2026-05-08 13:28:28 +03:00
/home/odd 7d530fe258 More splashes! (#23) 2026-05-08 13:27:38 +03:00
neoapps-dev 265a2b3992 feat: initial implementation of LCELive 2026-05-08 01:17:45 +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
100 changed files with 13678 additions and 2276 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
+4 -1
View File
@@ -91,7 +91,10 @@ jobs:
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
uploadPlainBinary: true
tagName: v__VERSION__
releaseName: 'Emerald v__VERSION__'
releaseBody: |
@@ -142,4 +145,4 @@ jobs:
tag_name: v${{ needs.get-version.outputs.version }}
files: Emerald.Legacy.Launcher.flatpak
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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.
+45 -39
View File
@@ -1,39 +1,45 @@
{
"name": "emerald-legacy-launcher",
"private": true,
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:build": "tauri build && pnpm run post-build:macos",
"post-build:macos": "sh src-tauri/scripts/post-build-macos.sh",
"flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"framer-motion": "^12.36.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "^0.183.2"
},
"devDependencies": {
"@types/three": "^0.183.1",
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"tauri-plugin-drpc": "^1.0.3",
"tauri-plugin-gamepad-api": "^0.0.5"
}
}
{
"name": "emerald-legacy-launcher",
"private": true,
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:build": "tauri build && pnpm run post-build:macos",
"post-build:macos": "sh src-tauri/scripts/post-build-macos.sh",
"flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "~2.3.1",
"@tauri-apps/plugin-updater": "~2.10.1",
"framer-motion": "^12.36.0",
"pako": "^2.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"three": "^0.183.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/pako": "^2.0.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tauri-plugin-drpc": "^1.0.3",
"tauri-plugin-gamepad-api": "^0.0.5",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
+910 -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.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

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: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

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

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.
+562 -2
View File
@@ -47,6 +47,21 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "ashpd"
version = "0.11.1"
@@ -284,6 +299,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -357,6 +378,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -512,6 +539,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.7"
@@ -624,12 +657,37 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -724,6 +782,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -734,6 +798,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -911,6 +986,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.8"
@@ -933,22 +1014,30 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "emerald-legacy-launcher"
version = "1.0.1"
version = "1.1.0"
dependencies = [
"base64 0.21.7",
"futures-util",
"http 1.4.0",
"image 0.24.9",
"libc",
"rand 0.8.5",
"reqwest 0.11.27",
"rfd",
"serde",
"serde_json",
"steam_shortcuts_util",
"tauri",
"tauri-build",
"tauri-plugin-drpc",
"tauri-plugin-gamepad",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tokio",
"tokio-tungstenite",
"tokio-util",
"url",
]
[[package]]
@@ -1055,6 +1144,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1080,6 +1184,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1424,6 +1538,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.1"
@@ -1627,6 +1751,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1805,6 +1940,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.0",
"hyper 1.9.0",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
@@ -1990,6 +2140,24 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.24.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"exr",
"gif",
"jpeg-decoder",
"num-traits",
"png 0.17.16",
"qoi",
"tiff",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -2163,6 +2331,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
dependencies = [
"rayon",
]
[[package]]
name = "js-sys"
version = "0.3.94"
@@ -2226,6 +2403,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -2391,6 +2574,18 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2514,6 +2709,27 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom_locate"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3"
dependencies = [
"bytecount",
"memchr",
"nom",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -2621,6 +2837,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2647,6 +2864,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2763,6 +2992,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3212,6 +3455,15 @@ version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -3367,6 +3619,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3492,15 +3764,20 @@ dependencies = [
"http-body 1.0.1",
"http-body-util",
"hyper 1.9.0",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper 1.0.2",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3536,6 +3813,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rpcdiscord"
version = "0.2.6"
@@ -3577,6 +3868,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3586,6 +3903,53 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3928,6 +4292,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4059,6 +4434,18 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "steam_shortcuts_util"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6"
dependencies = [
"ascii",
"crc32fast",
"nom",
"nom_locate",
]
[[package]]
name = "string_cache"
version = "0.8.9"
@@ -4114,6 +4501,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4256,6 +4649,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4279,7 +4683,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.4.0",
"image",
"image 0.25.10",
"jni",
"libc",
"log",
@@ -4448,6 +4852,49 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http 1.4.0",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -4622,6 +5069,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tiff"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
"flate2",
"jpeg-decoder",
"weezl",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -4701,6 +5159,30 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4924,6 +5406,26 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.4.0",
"httparse",
"log",
"native-tls",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5006,6 +5508,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5404,6 +5912,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5440,6 +5957,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6197,6 +6720,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.2"
@@ -6322,6 +6855,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.4"
@@ -6355,12 +6894,33 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.1",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zvariant"
version = "5.10.0"
+12 -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"
@@ -24,5 +24,15 @@ tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
tokio-util = { version = "0.7.18", features = ["rt"] }
base64 = "0.21"
tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "native-tls"] }
url = "2"
rand = "0.8"
http = "1"
rfd = "0.15"
libc = "0.2"
image = "0.24"
steam_shortcuts_util = "1.1.8"
tauri-plugin-process = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
+39 -25
View File
@@ -1,26 +1,40 @@
{
"identifier": "default",
"description": "Default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-set-title",
"core:window:allow-start-dragging",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-unminimize",
"core:window:allow-set-focus",
"opener:default",
"gamepad:default",
"drpc:allow-is-running",
"drpc:default",
"core:event:allow-listen",
"core:app:default"
]
{
"identifier": "default",
"description": "Default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:app:default",
"core:event:default",
"core:image:default",
"core:menu:default",
"core:path:default",
"core:resources:default",
"core:tray:default",
"core:webview:default",
"core:window:default",
"opener:default",
"gamepad:default",
"drpc:default",
"updater:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{
"path": "**"
}
]
},
{
"identifier": "opener:allow-reveal-item-in-dir",
"allow": [
{
"path": "**"
}
]
},
"process:default"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"updater:default",
"process:default"
]
}
File diff suppressed because one or more lines are too long
+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","updater:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]},"process:default"]},"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","process:default"],"platforms":["macOS","windows","linux"]}}
+84
View File
@@ -2443,6 +2443,90 @@
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
}
]
},
+84
View File
@@ -2443,6 +2443,90 @@
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
}
]
},
Binary file not shown.
+1384 -125
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));
+65 -58
View File
@@ -1,58 +1,65 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LCE Emerald Launcher",
"version": "1.0.1",
"identifier": "com.emerald.legacy",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "LCE Emerald Launcher",
"width": 1280,
"height": 720,
"minWidth": 1280,
"minHeight": 720,
"decorations": true,
"transparent": false,
"resizable": true,
"center": true,
"visible": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"linux": {
"appimage": {
"bundleMediaFramework": true
}
},
"resources": [
"resources/DLC"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"signingIdentity": null,
"providerShortName": "Emerald"
}
}
}
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LCE Emerald Launcher",
"version": "1.1.0",
"identifier": "com.emerald.legacy",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "LCE Emerald Launcher",
"width": 1280,
"height": 720,
"minWidth": 1280,
"minHeight": 720,
"decorations": true,
"transparent": false,
"resizable": true,
"center": true,
"visible": true
}
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1NTk5MjQ4QjMxQjFCQgpSV1M3c1RHTEpKbFZBMEtxMnNUZ3F5V2FmQmQrREY5Z0pkbnJuUlBYL2d1cUtzRkJIeDFkUkR4RQo=",
"endpoints": ["https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest/download/latest.json"],
"windows": {"installMode": "passive"}
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"linux": {
"appimage": {
"bundleMediaFramework": true
}
},
"resources": [
"resources/DLC"
],
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"signingIdentity": null,
"providerShortName": "Emerald"
}
}
}
+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" : ""}`}
+72 -70
View File
@@ -1,70 +1,72 @@
import React, { useEffect, useRef, useState } from 'react';
import { useUI } from '../../context/LauncherContext';
interface PanoramaProps {
profile: string;
isDay: boolean;
}
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
const { isWindowVisible } = useUI();
const PANORAMA_PROFILES = ['legacy_evolved', '360revived'];
const profileId = PANORAMA_PROFILES.includes(profile) ? profile : 'legacy_evolved';
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? 'Day' : 'Night'}.png`;
const [bgWidth, setBgWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let active = true;
const updateWidth = () => {
if (!containerRef.current) return;
const img = new Image();
img.src = currentPanorama;
img.onload = () => {
if (!active || !containerRef.current) return;
const height = containerRef.current.clientHeight;
const aspectRatio = img.naturalWidth / img.naturalHeight;
setBgWidth(Math.ceil(height * aspectRatio));
};
};
updateWidth();
window.addEventListener('resize', updateWidth);
return () => {
active = false;
window.removeEventListener('resize', updateWidth);
};
}, [currentPanorama]);
return (
<>
{bgWidth && (
<style>{`
@keyframes panoramaLoop {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
}
`}</style>
)}
<div ref={containerRef} className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500">
{isWindowVisible && (
<div
className="absolute top-0 left-0 h-full will-change-transform"
style={{
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : '200vw',
backgroundImage: `url("${currentPanorama}")`,
backgroundSize: bgWidth ? `${bgWidth}px 100%` : 'auto 100%',
backgroundRepeat: 'repeat-x',
animation: bgWidth ? 'panoramaLoop 140s linear infinite' : 'none'
}}
/>
)}
</div>
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
</>
);
});
export default PanoramaBackground;
import React, { useEffect, useRef, useState } from "react";
import { useUI } from "../../context/LauncherContext";
interface PanoramaProps {
profile: string;
isDay: boolean;
}
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
const { isWindowVisible } = useUI();
const baseId = profile;
const profileId = baseId ? baseId : "vanilla_tu19";
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? "Day" : "Night"}.png`;
const [bgWidth, setBgWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let active = true;
const updateWidth = () => {
if (!containerRef.current) return;
const img = new Image();
img.src = currentPanorama;
img.onload = () => {
if (!active || !containerRef.current) return;
const height = containerRef.current.clientHeight;
const aspectRatio = img.naturalWidth / img.naturalHeight;
setBgWidth(Math.ceil(height * aspectRatio));
};
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
active = false;
window.removeEventListener("resize", updateWidth);
};
}, [currentPanorama]);
return (
<>
{bgWidth && (
<style>{`
@keyframes panoramaLoop {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
}
`}</style>
)}
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500"
>
{isWindowVisible && (
<div
className="absolute top-0 left-0 h-full will-change-transform"
style={{
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : "200vw",
backgroundImage: `url("${currentPanorama}")`,
backgroundSize: bgWidth ? `${bgWidth}px 100%` : "auto 100%",
backgroundRepeat: "repeat-x",
animation: bgWidth ? "panoramaLoop 140s linear infinite" : "none",
}}
/>
)}
</div>
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
</>
);
});
export default PanoramaBackground;
+54
View File
@@ -0,0 +1,54 @@
import { useState, useEffect, useRef } from "react";
import { TauriService } from "../../services/TauriService";
interface ScreenshotImageProps {
path: string;
className?: string;
alt?: string;
loading?: "lazy" | "eager";
style?: React.CSSProperties;
fallbackSrc?: string;
}
export function ScreenshotImage({
path,
className,
alt,
loading,
style,
fallbackSrc,
}: ScreenshotImageProps) {
const [src, setSrc] = useState<string | undefined>(fallbackSrc);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
let cancelled = false;
setSrc(fallbackSrc);
TauriService.readScreenshotAsDataUrl(path)
.then((url) => {
if (!cancelled) setSrc(url);
})
.catch(() => {
if (!cancelled && fallbackSrc) setSrc(fallbackSrc);
});
return () => {
cancelled = true;
};
}, [path, fallbackSrc]);
const handleError = () => {
if (fallbackSrc) setSrc(fallbackSrc);
};
return (
<img
ref={imgRef}
src={src}
className={className}
alt={alt}
loading={loading}
style={style}
onError={handleError}
/>
);
}
+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;
+79 -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,12 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
} else if (focusIndex === 3) {
playPressSound();
setSkinUrl('/images/Default.png');
} else if (focusIndex === 4) {
playPressSound();
setActiveView('screenshots');
} else if (focusIndex === 5) {
playPressSound();
setActiveView('lcelive');
}
}
};
@@ -265,18 +284,18 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
className={`absolute ${legacyMode ? 'left-[calc(50vw-340px)]' : 'left-16'} ${legacyMode ? 'top-1/2' : 'top-[40%]'} -translate-y-1/2 flex flex-col items-center gap-1 outline-none z-10`}
>
{!legacyMode && (
<div className={`bg-black/20 flex justify-center items-center ${legacyMode ? 'mb-0' : 'mb-2'} px-2 py-1 rounded-sm border-2 transition-colors ${isFocusedSection && focusIndex === 0 ? 'border-[#FFFF55]' : 'border-transparent'}`} data-focus="0" tabIndex={0}>
<div className={`relative z-20 bg-black/20 flex justify-center items-center ${legacyMode ? 'mb-0' : 'mb-2'} px-2 py-1 rounded-sm border-2 transition-colors ${isFocusedSection && focusIndex === 0 ? 'border-[#FFFF55]' : 'border-transparent'}`} data-focus="0" tabIndex={0}>
<input
type="text" value={username} maxLength={16}
style={{ width: `${Math.max(username.length, 3) + 2}ch` }}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'ArrowDown') {
if (e.key === 'Enter') {
e.currentTarget.blur();
e.stopPropagation();
}
}}
className="bg-transparent text-white focus:text-[#FFFF55] outline-none border-none text-center font-['Mojangles'] mc-text-shadow tracking-widest text-xl cursor-default"
className="bg-transparent text-white focus:text-[#FFFF55] outline-none border-none text-center font-['Mojangles'] mc-text-shadow tracking-widest text-xl cursor-text"
/>
</div>
)}
@@ -320,6 +339,26 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
<img src="/images/Trash_Bin_Icon.png" alt="Delete" className="w-8 h-8 object-contain brightness-200" style={{ imageRendering: 'pixelated' }} />
</button>
)}
<button
data-focus="4" tabIndex={0}
onMouseEnter={() => isFocusedSection && setFocusIndex(4)}
onClick={() => { playPressSound(); setActiveView('screenshots'); }}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 4 ? 'scale-110' : ''}`}
style={isFocusedSection && focusIndex === 4 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
title="Screenshots"
>
<img src="/images/Screenshots_Icon.png" alt="Screenshots" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
</button>
<button
data-focus="5" tabIndex={0}
onMouseEnter={() => isFocusedSection && setFocusIndex(5)}
onClick={() => { playPressSound(); setActiveView('lcelive'); }}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 5 ? 'scale-110' : ''}`}
style={isFocusedSection && focusIndex === 5 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
title="LCELive"
>
<img src="/images/friends.png" alt="LCELive" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
</button>
</div>
</motion.div>
);
@@ -0,0 +1,287 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
import { lceLiveService, GameInvite } from "../../services/LceLiveService";
export default function ChooseInstanceModal({
isOpen,
onClose,
playPressSound,
playBackSound,
editions,
installs,
invite,
}: {
isOpen: boolean;
onClose: () => void;
playPressSound: (s?: string) => void;
playBackSound: (s?: string) => void;
editions: any[];
installs: string[];
invite: GameInvite | null;
}) {
const [selectedInstance, setSelectedInstance] = useState<string>("");
const [status, setStatus] = useState<string>("");
const [error, setError] = useState<string>("");
const [isJoining, setIsJoining] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const [connectionMethod, setConnectionMethod] = useState<"direct" | "relay">("direct");
const validInstances = editions.filter((e: any) =>
installs.includes(e.instanceId)
);
const hasRelay = !!(invite?.signalingSessionId);
useEffect(() => {
if (!isOpen) {
setSelectedInstance("");
setStatus("");
setError("");
setIsJoining(false);
setFocusIndex(0);
setConnectionMethod("direct");
}
}, [isOpen]);
useEffect(() => {
if (isOpen && validInstances.length > 0 && !selectedInstance) {
setSelectedInstance(validInstances[0].instanceId);
}
}, [isOpen, validInstances, selectedInstance]);
const handleJoin = async () => {
if (!invite || !selectedInstance) return;
playPressSound();
setIsJoining(true);
setError("");
setStatus("Accepting invite...");
try {
const inviteData = await lceLiveService.acceptGameInvite(invite.inviteId);
const hostIp = inviteData.hostIp || (typeof invite.from !== 'string' && (invite as any).from?.hostIp);
const hostPort = inviteData.hostPort || invite.hostPort;
const sessionId = inviteData.signalingSessionId || invite.signalingSessionId || "";
if (connectionMethod === "relay") {
if (!sessionId) {
throw new Error("Relay connection is not available for this invite.");
}
setStatus("Connecting via relay...");
const baseUrl = lceLiveService.apiBaseUrl;
const accessToken = lceLiveService.accessToken ?? "";
const port = await TauriService.startRelayProxy(baseUrl, accessToken, sessionId);
setStatus("Launching game...");
await TauriService.launchGame(selectedInstance, [
{ name: invite.hostName || "LCELive Game", ip: "127.0.0.1", port }
]);
} else {
setStatus("Launching game...");
await TauriService.stopProxy();
await TauriService.launchGame(selectedInstance, [
{ name: invite.hostName || "LCELive Game", ip: hostIp, port: hostPort }
]);
}
onClose();
} catch (e: any) {
setError(e.toString());
setStatus("");
setIsJoining(false);
}
};
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playBackSound();
if (isJoining) return;
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
setFocusIndex((prev) => (prev + 1) % max);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
setFocusIndex((prev) => (prev - 1 + max) % max);
} else if (e.key === "Enter") {
if (focusIndex === 0 && validInstances.length > 0) {
const currentIdx = validInstances.findIndex((i: any) => i.instanceId === selectedInstance);
const next = (currentIdx + 1) % validInstances.length;
setSelectedInstance(validInstances[next].instanceId);
playPressSound();
} else if (focusIndex === 1 && !isJoining) {
handleJoin();
} else if (focusIndex === (validInstances.length > 0 ? 2 : 1) && !isJoining) {
onClose();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, selectedInstance, validInstances, isJoining]);
if (!isOpen) return null;
const hostName = invite ? (typeof invite.from === 'string' ? invite.from : invite.from.displayName) : "";
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
>
<div
className="relative w-[450px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{!isJoining ? (
<>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-2 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Join Game
</h2>
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
Joining {hostName}'s game. Choose an instance:
</p>
{validInstances.length > 0 ? (
<div className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
style={{ scrollbarWidth: "thin", scrollbarColor: "#373737 transparent" }}>
{validInstances.map((inst: any) => {
const isSelected = selectedInstance === inst.instanceId;
return (
<div
key={inst.instanceId}
onClick={() => { playPressSound(); setSelectedInstance(inst.instanceId); }}
onMouseEnter={() => setFocusIndex(0)}
className={`w-full px-4 py-3 cursor-pointer flex items-center gap-3 transition-all outline-none border-none ${isSelected ? "bg-white/15 border-l-4 border-[#FFFF55]" : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent"}`}
style={{ imageRendering: "pixelated" }}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}>
{isSelected && <div className="w-2 h-2 rounded-full bg-[#FFFF55]" />}
</div>
<div className="flex flex-col">
<span className="text-white text-lg font-bold mc-text-shadow">{inst.name}</span>
{inst.selectedBranch && (
<span className="text-gray-400 text-xs">{inst.selectedBranch}</span>
)}
</div>
</div>
);
})}
</div>
) : (
<p className="text-red-400 text-sm mc-text-shadow mb-4 text-center">
No installed instances available. Install a version first.
</p>
)}
<div className="w-full mb-3 flex flex-col items-center gap-1">
<span className="text-gray-400 text-xs mc-text-shadow uppercase tracking-wider">
Connection Method
</span>
<div className="flex gap-2">
<button
onClick={() => setConnectionMethod("direct")}
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
connectionMethod === "direct"
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
: "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
}`}
style={{ imageRendering: "pixelated" }}
>
Direct
</button>
<button
onClick={() => hasRelay && setConnectionMethod("relay")}
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
connectionMethod === "relay"
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
: hasRelay
? "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
: "text-gray-600 bg-black/10 border-l-2 border-transparent cursor-not-allowed"
}`}
style={{ imageRendering: "pixelated" }}
title={!hasRelay ? "Relay not available for this invite" : "Connect via LCELive relay server"}
>
Relay
</button>
</div>
{!hasRelay && (
<span className="text-gray-600 text-[10px] mc-text-shadow">Relay not available for this invite</span>
)}
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
{error}
</div>
)}
<div className="flex gap-4 w-full justify-center">
<button
onMouseEnter={() => {
const cancelIdx = validInstances.length > 0 ? 2 : 1;
setFocusIndex(cancelIdx);
}}
onClick={() => { playBackSound(); onClose(); }}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${(() => {
const cancelIdx = validInstances.length > 0 ? 2 : 1;
return focusIndex === cancelIdx ? "text-[#FFFF55]" : "text-white";
})()}`}
style={{
backgroundImage: (() => {
const cancelIdx = validInstances.length > 0 ? 2 : 1;
return focusIndex === cancelIdx
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')";
})(),
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
{validInstances.length > 0 && (
<button
onMouseEnter={() => setFocusIndex(1)}
onClick={handleJoin}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Join
</button>
)}
</div>
</>
) : (
<>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Joining Game
</h2>
<div className="flex flex-col items-center gap-4 py-8">
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
<p className="text-white text-lg mc-text-shadow text-center">{status}</p>
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
{error}
</div>
)}
</>
)}
</div>
</motion.div>
);
}
+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}
+276
View File
@@ -0,0 +1,276 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
export default function SetUidModal({
isOpen,
onClose,
playPressSound,
playBackSound,
instances,
installedVersions,
targetInstanceId,
}: any) {
const [mode, setMode] = useState<"manual" | "copy">("manual");
const [uid, setUid] = useState("0xFF02F0C87E8AC1F2");
const [selectedInstance, setSelectedInstance] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [error, setError] = useState("");
const [focusIndex, setFocusIndex] = useState(0);
useEffect(() => {
if (!isOpen) {
setMode("manual");
setUid("0xFF02F0C87E8AC1F2");
setSelectedInstance("");
setError("");
setFocusIndex(0);
setIsDropdownOpen(false);
} else if (targetInstanceId) {
(async () => {
try {
const targetPath = await TauriService.getInstancePath(targetInstanceId);
const data = await TauriService.readBinaryFile(`${targetPath}/uid.dat`);
const currentUid = new TextDecoder().decode(data);
if (currentUid.trim()) {
setUid(currentUid);
} else {
setUid("0xFF02F0C87E8AC1F2");
}
} catch (e) {
setUid("0xFF02F0C87E8AC1F2");
}
})();
}
}, [isOpen, targetInstanceId]);
const validInstances = instances.filter((i: any) => installedVersions.includes(i.instanceId) && i.instanceId !== targetInstanceId);
const handleSave = async () => {
playPressSound("save_click.wav");
try {
let finalUid = uid;
if (mode === "copy") {
if (!selectedInstance) {
setError("Select an instance to copy from.");
return;
}
const sourcePath = await TauriService.getInstancePath(selectedInstance);
try {
const sourceData = await TauriService.readBinaryFile(`${sourcePath}/uid.dat`);
finalUid = new TextDecoder().decode(sourceData);
} catch (e) {
setError("Source instance has no uid.dat or it could not be read.");
return;
}
}
if (!finalUid) {
setError("UID cannot be empty.");
return;
}
const encodedUid = new TextEncoder().encode(finalUid);
const targetPath = await TauriService.getInstancePath(targetInstanceId);
await TauriService.writeBinaryFile(`${targetPath}/uid.dat`, encodedUid);
onClose();
} catch (e: any) {
setError(e.toString());
}
};
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playBackSound("close_click.wav");
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % 5);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + 5) % 5);
} else if (e.key === "Enter") {
if (focusIndex === 0) {
playPressSound();
setMode("manual");
} else if (focusIndex === 1) {
playPressSound();
setMode("copy");
} else if (focusIndex === 2 && mode === "copy") {
playPressSound();
setIsDropdownOpen(!isDropdownOpen);
} else if (focusIndex === 3) {
playBackSound("close_click.wav");
onClose();
} else if (focusIndex === 4) {
handleSave();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, mode, uid, selectedInstance, isDropdownOpen]);
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
>
<div
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Set UID
</h2>
<div className="flex gap-4 mb-5 w-full justify-center">
<button
onMouseEnter={() => setFocusIndex(0)}
onClick={() => { playPressSound(); setMode("manual"); }}
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 0 ? "text-[#FFFF55]" : mode === "manual" ? "text-white" : "text-gray-400"}`}
style={{
backgroundImage: focusIndex === 0 || mode === "manual"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Manual
</button>
<button
onMouseEnter={() => setFocusIndex(1)}
onClick={() => { playPressSound(); setMode("copy"); }}
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 1 ? "text-[#FFFF55]" : mode === "copy" ? "text-white" : "text-gray-400"}`}
style={{
backgroundImage: focusIndex === 1 || mode === "copy"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Copy
</button>
</div>
<div className="flex flex-col gap-4 w-full min-h-[80px]">
{mode === "manual" ? (
<div className="flex flex-col gap-1 items-center w-full">
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
Enter UID
</label>
<input
type="text"
autoFocus
value={uid}
onChange={(e) => setUid(e.target.value)}
onFocus={() => setFocusIndex(2)}
placeholder="0xFF02F0C87E8AC1F2"
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} text-white text-base outline-none font-['Mojangles'] text-center`}
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
/>
</div>
) : (
<div className="flex flex-col gap-1 items-center w-full relative">
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
Select Installed Instance
</label>
<div
onClick={() => { playPressSound(); setIsDropdownOpen(!isDropdownOpen); }}
onFocus={() => { setFocusIndex(2); setIsDropdownOpen(false); }}
tabIndex={0}
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} flex items-center justify-between text-white text-base outline-none font-['Mojangles'] cursor-pointer`}
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
>
<span className="truncate">
{selectedInstance
? (() => {
const i = validInstances.find((inst: any) => inst.instanceId === selectedInstance);
return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : "-- Select an Instance --";
})()
: "-- Select an Instance --"}
</span>
<span className="text-xs"></span>
</div>
{isDropdownOpen && validInstances.length > 0 && (
<div className="absolute top-[60px] left-0 w-full max-h-40 overflow-y-auto bg-black/90 border-2 border-[#373737] z-50 flex flex-col custom-scrollbar shadow-xl" style={{ imageRendering: "pixelated" }}>
{validInstances.map((i: any) => (
<div
key={i.instanceId}
onClick={() => {
playPressSound();
setSelectedInstance(i.instanceId);
setIsDropdownOpen(false);
}}
className="px-3 py-2 text-white text-sm cursor-pointer hover:bg-white/20 transition-colors truncate font-['Mojangles']"
>
{i.name} {i.selectedBranch ? `(${i.selectedBranch})` : ""}
</div>
))}
</div>
)}
{validInstances.length === 0 && (
<p className="text-red-400 text-xs text-center mt-1">No other installed instances available.</p>
)}
</div>
)}
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mt-2">
{error}
</div>
)}
</div>
<div className="flex gap-4 mt-6 w-full justify-center">
<button
onMouseEnter={() => setFocusIndex(3)}
onClick={() => {
playBackSound("close_click.wav");
onClose();
}}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 3 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 3
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
<button
onMouseEnter={() => setFocusIndex(4)}
onClick={handleSave}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 4 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 4
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Save
</button>
</div>
</div>
</motion.div>
);
}
+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>
);
}
+70 -31
View File
@@ -20,6 +20,7 @@ const HomeView = memo(function HomeView() {
downloadingId,
isGameRunning,
stopGame,
updatesAvailable,
} = useGame();
const isFocusedSection = focusSection === "menu";
@@ -54,10 +55,30 @@ const HomeView = memo(function HomeView() {
isDanger: isGameRunning,
disabled: isDownloading,
},
{ label: "Help & Options", action: () => setActiveView("settings"), disabled: false },
{ label: "Versions", action: () => setActiveView("versions"), disabled: false },
{ label: "Workshop", action: () => setActiveView("workshop"), disabled: false },
{ label: "Developer Tools", action: () => setActiveView("devtools"), disabled: false },
{
label: "Help & Options",
action: () => setActiveView("settings"),
disabled: false,
id: "settings",
},
{
label: "Versions",
action: () => setActiveView("versions"),
disabled: false,
id: "versions",
},
{
label: "Workshop",
action: () => setActiveView("workshop"),
disabled: false,
id: "workshop",
},
{
label: "Developer Tools",
action: () => setActiveView("devtools"),
disabled: false,
id: "devtools",
},
],
[
isDownloading,
@@ -105,40 +126,58 @@ const HomeView = memo(function HomeView() {
animate={{ opacity: isFocusedSection ? 1 : 0.5, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="w-full max-w-[540px] flex flex-col space-y-3 outline-none"
className="relative w-full max-w-[540px] flex flex-col space-y-3 outline-none"
>
{buttons.map((btn: any, i: number) => (
<button
key={i}
onMouseEnter={() => isFocusedSection && !btn.disabled && setMenuFocus(i)}
onMouseLeave={() => setMenuFocus(null)}
onClick={() => {
if (isFocusedSection && !btn.disabled) {
playPressSound();
btn.action();
<div key={i} className="relative w-full group">
<button
onMouseEnter={() =>
isFocusedSection && !btn.disabled && setMenuFocus(i)
}
}}
disabled={btn.disabled}
className={`w-full h-12 flex items-center justify-center text-2xl mc-text-shadow transition-colors outline-none border-none ${btn.disabled ? "text-gray-400 cursor-not-allowed" : menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
style={{
backgroundImage: btn.disabled
? "url('/images/Button_Background.png')"
: menuFocus === i
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
opacity: btn.disabled ? 0.5 : 1,
}}
>
{btn.label}
</button>
onMouseLeave={() => setMenuFocus(null)}
onClick={() => {
if (isFocusedSection && !btn.disabled) {
playPressSound();
btn.action();
}
}}
disabled={btn.disabled}
className={`w-full h-12 flex items-center justify-between px-6 text-2xl mc-text-shadow transition-colors outline-none border-none ${btn.disabled ? "text-gray-400 cursor-not-allowed" : menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
style={{
backgroundImage: btn.disabled
? "url('/images/Button_Background.png')"
: menuFocus === i
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
opacity: btn.disabled ? 0.5 : 1,
}}
>
<div className="w-full h-full flex items-center justify-center relative">
<span>{btn.label}</span>
{btn.id === "versions" &&
Object.values(updatesAvailable || {}).some((v) => v) && (
<img
src="/images/Update_Icon.png"
className="absolute right-4 w-6 h-6 object-contain"
style={{
imageRendering: "pixelated",
filter:
"drop-shadow(0 0 2px rgba(255, 255, 0, 0.8)) sepia(100%) saturate(500%) hue-rotate(5deg) brightness(1.2)",
}}
/>
)}
</div>
</button>
</div>
))}
{!legacyMode && (
<div className="pt-4 flex flex-col items-center w-full gap-3">
<div className="flex gap-8">
<a
href="https://discord.gg/vD8akRU24f"
href="https://discord.gg/cQVKhQXcCx"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
@@ -180,7 +219,7 @@ const HomeView = memo(function HomeView() {
setShowCredits(true);
}
}}
className={`text-white hover:text-[#FFFF55] text-xl mc-text-shadow tracking-widest transition-colors mt-1 bg-transparent border-none outline-none ${!isFocusedSection ? "cursor-default pointer-events-none" : ""}`}
className={`text-white hover:text-[#FFFF55] text-xl mc-text-shadow tracking-widest transition-colors mt-1 bg-transparent border-none outline-none ${!isFocusedSection ? "pointer-events-none" : ""}`}
>
EMERALD TEAM
</button>
+960
View File
@@ -0,0 +1,960 @@
import { useState, useEffect, useRef, useMemo, memo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
useUI,
useConfig,
useAudio,
useGame,
} from "../../context/LauncherContext";
import {
lceLiveService,
LceLiveAccount,
FriendRequest,
GameInvite,
} from "../../services/LceLiveService";
import { TauriService } from "../../services/TauriService";
import ChooseInstanceModal from "../modals/ChooseInstanceModal";
const LceLiveView = memo(function LceLiveView() {
const { setActiveView } = useUI();
const { animationsEnabled } = useConfig();
const { playPressSound, playBackSound } = useAudio();
const { editions, installs } = useGame();
const [isSignedIn, setIsSignedIn] = useState(lceLiveService.signedIn);
const [currentTab, setCurrentTab] = useState<
"friends" | "requests" | "invites" | "device_link"
>("friends");
const [focusIndex, setFocusIndex] = useState<number | null>(0);
const [acceptInvite, setAcceptInvite] = useState<GameInvite | null>(null);
const [friends, setFriends] = useState<LceLiveAccount[]>([]);
const [incomingReqs, setIncomingReqs] = useState<FriendRequest[]>([]);
const [outgoingReqs, setOutgoingReqs] = useState<FriendRequest[]>([]);
const [invites, setInvites] = useState<GameInvite[]>([]);
const [linkData, setLinkData] = useState<any>(null);
const [linkError, setLinkError] = useState<string | null>(null);
const [isHosting, setIsHosting] = useState(false);
const [hostStatus, setHostStatus] = useState("");
const [hostIp, setHostIp] = useState("");
const [hostPort, setHostPort] = useState(19132);
const [isDiscovering, setIsDiscovering] = useState(false);
const [invitedFriends, setInvitedFriends] = useState<Set<string>>(new Set());
const [showHostMethodPicker, setShowHostMethodPicker] = useState(false);
const [hostSessionId, setHostSessionId] = useState<string>("");
const [hostMethodPick, setHostMethodPick] = useState<"direct" | "relay">(
"direct",
);
const [relayStarted, setRelayStarted] = useState(false);
const [isAddingFriend, setIsAddingFriend] = useState(false);
const [addFriendUsername, setAddFriendUsername] = useState("");
const addFriendInputRef = useRef<HTMLInputElement>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fetchSocialData = async () => {
if (!lceLiveService.signedIn) return;
try {
const [f, reqs, invs] = await Promise.all([
lceLiveService.getFriends(),
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites(),
]);
setFriends(f);
setIncomingReqs(reqs.incoming);
setOutgoingReqs(reqs.outgoing);
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
} catch (e: any) {
console.error(e);
}
};
useEffect(() => {
if (isSignedIn) {
if (currentTab === "device_link") setCurrentTab("friends");
fetchSocialData();
const pollInvites = setInterval(async () => {
try {
const invs = await lceLiveService.getGameInvites();
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
} catch (e) {
console.warn("Failed to poll invites", e);
}
}, 5000);
return () => clearInterval(pollInvites);
} else {
setCurrentTab("device_link");
}
}, [isSignedIn, currentTab]);
useEffect(() => {
if (currentTab !== "device_link") return;
let mounted = true;
let pollInterval: any = null;
const startLink = async () => {
try {
if (!linkData) {
const data = await lceLiveService.startDeviceLink();
if (mounted) setLinkData(data);
}
} catch (e: any) {
if (mounted) setLinkError(e.message);
}
};
startLink();
if (linkData?.deviceCode) {
pollInterval = setInterval(
async () => {
try {
const res = await lceLiveService.pollDeviceLink(
linkData.deviceCode,
);
if (res.isLinked && mounted) {
setIsSignedIn(true);
setLinkData(null);
clearInterval(pollInterval);
}
} catch (e: any) {
console.warn("Poll failed", e);
}
},
Math.max(linkData.intervalSeconds * 1000, 2000),
);
}
return () => {
mounted = false;
if (pollInterval) clearInterval(pollInterval);
};
}, [currentTab, linkData]);
const handleLogout = () => {
playPressSound();
lceLiveService.logoutLocal();
setIsSignedIn(false);
setLinkData(null);
};
const handleAction = async (action: () => Promise<void>) => {
playPressSound();
try {
await action();
fetchSocialData();
} catch (e: any) {
setErrorModal(e.message || "An error occurred");
}
};
const handleStartHosting = () => {
playPressSound();
setRelayStarted(false);
setShowHostMethodPicker(true);
};
const handleHostDirect = async () => {
setShowHostMethodPicker(false);
setIsDiscovering(true);
setHostStatus("Discovering external IP...");
try {
const endpoint = await TauriService.stunDiscover();
setHostIp(endpoint.ip);
setHostPort(25565);
setIsHosting(true);
setHostStatus(`Hosting at ${endpoint.ip}:25565`);
setInvitedFriends(new Set());
setHostSessionId("");
} catch (e: any) {
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
setErrorModal("STUN discovery failed: " + msg);
setHostStatus("");
} finally {
setIsDiscovering(false);
}
};
const handleHostRelay = async () => {
setShowHostMethodPicker(false);
setIsDiscovering(true);
setHostStatus("Discovering external IP for invite...");
const sessionId = crypto.randomUUID();
setHostSessionId(sessionId);
try {
const endpoint = await TauriService.stunDiscover();
setHostIp(endpoint.ip);
setHostPort(25565);
} catch {
setHostIp("127.0.0.1");
setHostPort(25565);
}
setRelayStarted(false);
setIsHosting(true);
setHostStatus("Relay ready - invite friends to activate");
setInvitedFriends(new Set());
setIsDiscovering(false);
};
const handleStopHosting = async () => {
playPressSound();
try {
await TauriService.stopProxy();
await lceLiveService.deactivateGameInvites();
} catch (e: any) {
console.warn("Stop hosting failed", e);
}
setIsHosting(false);
setHostStatus("");
setHostIp("");
setInvitedFriends(new Set());
setHostSessionId("");
setRelayStarted(false);
};
const handleInviteFriend = async (friend: LceLiveAccount) => {
playPressSound();
const name = lceLiveService.displayUsername;
try {
await lceLiveService.sendGameInvite(
friend.accountId,
hostIp,
hostPort,
name,
hostSessionId || undefined,
);
setInvitedFriends((prev) => new Set(prev).add(friend.accountId));
if (hostSessionId && !relayStarted) {
setRelayStarted(true);
setHostStatus("Connecting relay...");
const baseUrl = lceLiveService.apiBaseUrl;
const accessToken = lceLiveService.accessToken ?? "";
TauriService.startHostRelay(baseUrl, accessToken, hostSessionId, 25565)
.then(() => setHostStatus("Relay active"))
.catch((relayErr: any) => {
const relayMsg =
typeof relayErr === "string"
? relayErr
: relayErr?.message || "Unknown error";
console.warn("Relay failed:", relayMsg);
setHostStatus("Relay disconnected");
});
}
} catch (e: any) {
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
setErrorModal("Failed to send invite: " + msg);
}
};
type MenuItem = {
id: string;
type: "button" | "friend" | "request_in" | "request_out" | "invite";
label: string;
onClick: () => void;
onClickSecondary?: () => void;
};
const menuItems = useMemo<MenuItem[]>(() => {
const items: MenuItem[] = [];
if (currentTab === "device_link") {
if (linkData) {
items.push({
id: "link_retry",
type: "button",
label: "Restart Link",
onClick: () => {
setLinkData(null);
playPressSound();
},
});
}
} else if (currentTab === "friends") {
if (!isDiscovering && !showHostMethodPicker) {
if (!isHosting) {
items.push({
id: "host_game",
type: "button",
label: "Host Game",
onClick: handleStartHosting,
});
} else {
items.push({
id: "stop_hosting",
type: "button",
label: "Stop Hosting",
onClick: handleStopHosting,
});
}
}
items.push({
id: "add_friend",
type: "button",
label: "Add Friend",
onClick: () => {
playPressSound();
setIsAddingFriend(true);
setAddFriendUsername("");
},
});
items.push({
id: "sign_out",
type: "button",
label: "Sign Out",
onClick: handleLogout,
});
friends.forEach((f) => {
items.push({
id: `friend_${f.accountId}`,
type: "friend",
label: f.displayName,
onClick: isHosting
? () => handleInviteFriend(f)
: () =>
handleAction(() => lceLiveService.removeFriend(f.accountId)),
onClickSecondary: isHosting
? () => handleAction(() => lceLiveService.removeFriend(f.accountId))
: undefined,
});
});
} else if (currentTab === "requests") {
incomingReqs.forEach((r) => {
items.push({
id: `req_in_${r.accountId}`,
type: "request_in",
label: r.displayName,
onClick: () =>
handleAction(() => lceLiveService.acceptFriendRequest(r.accountId)),
onClickSecondary: () =>
handleAction(() =>
lceLiveService.declineFriendRequest(r.accountId),
),
});
});
outgoingReqs.forEach((r) => {
items.push({
id: `req_out_${r.accountId}`,
type: "request_out",
label: r.displayName,
onClick: () =>
handleAction(() =>
lceLiveService.declineFriendRequest(r.accountId),
),
});
});
} else if (currentTab === "invites") {
invites.forEach((inv) => {
items.push({
id: `inv_${inv.inviteId}`,
type: "invite",
label:
typeof inv.from === "string" ? "Unknown" : inv.from.displayName,
onClick: () => {
playPressSound();
setAcceptInvite(inv);
},
onClickSecondary: () =>
handleAction(() => lceLiveService.declineGameInvite(inv.inviteId)),
});
});
}
return items;
}, [
currentTab,
friends,
incomingReqs,
outgoingReqs,
invites,
linkData,
playPressSound,
isHosting,
isDiscovering,
showHostMethodPicker,
]);
const tabs = ["friends", "requests", "invites"];
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (errorModal) {
if (e.key === "Escape" || e.key === "Enter") {
setErrorModal(null);
}
return;
}
if (isAddingFriend) {
if (e.key === "Escape") {
setIsAddingFriend(false);
playBackSound();
} else if (e.key === "Enter") {
if (addFriendUsername.trim() !== "") {
handleAction(() =>
lceLiveService.sendFriendRequest(addFriendUsername.trim()),
);
setIsAddingFriend(false);
}
}
return;
}
if (showHostMethodPicker) {
if (e.key === "Escape" || e.key === "Backspace") {
setShowHostMethodPicker(false);
playBackSound();
} else if (
e.key === "ArrowLeft" ||
e.key === "ArrowRight" ||
e.key === "q" ||
e.key === "Q" ||
e.key === "e" ||
e.key === "E"
) {
setHostMethodPick((prev: "direct" | "relay") =>
prev === "direct" ? "relay" : "direct",
);
playPressSound();
} else if (e.key === "Enter") {
if (hostMethodPick === "direct") handleHostDirect();
else handleHostRelay();
}
return;
}
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
setActiveView("main");
return;
}
if (currentTab !== "device_link") {
const curIdx = tabs.indexOf(currentTab);
if (e.key === "q" || e.key === "Q" || e.key === "ArrowLeft") {
const next = curIdx > 0 ? tabs[curIdx - 1] : tabs[tabs.length - 1];
setCurrentTab(next as any);
setFocusIndex(0);
playPressSound();
return;
}
if (e.key === "e" || e.key === "E" || e.key === "ArrowRight") {
const next = curIdx < tabs.length - 1 ? tabs[curIdx + 1] : tabs[0];
setCurrentTab(next as any);
setFocusIndex(0);
playPressSound();
return;
}
}
const itemCount = menuItems.length;
if (itemCount > 0) {
if (e.key === "ArrowDown") {
setFocusIndex((prev) =>
prev === null || prev >= itemCount - 1 ? 0 : prev + 1,
);
} else if (e.key === "ArrowUp") {
setFocusIndex((prev) =>
prev === null || prev <= 0 ? itemCount - 1 : prev - 1,
);
} else if (e.key === "Enter" && focusIndex !== null) {
menuItems[focusIndex]?.onClick();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
focusIndex,
menuItems,
currentTab,
playBackSound,
setActiveView,
isAddingFriend,
addFriendUsername,
errorModal,
showHostMethodPicker,
hostMethodPick,
handleHostDirect,
handleHostRelay,
]);
useEffect(() => {
if (isAddingFriend && addFriendInputRef.current) {
addFriendInputRef.current.focus();
} else if (focusIndex !== null) {
const el = containerRef.current?.querySelector(
`[data-index="${focusIndex}"]`,
) as HTMLElement;
if (el) {
el.focus();
if (scrollRef.current) {
const rect = el.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
if (rect.bottom > scrollRect.bottom || rect.top < scrollRect.top) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}
}
}, [focusIndex, isAddingFriend]);
const renderContent = () => {
if (currentTab === "device_link") {
return (
<div className="flex flex-col items-center justify-center space-y-6 flex-1 text-center py-20">
{!linkData ? (
<p className="text-xl text-[#2a2a2a] font-bold">
{linkError || "Starting device link..."}
</p>
) : (
<>
<p className="text-2xl text-[#2a2a2a] font-bold">
Open this link in your browser:
</p>
<p className="text-[#111] text-3xl font-bold tracking-widest break-words bg-black/10 px-6 py-2 rounded shadow-inner">
{linkData.verificationUri}
</p>
<p className="text-2xl text-[#2a2a2a] font-bold mt-8">
And enter the code:
</p>
<p className="text-[#111] text-6xl tracking-[0.2em] font-bold mt-2 bg-black/10 px-8 py-4 rounded shadow-inner">
{linkData.userCode}
</p>
</>
)}
</div>
);
}
const topButtons = menuItems.filter((m) => m.type === "button");
const listItems = menuItems.filter((m) => m.type !== "button");
return (
<div className="flex flex-col h-full space-y-4">
{showHostMethodPicker ? (
<div className="flex gap-4 flex-wrap">
<button
onClick={handleHostDirect}
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "direct" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
style={{
backgroundImage:
hostMethodPick === "direct"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Direct (STUN)
</button>
<button
onClick={handleHostRelay}
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "relay" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
style={{
backgroundImage:
hostMethodPick === "relay"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Relay
</button>
<button
onClick={() => {
setShowHostMethodPicker(false);
playBackSound();
}}
className="flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all text-white mc-text-shadow hover:text-gray-200"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
</div>
) : (
topButtons.length > 0 && (
<div className="flex gap-4 flex-wrap">
{topButtons.map((btn) => {
const idx = menuItems.indexOf(btn);
const isFocused = focusIndex === idx;
return (
<button
key={btn.id}
data-index={idx}
onMouseEnter={() => setFocusIndex(idx)}
onClick={btn.onClick}
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${isFocused ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
style={{
backgroundImage: isFocused
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{btn.label}
</button>
);
})}
</div>
)
)}
{hostStatus && (
<div className="text-center text-sm text-[#FFFF55] mc-text-shadow py-1 tracking-wider">
{hostStatus}
</div>
)}
<div className="flex flex-col flex-1 bg-black/5 shadow-inner rounded overflow-hidden border-4 border-[#222]">
<div className="bg-black/10 px-4 py-3 text-[#2a2a2a] font-bold tracking-widest uppercase border-b-4 border-[#222] flex justify-between shadow-sm z-10">
<span>
{currentTab === "friends"
? "Joinable Friends"
: currentTab === "requests"
? "Pending Requests"
: "Game Invites"}
</span>
<span className="text-[#111]">{listItems.length}</span>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto w-full">
{listItems.length === 0 ? (
<div className="flex items-center justify-center h-[200px] text-[#555] font-bold">
None available
</div>
) : (
<div className="flex flex-col p-2 space-y-2">
{listItems.map((item) => {
const idx = menuItems.indexOf(item);
const isFocused = focusIndex === idx;
return (
<div
key={item.id}
data-index={idx}
onMouseEnter={() => setFocusIndex(idx)}
className={`w-full flex items-center justify-between px-4 py-3 relative outline-none border-none rounded transition-all ${isFocused ? "bg-black/15 shadow-inner" : "bg-transparent hover:bg-black/5"}`}
tabIndex={-1}
>
<div className="flex items-center w-full">
<div className="flex flex-col ml-2 flex-1 min-w-0">
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
{item.label}
</span>
<span className="text-[#555] text-base font-bold truncate">
@
{menuItems.find((m) => m.id === item.id)?.type ===
"friend"
? friends.find(
(f) => `friend_${f.accountId}` === item.id,
)?.username
: item.type === "request_in"
? incomingReqs.find(
(f) => `req_in_${f.accountId}` === item.id,
)?.username
: item.type === "request_out"
? outgoingReqs.find(
(f) => `req_out_${f.accountId}` === item.id,
)?.username
: "Invite"}
</span>
</div>
</div>
<div className="flex space-x-3 pr-2 shrink-0">
{item.type === "friend" && !isHosting && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
REMOVE
</button>
)}
{item.type === "friend" && isHosting && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
{invitedFriends.has(
item.id.replace("friend_", ""),
)
? "INVITED"
: "INVITE"}
</button>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
>
REMOVE
</button>
</>
)}
{item.type === "request_out" && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
CANCEL
</button>
)}
{(item.type === "request_in" ||
item.type === "invite") && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
ACCEPT
</button>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
>
DECLINE
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
return (
<motion.div
ref={containerRef}
tabIndex={-1}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center justify-center w-full h-full absolute inset-0 outline-none p-12"
>
<div className="w-full max-w-5xl h-full flex flex-col mt-[4vh] mb-[4vh] relative drop-shadow-2xl">
{currentTab !== "device_link" && (
<div
className="flex z-10 space-x-2 px-12 relative w-full items-end"
style={{ marginBottom: "-4px" }}
>
{tabs.map((t) => (
<button
key={t}
className={`flex-1 font-bold text-xl outline-none uppercase transition-all duration-200 ease-in-out ${currentTab === t ? "text-[#2a2a2a] z-20 pb-6 pt-5 text-2xl drop-shadow-[5px_-5px_15px_rgba(0,0,0,0.3)] rounded-t border-4 border-[#222] border-b-0" : "text-[#555] mt-2 py-4 hover:bg-black/30 bg-black/10 hover:text-[#222] border-4 border-transparent border-b-0"}`}
style={{
backgroundImage: "url('/images/background.png')",
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
backgroundPosition: "bottom",
imageRendering: "pixelated",
}}
onClick={() => {
setCurrentTab(t as any);
setFocusIndex(0);
playPressSound();
}}
>
<div className="flex items-center justify-center">
{t}
{t === "requests" && incomingReqs.length > 0 && (
<span
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
>
{incomingReqs.length}
</span>
)}
{t === "invites" && invites.length > 0 && (
<span
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#30872a] border-[#1b5e16]" : "bg-[#23681d] border-[#111]"}`}
>
{invites.length}
</span>
)}
</div>
</button>
))}
</div>
)}
<div
className="flex-1 flex flex-col p-8 z-10 relative overflow-hidden rounded-b shadow-[0_0_30px_rgba(0,0,0,0.6)] border-4 border-[#222] border-t-0"
style={{
backgroundImage: "url('/images/background.png')",
backgroundSize: "100% auto",
backgroundRepeat: "repeat",
backgroundPosition: "top",
imageRendering: "pixelated",
}}
>
{renderContent()}
</div>
</div>
<AnimatePresence>
{isAddingFriend && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[420px] p-8 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-6 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
Add Friend
</h2>
<input
ref={addFriendInputRef}
type="text"
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-6 mc-text-shadow"
placeholder="Username"
value={addFriendUsername}
onChange={(e) => setAddFriendUsername(e.target.value)}
/>
<div className="flex gap-4 w-full">
<button
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={() => {
playPressSound();
if (addFriendUsername.trim() !== "") {
handleAction(() =>
lceLiveService.sendFriendRequest(
addFriendUsername.trim(),
),
);
setIsAddingFriend(false);
}
}}
>
Send
</button>
<button
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={() => {
setIsAddingFriend(false);
playBackSound();
}}
>
Cancel
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{errorModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[400px] p-8 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
Error
</h2>
<p className="text-white text-lg mc-text-shadow text-center mb-6">
{errorModal}
</p>
<button
className="h-12 w-48 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={() => setErrorModal(null)}
>
OK
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<ChooseInstanceModal
isOpen={acceptInvite !== null}
onClose={() => {
setAcceptInvite(null);
fetchSocialData();
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
editions={editions}
installs={installs}
invite={acceptInvite}
/>
</motion.div>
);
});
export default LceLiveView;
+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
+454
View File
@@ -0,0 +1,454 @@
import { useState, useEffect, memo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
useUI,
useAudio,
useGame,
useConfig,
} from "../../context/LauncherContext";
import {
ScreenshotService,
ScreenshotInfo,
} from "../../services/ScreenshotService";
import { ScreenshotImage } from "../common/ScreenshotImage";
const ScreenshotsView = memo(function ScreenshotsView() {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { editions } = useGame();
const { animationsEnabled } = useConfig();
const [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
const [selectedScreenshot, setSelectedScreenshot] =
useState<ScreenshotInfo | null>(null);
const [loading, setLoading] = useState(true);
const [gridFocusIndex, setGridFocusIndex] = useState(0);
const [modalFocusIndex, setModalFocusIndex] = useState(0);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmFocusIndex, setDeleteConfirmFocusIndex] = useState(0);
useEffect(() => {
ScreenshotService.getScreenshots().then((data) => {
setScreenshots(data);
setLoading(false);
});
}, []);
const handleBack = () => {
playBackSound();
setActiveView("main");
};
const handleOpenFolder = (screenshot: ScreenshotInfo) => {
playPressSound();
ScreenshotService.showInFolder(screenshot.path);
};
const confirmDelete = async () => {
if (!selectedScreenshot) return;
playPressSound();
await ScreenshotService.deleteScreenshot(selectedScreenshot.path);
setScreenshots((prev) =>
prev.filter((s) => s.path !== selectedScreenshot.path),
);
setSelectedScreenshot(null);
setShowDeleteConfirm(false);
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (loading) return;
if (showDeleteConfirm) {
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
setShowDeleteConfirm(false);
} else if (
e.key === "ArrowLeft" ||
e.key === "ArrowRight" ||
e.key === "Tab"
) {
e.preventDefault();
playPressSound();
setDeleteConfirmFocusIndex((prev) => (prev === 0 ? 1 : 0));
} else if (e.key === "Enter") {
if (deleteConfirmFocusIndex === 1) confirmDelete();
else {
playBackSound();
setShowDeleteConfirm(false);
}
}
return;
}
if (selectedScreenshot) {
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
setSelectedScreenshot(null);
} else if (e.key === "ArrowLeft") {
playPressSound();
setModalFocusIndex((prev) => (prev > 0 ? prev - 1 : 2));
} else if (e.key === "ArrowRight" || e.key === "Tab") {
e.preventDefault();
playPressSound();
setModalFocusIndex((prev) => (prev < 2 ? prev + 1 : 0));
} else if (e.key === "Enter") {
if (modalFocusIndex === 0) handleOpenFolder(selectedScreenshot);
else if (modalFocusIndex === 1) {
playPressSound();
setDeleteConfirmFocusIndex(0);
setShowDeleteConfirm(true);
} else if (modalFocusIndex === 2) {
playBackSound();
setSelectedScreenshot(null);
}
}
return;
}
const cols =
window.innerWidth >= 1024 ? 4 : window.innerWidth >= 768 ? 3 : 2;
if (e.key === "Escape" || e.key === "Backspace") {
handleBack();
} else if (e.key === "ArrowLeft") {
setGridFocusIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === "ArrowRight") {
setGridFocusIndex((prev) =>
prev < screenshots.length - 1 ? prev + 1 : prev,
);
} else if (e.key === "ArrowUp") {
setGridFocusIndex((prev) => (prev >= cols ? prev - cols : prev));
} else if (e.key === "ArrowDown") {
setGridFocusIndex((prev) =>
prev <= screenshots.length - 1 - cols ? prev + cols : prev,
);
} else if (e.key === "Enter") {
if (screenshots[gridFocusIndex]) {
playPressSound();
setModalFocusIndex(2);
setSelectedScreenshot(screenshots[gridFocusIndex]);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
loading,
selectedScreenshot,
gridFocusIndex,
modalFocusIndex,
screenshots,
showDeleteConfirm,
deleteConfirmFocusIndex,
]);
useEffect(() => {
if (!selectedScreenshot) {
const element = document.getElementById(`ss-${gridFocusIndex}`);
element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [gridFocusIndex, selectedScreenshot]);
const getEditionLogo = (instanceId: string) => {
const edition = editions.find((e: any) => e.id === instanceId);
return edition?.logo || edition?.titleImage;
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full h-full max-w-6xl relative font-['Mojangles'] text-white select-none outline-none focus:outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-4 mb-6 border-b-2 border-[#373737] pb-2 w-[30%] max-w-[250px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
Screenshots
</h2>
<div className="w-[98%] flex-1 relative overflow-hidden">
<div className="absolute inset-0 overflow-y-auto p-6 scroll-smooth custom-scrollbar">
{loading ? (
<div className="flex flex-col items-center justify-center h-full gap-4">
<span className="text-3xl text-[#FFFF55] mc-text-shadow tracking-widest animate-pulse uppercase">
Scanning Archives...
</span>
</div>
) : screenshots.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full space-y-4 opacity-40">
<span className="text-2xl mc-text-shadow uppercase tracking-widest">
No screenshots found
</span>
<span className="text-sm mc-text-shadow tracking-widest italic">
Take some in-game with F2
</span>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{screenshots.map((ss, index) => (
<div
key={ss.path}
id={`ss-${index}`}
onClick={() => {
setGridFocusIndex(index);
setModalFocusIndex(2); // Close button
setSelectedScreenshot(ss);
playPressSound();
}}
onMouseEnter={() => setGridFocusIndex(index)}
className={`
relative aspect-video flex flex-col cursor-pointer transition-all border-2 rounded-sm overflow-hidden bg-black/40
${gridFocusIndex === index ? "border-[#FFFF55] scale-105 z-10" : "border-[#333] hover:border-[#FFFF55]"}
`}
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
boxShadow:
gridFocusIndex === index
? "0 0 20px rgba(255, 255, 85, 0.2)"
: "none",
}}
>
<div className="w-full h-full relative overflow-hidden bg-black/50">
<ScreenshotImage
path={ss.path}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
alt={ss.name}
fallbackSrc="/images/Folder_Icon.png"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60" />
{getEditionLogo(ss.instanceId) && (
<div className="absolute bottom-2 left-2 flex items-center gap-2">
<img
src={getEditionLogo(ss.instanceId)}
className="w-6 h-6 object-contain drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)]"
style={{ imageRendering: "pixelated" }}
/>
</div>
)}
<div className="absolute bottom-2 right-2 text-[8px] bg-black/60 border border-[#555] px-1.5 py-0.5 text-[#A0A0A0] mc-text-shadow uppercase tracking-tighter">
{new Date(ss.date * 1000).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="w-full mt-6 mb-4 flex justify-center">
<button
onClick={handleBack}
className={`
w-72 h-10 flex items-center justify-center text-xl mc-text-shadow border-none outline-none transition-all text-white
hover:text-[#FFFF55]
`}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/button_highlighted.png')";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/Button_Background.png')";
}}
>
Back
</button>
</div>
<AnimatePresence>
{selectedScreenshot && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] bg-black/90 flex flex-col items-center justify-center p-8 backdrop-blur-md"
onClick={() => setSelectedScreenshot(null)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="relative max-w-5xl w-full flex flex-col items-center border-2 border-[#555] rounded-sm p-2"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full aspect-video bg-black/60 overflow-hidden border border-[#444] rounded-sm">
<ScreenshotImage
path={selectedScreenshot.path}
className="w-full h-full object-contain"
fallbackSrc="/images/Pack_Icon.png"
/>
<div className="absolute bottom-4 left-6 right-6 flex items-end justify-between pointer-events-none">
<div className="flex flex-col gap-1">
<span className="text-xl text-white mc-text-shadow block leading-tight tracking-wide font-bold">
{selectedScreenshot.name}
</span>
<span className="text-sm text-[#FFFF55] mc-text-shadow uppercase tracking-widest opacity-90">
Captured on{" "}
{new Date(
selectedScreenshot.date * 1000,
).toLocaleString()}
</span>
</div>
{getEditionLogo(selectedScreenshot.instanceId) && (
<img
src={getEditionLogo(selectedScreenshot.instanceId)}
className="w-16 h-16 object-contain drop-shadow-[0_4px_4px_rgba(0,0,0,0.8)]"
style={{ imageRendering: "pixelated" }}
/>
)}
</div>
</div>
<div className="flex gap-6 mt-6 mb-2 w-full justify-center px-6">
<button
onMouseEnter={() => setModalFocusIndex(0)}
onClick={() => handleOpenFolder(selectedScreenshot)}
className={`
flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow border-none outline-none cursor-pointer transition-all
${modalFocusIndex === 0 ? "text-[#FFFF55] scale-105" : "text-white"}
`}
style={{
backgroundImage:
modalFocusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
OPEN FOLDER
</button>
<button
onMouseEnter={() => setModalFocusIndex(1)}
onClick={() => {
playPressSound();
setDeleteConfirmFocusIndex(0);
setShowDeleteConfirm(true);
}}
className={`
flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow border-none outline-none cursor-pointer transition-all
${modalFocusIndex === 1 ? "text-[#FF5555] scale-105" : "text-white"}
`}
style={{
backgroundImage:
modalFocusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
DELETE
</button>
<button
onMouseEnter={() => setModalFocusIndex(2)}
onClick={() => setSelectedScreenshot(null)}
className={`
w-48 h-12 flex items-center justify-center text-xl mc-text-shadow border-none outline-none cursor-pointer transition-all
${modalFocusIndex === 2 ? "text-[#FFFF55] scale-105" : "text-white"}
`}
style={{
backgroundImage:
modalFocusIndex === 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
CLOSE
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showDeleteConfirm && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[300] bg-black/80 backdrop-blur-sm flex items-center justify-center"
onClick={() => setShowDeleteConfirm(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-[420px] p-6 border-2 border-[#555] rounded-sm flex flex-col items-center"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => e.stopPropagation()}
>
<span className="text-2xl text-white mc-text-shadow text-center mb-6 px-4">
Are you sure you want to delete this screenshot?
</span>
<div className="flex gap-4 w-full">
<button
onMouseEnter={() => setDeleteConfirmFocusIndex(0)}
onClick={() => {
playBackSound();
setShowDeleteConfirm(false);
}}
className={`
flex-1 h-10 flex items-center justify-center text-lg mc-text-shadow border-none outline-none cursor-pointer transition-all
${deleteConfirmFocusIndex === 0 ? "text-[#FFFF55] scale-105" : "text-white"}
`}
style={{
backgroundImage:
deleteConfirmFocusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
CANCEL
</button>
<button
onMouseEnter={() => setDeleteConfirmFocusIndex(1)}
onClick={confirmDelete}
className={`
flex-1 h-10 flex items-center justify-center text-lg mc-text-shadow border-none outline-none cursor-pointer transition-all
${deleteConfirmFocusIndex === 1 ? "text-[#FF5555] scale-105" : "text-white"}
`}
style={{
backgroundImage:
deleteConfirmFocusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
DELETE
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
});
export default ScreenshotsView;
+198 -101
View File
@@ -2,16 +2,56 @@ import { useState, useEffect, useRef, useMemo, memo } from "react";
import { motion } from "framer-motion";
import { TauriService, Runner } from "../../services/TauriService";
import { usePlatform } from "../../hooks/usePlatform";
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
import {
useUI,
useConfig,
useAudio,
useGame,
} from "../../context/LauncherContext";
const SettingsView = memo(function SettingsView() {
const { setActiveView } = useUI();
const { vfxEnabled, setVfxEnabled, animationsEnabled, setAnimationsEnabled, musicVol: musicVolume, setMusicVol: setMusicVolume, sfxVol: sfxVolume, setSfxVol: setSfxVolume, layout, setLayout, linuxRunner, setLinuxRunner, perfBoost, setPerfBoost, rpcEnabled, setRpcEnabled, legacyMode, setLegacyMode } = useConfig();
const { currentTrack, setCurrentTrack, tracks, playPressSound, playBackSound } = useAudio();
const { isGameRunning, stopGame, isRunnerDownloading, runnerDownloadProgress, downloadRunner } = useGame();
const {
vfxEnabled,
setVfxEnabled,
animationsEnabled,
setAnimationsEnabled,
musicVol: musicVolume,
setMusicVol: setMusicVolume,
sfxVol: sfxVolume,
setSfxVol: setSfxVolume,
layout,
setLayout,
linuxRunner,
setLinuxRunner,
perfBoost,
setPerfBoost,
rpcEnabled,
setRpcEnabled,
legacyMode,
setLegacyMode,
mangohudEnabled,
setMangohudEnabled,
} = useConfig();
const {
currentTrack,
setCurrentTrack,
tracks,
playPressSound,
playBackSound,
} = useAudio();
const {
isGameRunning,
stopGame,
isRunnerDownloading,
runnerDownloadProgress,
downloadRunner,
} = useGame();
const { isLinux, isMac } = usePlatform();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [currentSubMenu, setCurrentSubMenu] = useState<"main" | "audio" | "video" | "controls" | "launcher">("main");
const [currentSubMenu, setCurrentSubMenu] = useState<
"main" | "audio" | "video" | "controls" | "launcher"
>("main");
const [runners, setRunners] = useState<Runner[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
@@ -53,6 +93,11 @@ const SettingsView = memo(function SettingsView() {
setLegacyMode(!legacyMode);
};
const handleMangohudToggle = () => {
playPressSound();
setMangohudEnabled(!mangohudEnabled);
};
const handleRunnerToggle = () => {
playPressSound();
if (runners.length === 0) return;
@@ -69,35 +114,37 @@ const SettingsView = memo(function SettingsView() {
const handleResetSetup = () => {
playPressSound();
// Create styled confirmation dialog
const dialog = document.createElement('div');
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
const dialog = document.createElement("div");
dialog.className =
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
dialog.innerHTML = `
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
<h3 class="text-2xl font-bold text-white mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">Reset Setup</h3>
<p class="text-white mb-6 text-center">Are you sure you want to reset launcher setup?</p>
<div class="flex gap-4 justify-center">
<button id="reset-yes" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Yes</button>
<button id="reset-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">No</button>
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
<h3 class="text-2xl font-bold text-[#333333] mb-4 text-left w-full px-4 mc-text-shadow">Reset Setup</h3>
<p class="text-[#333333] mb-8 text-left w-full px-4">Are you sure you want to reset launcher setup?</p>
<div class="flex flex-col gap-3 w-full px-4">
<button id="reset-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
<button id="reset-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">OK</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const handleYes = () => {
const handleOk = () => {
document.body.removeChild(dialog);
showSecondConfirmation();
};
const handleNo = () => {
const handleCancel = () => {
document.body.removeChild(dialog);
};
dialog.querySelector('#reset-yes')?.addEventListener('click', handleYes);
dialog.querySelector('#reset-no')?.addEventListener('click', handleNo);
dialog.querySelector("#reset-ok")?.addEventListener("click", handleOk);
dialog
.querySelector("#reset-cancel")
?.addEventListener("click", handleCancel);
dialog.addEventListener('click', (e) => {
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
document.body.removeChild(dialog);
}
@@ -105,43 +152,48 @@ const SettingsView = memo(function SettingsView() {
};
const showSecondConfirmation = () => {
const dialog = document.createElement('div');
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
const dialog = document.createElement("div");
dialog.className =
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
dialog.innerHTML = `
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
<h3 class="text-2xl font-bold text-yellow-400 mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">CONFIRM RESET</h3>
<div class="text-white mb-6 text-left">
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
<h3 class="text-2xl font-bold text-[#333333] mb-2 text-left w-full px-4 mc-text-shadow">CONFIRM RESET</h3>
<div class="text-[#333333] mb-6 text-left w-full px-4">
<p class="mb-2">⚠️ This will:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<ul class="list-none space-y-1 text-sm">
<li>Clear all launcher settings</li>
<li>Reset your username</li>
<li>Show setup screen again</li>
<li>Require reconfiguration</li>
</ul>
<p class="mt-3 text-yellow-400 font-bold">This action cannot be undone!</p>
<p class="mt-3 text-[#333333] font-bold">This action cannot be undone!</p>
</div>
<div class="flex gap-4 justify-center">
<button id="reset-final-yes" class="mc-sq-btn px-6 py-3 text-yellow-400 hover:scale-105 active:scale-95 transition-transform">YES, RESET</button>
<button id="reset-final-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Cancel</button>
<div class="flex flex-col gap-3 w-full px-4">
<button id="reset-final-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
<button id="reset-final-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">OK</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const handleFinalYes = () => {
const handleFinalOk = () => {
document.body.removeChild(dialog);
performReset();
};
const handleFinalNo = () => {
const handleFinalCancel = () => {
document.body.removeChild(dialog);
};
dialog.querySelector('#reset-final-yes')?.addEventListener('click', handleFinalYes);
dialog.querySelector('#reset-final-no')?.addEventListener('click', handleFinalNo);
dialog
.querySelector("#reset-final-ok")
?.addEventListener("click", handleFinalOk);
dialog
.querySelector("#reset-final-cancel")
?.addEventListener("click", handleFinalCancel);
dialog.addEventListener('click', (e) => {
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
document.body.removeChild(dialog);
}
@@ -149,13 +201,10 @@ const SettingsView = memo(function SettingsView() {
};
const performReset = () => {
// Clear all localStorage data
localStorage.clear();
// Set setup as not completed
localStorage.setItem('lce-setup-completed', 'false');
localStorage.setItem("lce-setup-completed", "false");
// Force reload to show setup screen
window.location.reload();
};
@@ -163,11 +212,9 @@ const SettingsView = memo(function SettingsView() {
if (tracks && tracks.length > 0) {
const fullPath = tracks[currentTrack];
if (fullPath) {
trackName = fullPath
.split("/")
.pop()
?.replace(".ogg", "")
.replace(".wav", "") || "Unknown";
trackName =
fullPath.split("/").pop()?.replace(".ogg", "").replace(".wav", "") ||
"Unknown";
}
}
@@ -176,20 +223,20 @@ const SettingsView = memo(function SettingsView() {
type SettingsItem =
| {
id: string;
label: string;
type: "slider";
value: number;
onChange: (val: any) => void;
}
id: string;
label: string;
type: "slider";
value: number;
onChange: (val: any) => void;
}
| {
id: string;
label: string;
type: "button";
onClick: () => void;
small?: boolean;
color?: string;
};
id: string;
label: string;
type: "button";
onClick: () => void;
small?: boolean;
color?: string;
};
const settingsItems = useMemo<SettingsItem[]>(() => {
const items: SettingsItem[] = [];
@@ -199,25 +246,41 @@ const SettingsView = memo(function SettingsView() {
id: "audio_menu",
label: "Audio",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("audio"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("audio");
setFocusIndex(0);
},
});
items.push({
id: "video_menu",
label: "Video",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("video"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("video");
setFocusIndex(0);
},
});
items.push({
id: "controls_menu",
label: "Controls",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("controls"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("controls");
setFocusIndex(0);
},
});
items.push({
id: "launcher_menu",
label: "Launcher",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("launcher"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("launcher");
setFocusIndex(0);
},
});
} else if (currentSubMenu === "audio") {
items.push({
@@ -288,6 +351,12 @@ const SettingsView = memo(function SettingsView() {
type: "button",
onClick: handleRunnerToggle,
});
items.push({
id: "mangohud",
label: `MangoHud: ${mangohudEnabled ? "ON" : "OFF"}`,
type: "button",
onClick: handleMangohudToggle,
});
items.push({
id: "download_runner",
label: isRunnerDownloading
@@ -296,7 +365,10 @@ const SettingsView = memo(function SettingsView() {
type: "button",
onClick: () => {
if (!isRunnerDownloading) {
downloadRunner("GE-Proton9-25", "https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz");
downloadRunner(
"GE-Proton9-25",
"https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz",
);
}
},
small: true,
@@ -349,6 +421,7 @@ const SettingsView = memo(function SettingsView() {
animationsEnabled,
layout,
isLinux,
mangohudEnabled,
selectedRunnerName,
isRunnerDownloading,
runnerDownloadProgress,
@@ -363,6 +436,7 @@ const SettingsView = memo(function SettingsView() {
handleLayoutToggle,
handleRunnerToggle,
handlePerfToggle,
handleMangohudToggle,
handleResetSetup,
stopGame,
downloadRunner,
@@ -435,11 +509,16 @@ const SettingsView = memo(function SettingsView() {
backgroundImage: "url('/images/Button_Background2.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated" as const,
color: focusIndex === index ? "#FFFF55" : "white",
color: focusIndex === index ? "#ffff00" : "white",
});
const isToggleOption = (label: string): boolean => {
return label.includes("ON") || label.includes("OFF") || label.includes("Enabled") || label.includes("Disabled");
return (
label.includes("ON") ||
label.includes("OFF") ||
label.includes("Enabled") ||
label.includes("Disabled")
);
};
const getToggleState = (label: string): boolean => {
@@ -454,10 +533,18 @@ const SettingsView = memo(function SettingsView() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-2xl outline-none"
className="flex flex-col items-center w-full max-w-3xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
{currentSubMenu === "main" ? "Settings" : currentSubMenu === "audio" ? "Audio" : currentSubMenu === "video" ? "Video" : currentSubMenu === "controls" ? "Controls" : "Launcher"}
{currentSubMenu === "main"
? "Settings"
: currentSubMenu === "audio"
? "Audio"
: currentSubMenu === "video"
? "Video"
: currentSubMenu === "controls"
? "Controls"
: "Launcher"}
</h2>
{currentSubMenu === "main" ? (
@@ -472,11 +559,11 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
tabIndex={0}
onMouseEnter={() => setFocusIndex(index)}
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
style={getSliderStyle(index)}
>
<span
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : "text-white"}`}
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : "text-white"}`}
>
{item.label}
</span>
@@ -505,14 +592,15 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
onMouseEnter={() => setFocusIndex(index)}
onClick={item.onClick}
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${isRed
? focusIndex === index
? "text-red-400"
: "text-red-200"
: focusIndex === index
? "text-[#FFFF55]"
: "text-white"
} ${isRed ? "hover:text-red-500" : "hover:text-[#FFFF55]"}`}
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${
isRed
? focusIndex === index
? "text-red-400"
: "text-red-200"
: focusIndex === index
? "text-[#ffff00]"
: "text-white"
} ${isRed ? "hover:text-red-500" : "hover:text-[#ffff00]"}`}
style={getItemStyle(index)}
>
<span
@@ -525,15 +613,7 @@ const SettingsView = memo(function SettingsView() {
})}
</div>
) : (
<div
className="min-w-[420px] w-fit p-6 flex flex-col items-center"
style={{
backgroundImage: "url('/images/background.png')",
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
imageRendering: "pixelated",
}}
>
<div className="min-w-[420px] w-fit p-4 flex flex-col items-center mc-options-bg">
<div className="w-full space-y-3 flex flex-col items-center overflow-y-auto max-h-[50vh] py-2">
{settingsItems.map((item, index) => {
if (item.id === "back") return null;
@@ -545,11 +625,11 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
tabIndex={0}
onMouseEnter={() => setFocusIndex(index)}
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
style={getSliderStyle(index)}
>
<span
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
>
{item.label}
</span>
@@ -560,7 +640,9 @@ const SettingsView = memo(function SettingsView() {
max="100"
step="1"
value={item.value}
onChange={(e) => item.onChange(parseInt(e.target.value))}
onChange={(e) =>
item.onChange(parseInt(e.target.value))
}
onMouseUp={playPressSound}
className="mc-slider-custom w-[calc(100%+16px)] h-full opacity-100 cursor-pointer z-20 outline-none m-0"
/>
@@ -580,21 +662,35 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
onMouseEnter={() => setFocusIndex(index)}
onClick={item.onClick}
className={`w-[360px] h-10 flex items-center justify-between px-4 relative z-30 transition-all outline-none border-none shrink-0 rounded ${focusIndex === index ? "bg-black/10" : "bg-transparent"} ${isRed ? "text-red-600" : "text-[#2a2a2a]"} hover:bg-black/15`}
className={`w-[360px] h-10 flex items-center pl-1.5 pr-4 relative z-30 outline-none border-none shrink-0 rounded ${focusIndex === index ? "text-[#ffff00]" : isRed ? "text-red-600" : "text-[#333333]"}`}
>
{isToggle && (
<div className="relative w-6 h-6 mr-3 shrink-0">
<img
src={
focusIndex === index
? "/images/checkbox_highlighted.png"
: "/images/checkbox.png"
}
alt="checkbox"
className="w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
{toggleState && (
<img
src="/images/check.png"
alt="checked"
className="absolute inset-0 w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
)}
</div>
)}
<span
className={`tracking-widest uppercase ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left flex-1`}
className={`tracking-widest text-xl mc-text-shadow ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left`}
>
{isToggle ? item.label.split(":")[0] : item.label}
</span>
{isToggle && (
<img
src={toggleState ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
alt={toggleState ? "ON" : "OFF"}
className="w-12 h-6 object-contain shrink-0 ml-2"
style={{ imageRendering: "pixelated" }}
/>
)}
</button>
);
})}
@@ -612,11 +708,12 @@ const SettingsView = memo(function SettingsView() {
data-index={backIndex}
onMouseEnter={() => setFocusIndex(backIndex)}
onClick={backItem.onClick}
className={`w-72 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] mt-4 ${focusIndex === backIndex ? "text-[#FFFF55]" : "text-white"}`}
className={`w-40 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#ffff00] mt-4 ${focusIndex === backIndex ? "text-[#ffff00]" : "text-white"}`}
style={{
backgroundImage: focusIndex === backIndex
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundImage:
focusIndex === backIndex
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
+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>
);
}
+409 -251
View File
@@ -2,8 +2,14 @@ import { useState, useEffect, useRef, memo } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
import CustomTUModal from "../modals/CustomTUModal";
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
import SetUidModal from "../modals/SetUidModal";
import {
useUI,
useConfig,
useAudio,
useGame,
} from "../../context/LauncherContext";
import { ScreenshotImage } from "../common/ScreenshotImage";
interface DeleteConfirmButtonProps {
label: string;
onClick: () => void;
@@ -40,25 +46,48 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
const VersionsView = memo(function VersionsView() {
const { setActiveView } = useUI();
const { profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled } = useConfig();
const {
profile: selectedProfile,
setProfile: setSelectedProfile,
animationsEnabled,
} = useConfig();
const { playPressSound, playBackSound } = useAudio();
const { editions, installs: installedVersions, toggleInstall, handleUninstall, handleCancelDownload, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingId, downloadProgress } = useGame();
const {
editions,
installs: installedVersions,
toggleInstall,
handleUninstall,
handleCancelDownload,
deleteCustomEdition: onDeleteEdition,
addCustomEdition: onAddEdition,
updateCustomEdition: onUpdateEdition,
downloadingId,
downloadProgress,
updatesAvailable,
addToSteam,
cycleBranch,
} = useGame();
const { isDayTime } = useConfig();
const [focusIndex, setFocusIndex] = useState<number>(0);
const [focusBtn, setFocusBtn] = useState<number>(0);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isSetUidModalOpen, setIsSetUidModalOpen] = useState(false);
const [setUidTargetId, setSetUidTargetId] = useState("");
const [editingEdition, setEditingEdition] = useState<any>(null);
const [hoveredBtn, setHoveredBtn] = useState<{row: number, btn: string} | null>(null);
const [initialPath, setInitialPath] = useState<string>("");
const [hoveredBtn, setHoveredBtn] = useState<{
row: number;
btn: string;
} | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [deleteConfirmEdition, setDeleteConfirmEdition] = useState<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const ITEM_COUNT = editions.length + 2; // +1 for "+" button, +1 for Done button
const ITEM_COUNT = editions.length + 3;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
setActiveView("main");
@@ -79,7 +108,7 @@ const VersionsView = memo(function VersionsView() {
const edition = editions[focusIndex];
const isInstalled = installedVersions.includes(edition.id);
const isCustom = edition.id.startsWith("custom_");
const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1;
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1));
}
} else if (e.key === "ArrowRight") {
@@ -88,57 +117,37 @@ const VersionsView = memo(function VersionsView() {
const edition = editions[focusIndex];
const isInstalled = installedVersions.includes(edition.id);
const isCustom = edition.id.startsWith("custom_");
const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1;
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
setFocusBtn((prev) => (prev >= maxBtn ? 0 : prev + 1));
}
} else if (e.key === "Enter") {
e.preventDefault();
if (focusIndex < editions.length) {
const edition = editions[focusIndex];
const isInstalled = installedVersions.includes(edition.id);
const isCustom = edition.id.startsWith("custom_");
const isDownloading = downloadingId === edition.id;
const isInstalled = installedVersions.includes(edition.instanceId);
const isDownloading = downloadingId === edition.instanceId;
if (focusBtn === 0) {
if (isInstalled) {
playPressSound();
setSelectedProfile(edition.id);
}
} else if (isInstalled) {
if (focusBtn === 1) {
if (!downloadingId) {
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
} else {
if (!isDownloading && !downloadingId) {
playPressSound();
toggleInstall(edition.id);
}
} else if (focusBtn === 2) {
playPressSound();
TauriService.openInstanceFolder(edition.id);
} else if (focusBtn === 3) {
if (isCustom) {
playBackSound();
onDeleteEdition(edition.id);
} else {
playPressSound();
setDeleteConfirmEdition(edition);
}
} else if (focusBtn === 4 && isCustom) {
playPressSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
}
} else {
if (focusBtn === 1) {
if (isDownloading) {
toggleInstall(edition.instanceId);
} else if (isDownloading) {
handleCancelDownload();
} else if (!downloadingId) {
playPressSound();
toggleInstall(edition.id);
}
}
} else if (focusBtn === 2) {
playPressSound();
cycleBranch(edition.id);
}
} else if (focusIndex === editions.length) {
playPressSound();
setIsImportModalOpen(true);
} else if (focusIndex === editions.length + 1) {
playPressSound();
handleImportFolder();
} else {
playBackSound();
setActiveView("main");
@@ -161,11 +170,15 @@ const VersionsView = memo(function VersionsView() {
setActiveView,
toggleInstall,
handleCancelDownload,
addToSteam,
isDayTime,
]);
useEffect(() => {
if (focusIndex < editions.length && listRef.current) {
const el = listRef.current.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement;
const el = listRef.current.querySelector(
`[data-index="${focusIndex}"]`,
) as HTMLElement;
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
@@ -173,15 +186,26 @@ const VersionsView = memo(function VersionsView() {
}, [focusIndex]);
const handleEditionClick = (edition: any, index: number) => {
const isInstalled = installedVersions.includes(edition.id);
const isInstalled = installedVersions.includes(edition.instanceId);
if (isInstalled) {
playPressSound();
setSelectedProfile(edition.id);
setSelectedProfile(edition.instanceId);
}
setFocusIndex(index);
};
const handleImportFolder = async () => {
try {
const folder = await TauriService.pickFolder();
if (folder) {
setInitialPath(folder);
setIsImportModalOpen(true);
}
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
};
return (
<motion.div
ref={containerRef}
@@ -189,13 +213,13 @@ const VersionsView = memo(function VersionsView() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: animationsEnabled ? 0.25 : 0 }}
className="flex flex-col items-center w-full max-w-2xl outline-none"
className="flex flex-col items-center w-full max-w-5xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase font-bold">
Versions
</h2>
<div
<div
className="w-full min-w-[480px] p-6 mb-4"
style={{
backgroundImage: "url('/images/background.png')",
@@ -210,12 +234,15 @@ const VersionsView = memo(function VersionsView() {
>
<div className="flex flex-col gap-1">
{editions.map((edition: any, i: number) => {
const isInstalled = installedVersions.includes(edition.id);
const isInstalled = installedVersions.includes(
edition.instanceId,
);
const hasAnyInstall = installedVersions.length > 0;
const isSelected = hasAnyInstall && selectedProfile === edition.id;
const isSelected =
hasAnyInstall && selectedProfile === edition.instanceId;
const isFocused = focusIndex === i;
const isCustom = edition.id.startsWith("custom_");
const isDownloading = downloadingId === edition.id;
const isDownloading = downloadingId === edition.instanceId;
const isComingSoon = edition.comingSoon;
return (
@@ -226,7 +253,7 @@ const VersionsView = memo(function VersionsView() {
isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
}`}
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
>
<div className="w-6 flex items-center justify-center flex-shrink-0">
@@ -258,22 +285,34 @@ const VersionsView = memo(function VersionsView() {
)}
</div>
<button
onClick={() => !isComingSoon && handleEditionClick(edition, i)}
disabled={isComingSoon}
className={`flex-1 text-left min-w-0 outline-none rounded ${
focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : ""
<div
onClick={() =>
!isComingSoon && handleEditionClick(edition, i)
}
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${
focusIndex === i && focusBtn === 0 && !isComingSoon
? "ring-2 ring-white"
: ""
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-2">
{edition.logo && (
<img
src={edition.logo}
alt=""
className="w-5 h-5 object-contain flex-shrink-0"
style={{ imageRendering: "pixelated" }}
/>
)}
{edition.logo &&
(edition.logo.startsWith("http") ||
edition.logo.startsWith("/images") ? (
<img
src={edition.logo}
alt=""
className="w-5 h-5 object-contain flex-shrink-0"
style={{ imageRendering: "pixelated" }}
/>
) : (
<ScreenshotImage
path={edition.logo}
alt=""
className="w-5 h-5 object-contain flex-shrink-0"
style={{ imageRendering: "pixelated" }}
/>
))}
<span
className={`text-xl tracking-wide truncate ${
isSelected ? "text-white" : "text-black"
@@ -282,248 +321,353 @@ const VersionsView = memo(function VersionsView() {
>
{edition.name}
</span>
{isCustom && (
{edition.category &&
edition.category.map((cat: string) => (
<span
key={cat}
className="text-[9px] px-1.5 py-0.5 bg-[#444] text-[#aaa] font-bold uppercase border border-[#555] mc-text-shadow"
>
{cat}
</span>
))}
{isCustom && !edition.category && (
<span className="text-[10px] px-1.5 py-0.5 bg-[#777] text-[#222] font-bold uppercase">
Custom
</span>
)}
</div>
<p className={`text-base font-medium leading-tight ${
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
}`}>
<p
className={`text-base font-medium leading-tight ${
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
}`}
>
{edition.desc}
</p>
</button>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-2 flex-shrink-0 relative">
{!isInstalled ? (
isDownloading ? (
<button
onClick={(e) => {
e.stopPropagation();
<button
onClick={(e) => {
e.stopPropagation();
if (!isDownloading && !downloadingId) {
toggleInstall(edition.instanceId);
} else if (isDownloading) {
handleCancelDownload();
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-red-600"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'cancel') || (focusIndex === i && focusBtn === 1)
}
}}
onMouseEnter={() =>
setHoveredBtn({ row: i, btn: "main" })
}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-9 h-9 flex items-center justify-center ${
isDownloading || (!!downloadingId && !isInstalled)
? "opacity-50"
: ""
}`}
style={{
backgroundImage:
(hoveredBtn?.row === i &&
hoveredBtn?.btn === "main") ||
(focusIndex === i && focusBtn === 0)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
) : edition.comingSoon ? (
<div className="w-8 h-8" />
) : (
<button
onClick={(e) => {
e.stopPropagation();
if (!downloadingId) toggleInstall(edition.id);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'download'})}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src={
isDownloading
? "/images/Trash_Bin_Icon.png"
: "/images/Download_Icon.png"
}
alt=""
className="w-5 h-5 object-contain"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'download') || (focusIndex === i && focusBtn === 1)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
opacity: downloadingId ? 0.5 : 1,
filter: isDownloading
? "hue-rotate(300deg)"
: "none",
}}
disabled={!!downloadingId}
>
<img
src="/images/Download_Icon.png"
alt="Download"
className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
)
/>
</button>
) : (
<>
{isDownloading ? (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setOpenMenuId(
openMenuId === edition.id ? null : edition.id,
);
}}
onMouseEnter={() =>
setHoveredBtn({ row: i, btn: "menu" })
}
onMouseLeave={() => setHoveredBtn(null)}
className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative"
style={{
backgroundImage:
(hoveredBtn?.row === i &&
hoveredBtn?.btn === "menu") ||
(focusIndex === i &&
(focusBtn === 0 || focusBtn === 1))
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
filter: updatesAvailable?.[edition.instanceId]
? "drop-shadow(0 0 4px rgba(255,255,0,0.8))"
: "none",
}}
>
<div
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
/>
<div
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
/>
<div
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
/>
</button>
)}
{openMenuId === edition.id && (
<div
className="absolute right-0 top-11 w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75"
style={{
imageRendering: "pixelated",
}}
>
{updatesAvailable?.[edition.instanceId] && (
<button
onClick={(e) => {
e.stopPropagation();
handleCancelDownload();
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-red-600"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'cancel') || (focusIndex === i && focusBtn === 1)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
playPressSound();
toggleInstall(edition.instanceId);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-1.5 text-xs text-[#ffff55] hover:text-white hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
<img
src="/images/Download_Icon.png"
alt=""
className="w-3 h-3 object-contain"
style={{ imageRendering: "pixelated" }}
/>
Update Available!
</button>
) : (
<>
<button
onClick={(e) => {
e.stopPropagation();
if (!downloadingId) toggleInstall(edition.id);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'update'})}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'update') || (focusIndex === i && focusBtn === 1)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
opacity: downloadingId ? 0.5 : 1,
}}
disabled={!!downloadingId}
>
<img
src="/images/Update_Icon.png"
alt="Update"
className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
)}
{Array.isArray(edition.branches) &&
edition.branches.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
TauriService.openInstanceFolder(edition.id);
cycleBranch(edition.id);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'folder'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'folder') || (focusIndex === i && focusBtn === 2)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow hover:bg-white/10 flex items-center justify-between group transition-colors"
>
<span className="text-[#AAAAAA] group-hover:text-white font-bold">
Channel
</span>
<span className="text-[#ffff55] font-bold">
{edition.selectedBranch ?? "Latest"}
</span>
</button>
)}
<div className="h-[1px] bg-white/5 my-0.5 mx-1" />
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
TauriService.openInstanceFolder(edition.instanceId);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<img
src="/images/Folder_Icon.png"
alt="Folder"
className="w-6 h-6 object-contain"
alt=""
className="w-3.5 h-3.5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
Open Folder
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
const PANORAMA_PROFILES = [
"legacy_evolved",
"vanilla_tu19",
"360revived",
"vanilla_tu24",
];
const panoId = PANORAMA_PROFILES.includes(
edition.id,
)
? edition.id
: "vanilla_tu19";
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`;
addToSteam(
edition.instanceId,
edition.name,
edition.titleImage,
panoramaUrl,
);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<img
src="/images/steam.png"
alt=""
className="w-3.5 h-3.5 object-contain invert brightness-0"
style={{ imageRendering: "pixelated" }}
/>
Add to Steam
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setSetUidTargetId(edition.instanceId);
setIsSetUidModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
Set UID
</button>
{isCustom ? (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#aaaaaa] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Edit Custom
</button>
) : null}
<div className="h-[2px] bg-[#555] my-0.5 mx-1" />
<button
onClick={(e) => {
e.stopPropagation();
playBackSound();
setDeleteConfirmEdition(edition);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'delete'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
if (isCustom) {
onDeleteEdition(edition.id);
} else {
setDeleteConfirmEdition(edition);
}
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors mc-text-shadow font-bold"
>
<img
src="/images/Trash_Bin_Icon.png"
alt="Delete"
className="w-6 h-6 object-contain"
alt=""
className="w-3.5 h-3.5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
{isCustom ? "Remove Custom" : "Uninstall"}
</button>
{isCustom && (
<>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'edit'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'edit') || (focusIndex === i && focusBtn === 4)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
playBackSound();
onDeleteEdition(edition.id);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'delete'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-red-600"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</>
)}
</>
</div>
)}
</>
)}
</div>
</div>
);
})}
<div className="w-full flex items-center justify-center p-2 mt-1">
<div className="w-full flex items-center justify-center gap-4 p-2 mt-1">
<button
onClick={() => {
playPressSound();
setInitialPath("");
setIsImportModalOpen(true);
}}
onMouseEnter={() => setHoveredBtn({row: editions.length, btn: 'add'})}
onMouseEnter={() => setFocusIndex(editions.length)}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === editions.length && hoveredBtn?.btn === 'add') || focusIndex === editions.length
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundImage:
(hoveredBtn?.row === editions.length &&
hoveredBtn?.btn === "add") ||
focusIndex === editions.length
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
<path d="M12 5v14M5 12h14"/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="square"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button
onClick={() => {
playPressSound();
handleImportFolder();
}}
onMouseEnter={() => setFocusIndex(editions.length + 1)}
onMouseLeave={() => setHoveredBtn(null)}
title="Import Custom TU"
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage:
(hoveredBtn?.row === editions.length &&
hoveredBtn?.btn === "folder_import") ||
focusIndex === editions.length + 1
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/Folder_Icon.png"
alt="Import Custom TU"
className="w-5 h-5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
</div>
</div>
</div>
@@ -531,17 +675,18 @@ const VersionsView = memo(function VersionsView() {
<div className="flex justify-center">
<button
data-index={editions.length + 1}
onMouseEnter={() => setFocusIndex(editions.length + 1)}
data-index={editions.length + 2}
onMouseEnter={() => setFocusIndex(editions.length + 2)}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white"
style={{
backgroundImage: focusIndex === editions.length + 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundImage:
focusIndex === editions.length + 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
@@ -555,6 +700,7 @@ const VersionsView = memo(function VersionsView() {
onClose={() => {
setIsImportModalOpen(false);
setEditingEdition(null);
setInitialPath("");
}}
onImport={(ed: any) => {
if (editingEdition) {
@@ -567,6 +713,17 @@ const VersionsView = memo(function VersionsView() {
playPressSound={playPressSound}
playBackSound={playBackSound}
editingEdition={editingEdition}
initialPath={initialPath}
/>
<SetUidModal
isOpen={isSetUidModalOpen}
onClose={() => setIsSetUidModalOpen(false)}
playPressSound={playPressSound}
playBackSound={playBackSound}
instances={editions}
installedVersions={installedVersions}
targetInstanceId={setUidTargetId}
/>
{deleteConfirmEdition && (
@@ -584,7 +741,8 @@ const VersionsView = memo(function VersionsView() {
Delete {deleteConfirmEdition.name}?
</h3>
<p className="text-sm text-white mb-6 text-center leading-relaxed">
Warning: All your saves and worlds for this version will be permanently deleted!
Warning: All your saves and worlds for this version will be
permanently deleted!
</p>
<div className="flex justify-center gap-4">
<DeleteConfirmButton
File diff suppressed because it is too large Load Diff
+14 -9
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,
@@ -66,14 +65,18 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
configRaw.legacyMode, configRaw.animationsEnabled
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled
]);
const game = useMemo(() => gameRaw, [
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
gameRaw.downloadingId, gameRaw.editions, gameRaw.isRunnerDownloading,
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
gameRaw.handleUninstall, gameRaw.handleCancelDownload, configRaw.profile
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
gameRaw.deleteCustomEdition, gameRaw.downloadRunner
]);
const audio = useMemo(() => audioRaw, [
@@ -126,13 +129,15 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
musicVol: config.musicVol,
sfxVol: config.sfxVol,
legacyMode: config.legacyMode,
mangohudEnabled: config.mangohudEnabled,
}).catch(console.error);
}
}, [
config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
config.perfBoost, config.customEditions, config.profile,
config.vfxEnabled, config.animationsEnabled,
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, config.isLoaded
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
config.mangohudEnabled, config.isLoaded
]);
useEffect(() => {
@@ -180,8 +185,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
isWindowVisible,
showCredits, setShowCredits, focusSection, setFocusSection,
onNavigateToSkin, onNavigateToMenu, connected,
updateMessage, clearUpdateMessage
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, clearUpdateMessage]);
updateMessage, updateUrl, clearUpdateMessage
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]);
return (
<UIContext.Provider value={uiValue}>
+312 -132
View File
@@ -1,132 +1,312 @@
@import "tailwindcss";
@font-face {
font-family: 'Mojangles';
src: url('/fonts/Mojangles.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
.gamepad-connected-indicator {
position: fixed;
bottom: 10px;
left: 10px;
color: #FFFF55;
font-size: 10px;
z-index: 1000;
opacity: 0.5;
}
.mc-setup-nav-btn {
border: 4px solid transparent;
border-image: url('/images/Button_Background.png') 4 stretch;
image-rendering: pixelated;
background: rgba(0,0,0,0.6);
transition: all 0.2s ease;
}
.mc-setup-nav-btn:hover:not(:disabled) {
transform: scale(1.05);
}
.mc-setup-nav-btn:active:not(:disabled) {
transform: scale(0.95);
}
.mc-setup-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
body {
margin: 0;
background-color: #000;
font-family: 'Mojangles', monospace;
-webkit-font-smoothing: none;
}
.mc-button {
background-image: url('/images/button.png');
background-size: 100% 100%;
image-rendering: pixelated;
transition: none;
}
.mc-button:hover:not(:disabled) {
background-image: url('/images/button_highlighted.png');
}
.mc-text-shadow {
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
}
.mc-text-shadow-hover {
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
}
@keyframes sga-burst {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 1;
}
100% {
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY))) scale(1.2) rotate(var(--rot));
opacity: 0;
}
}
.particle-burst {
animation: sga-burst 0.8s ease-out forwards;
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
}
::-webkit-scrollbar {
width: 14px;
}
::-webkit-scrollbar-track {
background: url('/images/SliderHandlerBackground.png') center;
background-size: 100% 100%;
image-rendering: pixelated;
}
::-webkit-scrollbar-thumb {
background: url('/images/Slider_Handle.png') no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
cursor: pointer;
min-height: 44px;
}
::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.custom-scrollbar::-webkit-scrollbar {
width: 14px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: url('/images/SliderHandlerBackground.png') center;
background-size: 100% 100%;
image-rendering: pixelated;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: url('/images/Slider_Handle.png') no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
cursor: pointer;
min-height: 44px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.no-animations * {
transition: none !important;
animation: none !important;
}
@import "tailwindcss";
@font-face {
font-family: "Mojangles";
src: url("/fonts/Mojangles.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
.gamepad-connected-indicator {
position: fixed;
bottom: 10px;
left: 10px;
color: #ffff55;
font-size: 10px;
z-index: 1000;
opacity: 0.5;
}
.mc-setup-nav-btn {
border: 4px solid transparent;
border-image: url("/images/Button_Background.png") 4 stretch;
image-rendering: pixelated;
background: rgba(0, 0, 0, 0.6);
transition: all 0.2s ease;
}
.mc-setup-nav-btn:hover:not(:disabled) {
transform: scale(1.05);
}
.mc-setup-nav-btn:active:not(:disabled) {
transform: scale(0.95);
}
.mc-setup-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mc-options-bg {
border-image: url("/images/options_background.png") 4 fill;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
}
.mc-lce-tab {
background: linear-gradient(180deg, #c0c0c0 0%, #a0a0a0 100%);
border: 2px solid #808080;
border-bottom: none;
border-radius: 4px 4px 0 0;
box-shadow:
inset 2px 2px 0 #e0e0e0,
inset -2px 0 0 #808080,
0 -2px 0 #606060;
image-rendering: pixelated;
position: relative;
transform: translateY(2px);
transition: all 0.1s ease;
}
.mc-lce-tab.active {
background: linear-gradient(180deg, #d0d0d0 0%, #b0b0b0 100%);
transform: translateY(0);
z-index: 10;
box-shadow:
inset 2px 2px 0 #f0f0f0,
inset -2px 0 0 #909090,
0 -3px 0 #707070;
}
.mc-lce-content {
background: linear-gradient(180deg, #b0b0b0 0%, #909090 100%);
border: 3px solid #808080;
border-radius: 0 4px 4px 4px;
box-shadow:
inset 3px 3px 0 #d0d0d0,
inset -3px -3px 0 #606060;
position: relative;
}
.mc-lce-content::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 8px;
border: 2px solid #ffffff;
pointer-events: none;
opacity: 0.6;
}
.mc-lce-tab-bg {
border: 4px solid transparent;
border-image: url("/images/options_background.png") 4 fill;
image-rendering: pixelated;
position: relative;
transform: translateY(4px);
z-index: 1;
transition: all 0.15s ease;
min-height: 40px;
background-color: rgba(0, 0, 0, 0.3);
}
.mc-lce-tab-bg.active {
transform: translateY(-4px);
z-index: 20;
}
.mc-lce-content-bg {
border-image: url("/images/options_background.png") 4 fill;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
position: relative;
z-index: 10;
margin-top: -8px;
}
* {
cursor:
url("/images/pointer.png") 16 16,
auto;
}
body {
margin: 0;
background-color: #000;
font-family: "Mojangles", monospace;
-webkit-font-smoothing: none;
}
.mc-button {
background-image: url("/images/button.png");
background-size: 100% 100%;
image-rendering: pixelated;
transition: none;
}
.mc-button:hover:not(:disabled) {
background-image: url("/images/button_highlighted.png");
}
.mc-text-shadow {
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
}
.mc-text-shadow-hover {
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
}
@keyframes sga-burst {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 1;
}
100% {
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY)))
scale(1.2) rotate(var(--rot));
opacity: 0;
}
}
.particle-burst {
animation: sga-burst 0.8s ease-out forwards;
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
}
::-webkit-scrollbar {
width: 14px;
}
::-webkit-scrollbar-track {
background: url("/images/SliderHandlerBackground.png") center;
background-size: 100% 100%;
image-rendering: pixelated;
}
::-webkit-scrollbar-thumb {
background: url("/images/Slider_Handle.png") no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
min-height: 44px;
}
::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.custom-scrollbar::-webkit-scrollbar {
width: 14px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: url("/images/SliderHandlerBackground.png") center;
background-size: 100% 100%;
image-rendering: pixelated;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: url("/images/Slider_Handle.png") no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
min-height: 44px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.no-animations * {
transition: none !important;
animation: none !important;
}
.workshop-markdown h1,
.workshop-markdown h2,
.workshop-markdown h3 {
color: #ffff55;
margin-top: 1rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.workshop-markdown strong {
color: #ffff55;
}
.workshop-markdown ul,
.workshop-markdown ol {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.workshop-markdown li {
margin-bottom: 0.25rem;
}
.workshop-markdown a {
color: #55ffff;
text-decoration: underline;
}
.workshop-markdown code {
background: rgba(0, 0, 0, 0.4);
padding: 2px 4px;
border-radius: 2px;
color: #ff55ff;
}
.workshop-markdown table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
margin-bottom: 1rem;
background: rgba(0, 0, 0, 0.3);
border: 2px solid #555;
image-rendering: pixelated;
}
.workshop-markdown th {
background: rgba(0, 0, 0, 0.5);
color: #ffff55;
padding: 8px;
border: 1px solid #444;
text-align: left;
text-transform: uppercase;
font-size: 0.8rem;
}
.workshop-markdown td {
padding: 8px;
border: 1px solid #333;
color: #e0e0e0;
font-size: 0.9rem;
}
.workshop-markdown tr:nth-child(even) {
background: rgba(255, 255, 255, 0.05);
}
.workshop-markdown blockquote {
border-left: 4px solid #555555;
padding-left: 1rem;
margin: 1rem 0;
color: #a0a0a0;
font-style: italic;
background: rgba(255, 255, 255, 0.02);
}
.workshop-markdown del {
color: #aa0000;
text-decoration: line-through;
}
.workshop-markdown input[type="checkbox"] {
accent-color: #ffff55;
margin-right: 0.5rem;
transform: scale(1.2);
vertical-align: middle;
}
.workshop-markdown hr {
border: none;
border-top: 2px solid #373737;
margin: 1.5rem 0;
}
+6 -4
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,11 @@ export function useAppConfig() {
const [profile, setProfile] = useLocalStorage("lce-profile", "legacy_evolved");
const [legacyMode, setLegacyMode] = useLocalStorage("lce-legacy-mode", false);
const [hasCompletedSetup, setHasCompletedSetup] = useLocalStorage("lce-setup-completed", false);
const [isLoaded, setIsLoaded] = useState(false);
const [linuxRunner, setLinuxRunner] = useState<string | undefined>();
const [perfBoost, setPerfBoost] = useState(false);
const [customEditions, setCustomEditions] = useState<any[]>([]);
const [mangohudEnabled, setMangohudEnabled] = useState(false);
useEffect(() => {
TauriService.loadConfig().then((config) => {
if (config.username) setUsername(config.username);
@@ -36,6 +34,7 @@ export function useAppConfig() {
if (config.musicVol !== undefined && config.musicVol !== null) setMusicVol(config.musicVol);
if (config.sfxVol !== undefined && config.sfxVol !== null) setSfxVol(config.sfxVol);
if (config.legacyMode !== undefined) setLegacyMode(config.legacyMode);
if (config.mangohudEnabled !== undefined) setMangohudEnabled(config.mangohudEnabled);
setIsLoaded(true);
});
}, []);
@@ -55,9 +54,10 @@ export function useAppConfig() {
musicVol,
sfxVol,
legacyMode,
mangohudEnabled,
}).catch(console.error);
}
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, isLoaded]);
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, isLoaded]);
return {
username,
@@ -91,5 +91,7 @@ export function useAppConfig() {
isLoaded,
hasCompletedSetup,
setHasCompletedSetup,
mangohudEnabled,
setMangohudEnabled,
};
}
+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,
+9 -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,15 @@ export function useDiscordRPC({
devtools: "Developing for LCE",
skins: "Changing Skins",
workshop: "Browsing Workshop",
lcelive: "Browsing Friends",
"pck-editor": "Editing a PCK file",
"options-editor": "Editing Options Files",
"arc-editor": "Editing an ARC file",
"loc-editor": "Editing Localisation Files",
screenshots: "Browsing Screenshots",
"col-editor": "Editing Color Files",
"grf-editor": "Editing Game Rules",
"swf-editor": "Editing Game UI",
};
details = tabNames[activeView] || "In Menus";
}
+299 -18
View File
@@ -1,23 +1,40 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { TauriService } from "../services/TauriService";
import { getCurrentWindow } from "@tauri-apps/api/window";
async function imageUrlToBase64(url: string): Promise<string> {
const response = await fetch(url);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
const BASE_EDITIONS = [
{
id: "legacy_evolved", //neo: yes. we're not changing the ID. that will break user data.
id: "legacy_evolved",
name: "neoLegacy",
desc: "Backporting newer title updates and Minigames back to LCE",
url: "https://codeberg.org/piebot/LegacyEvolved/releases/download/nightly/LCEWindows64.zip",
url: "https://github.com/pieeebot/neoLegacy/releases/download/latest/neoLegacyWindows64.zip",
titleImage: "/images/minecraft_title_neoLegacy.png",
supportsSlimSkins: true,
logo: "/images/neoLegacy.png"
logo: "/images/neoLegacy.png",
panorama: "legacy_evolved",
},
{
id: "revelations",
name: "Legacy Revelations",
desc: "QoL, performance, hardcore mode, & security features for LCE.",
url: "https://github.com/LCE-Hub/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
url: "https://github.com/itsRevela/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
titleImage: "/images/minecraft_title_revelations.png",
supportsSlimSkins: false,
panorama: "vanilla_tu24",
},
{
id: "360revived",
@@ -26,8 +43,19 @@ const BASE_EDITIONS = [
url: "https://github.com/BluTac10/360Revived/releases/download/nightly/LCEWindows64.zip",
titleImage: "/images/minecraft_title_360revived.png",
supportsSlimSkins: false,
logo: "/images/360_revived.png"
}
logo: "/images/360_revived.png",
panorama: "360revived",
},
{
id: "legacy_nether_fork",
name: "Hellish Ends",
desc: "QoL, Random additions, and Nether/End dimensions overhaul (Modded build !)",
url: "https://github.com/deadvoxelx/HellishEnds/releases/download/nightly/LCEWindows64.zip",
titleImage: "/images/minecraft_title_hellishends.png",
supportsSlimSkins: false,
logo: "/images/netherrack_0.png",
panorama: "vanilla_tu19",
},
];
const PARTNERSHIP_SERVERS = [
@@ -45,6 +73,23 @@ interface GameManagerProps {
setCustomEditions: (editions: any[]) => void;
}
function compareVersions(v1: string, v2: string) {
const parts1 = v1.replace(/^v/, "").split(/[.-]/);
const parts2 = v2.replace(/^v/, "").split(/[.-]/);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || "0";
const p2 = parts2[i] || "0";
const n1 = parseInt(p1);
const n2 = parseInt(p2);
if (!isNaN(n1) && !isNaN(n2)) {
if (n1 !== n2) return n1 - n2;
} else {
if (p1 !== p2) return p1 > p2 ? 1 : -1;
}
}
return 0;
}
export function useGameManager({
profile,
setProfile,
@@ -60,22 +105,216 @@ export function useGameManager({
number | null
>(null);
const [error, setError] = useState<string | null>(null);
const editions = useMemo(
() => [...BASE_EDITIONS, ...customEditions],
[customEditions],
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(
null,
);
const [steamSuccessMessage, setSteamSuccessMessage] = useState<string | null>(
null,
);
const [dynamicUrls, setDynamicUrls] = useState<Record<string, string>>({});
const [branches, setBranches] = useState<Record<string, string[]>>({});
const [selectedBranches, setSelectedBranches] = useState<
Record<string, string>
>({});
const branchesFetched = useRef<Set<string>>(new Set());
const initialBranchesSet = useRef(false);
useEffect(() => {
if (initialBranchesSet.current || !profile) return;
BASE_EDITIONS.forEach((e) => {
if (profile.startsWith(e.id + "_")) {
const branch = profile.replace(e.id + "_", "");
setSelectedBranches((prev) => ({ ...prev, [e.id]: branch }));
} else if (profile === e.id) {
setSelectedBranches((prev) => ({ ...prev, [e.id]: "Stable" }));
}
});
initialBranchesSet.current = true;
}, [profile]);
useEffect(() => {
async function fetchLatestReleases() {
try {
const response = await fetch(
"https://api.github.com/repos/pieeebot/neoLegacy/releases/latest",
);
if (response.ok) {
const data = await response.json();
const asset = data.assets.find(
(a: any) => a.name === "neoLegacyWindows64.zip",
);
if (asset) {
setDynamicUrls((prev) => ({
...prev,
legacy_evolved: asset.browser_download_url,
}));
}
}
} catch (e) {
console.error("Failed to fetch latest releases:", e);
}
}
fetchLatestReleases();
}, []);
const fetchBranchesForEdition = useCallback(
async (editionId: string, url: string) => {
if (branchesFetched.current.has(editionId)) return;
if (!url.includes("github.com")) return;
const parts = url.split("github.com/")[1].split("/");
const owner = parts[0];
const repo = parts[1];
try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/releases`,
);
if (response.ok) {
const data = await response.json();
let tags: string[] = data
.map((r: any) => r.tag_name)
.filter((t: string) => !t.toLowerCase().includes("server"));
const vTags = tags
.filter((t) => t.startsWith("v"))
.sort(compareVersions);
const bestVTag = vTags[vTags.length - 1];
if (!tags.includes("Stable")) tags.unshift("Stable");
if (bestVTag) {
setDynamicUrls((prev) => ({
...prev,
[`${editionId}_Stable`]: bestVTag,
}));
}
setBranches((prev) => ({ ...prev, [editionId]: tags }));
branchesFetched.current.add(editionId);
}
} catch (e) {
console.error(`Failed to fetch branches for ${editionId}:`, e);
}
},
[],
);
useEffect(() => {
BASE_EDITIONS.forEach((e) => fetchBranchesForEdition(e.id, e.url));
}, [fetchBranchesForEdition]);
const cycleBranch = useCallback(
(editionId: string) => {
const available = branches[editionId] || ["Stable"];
if (available.length <= 1) return;
setSelectedBranches((prev) => {
const current = prev[editionId] || available[0];
let currentIndex = available.indexOf(current);
let nextIndex = currentIndex;
let nextBranch = current;
do {
nextIndex = (nextIndex + 1) % available.length;
nextBranch = available[nextIndex];
} while (nextBranch.startsWith("v") && nextIndex !== currentIndex);
const oldInstanceId =
current === "Stable" ? editionId : `${editionId}_${current}`;
const newInstanceId =
nextBranch === "Stable" ? editionId : `${editionId}_${nextBranch}`;
if (profile === oldInstanceId) {
setProfile(newInstanceId);
}
return { ...prev, [editionId]: nextBranch };
});
},
[branches, profile, setProfile],
);
const editions = useMemo(() => {
return [
...BASE_EDITIONS.map((e) => {
const availableBranches = branches[e.id] || ["Stable"];
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
let url = dynamicUrls[e.id] || e.url;
const defaultBranchFromUrl = e.url.includes("/releases/download/")
? e.url.split("/releases/download/")[1].split("/")[0]
: "nightly";
const branchToUse =
selectedBranch === "Stable"
? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl
: selectedBranch;
if (e.url.includes("github.com")) {
const baseUrl = e.url.split("/releases/download/")[0];
const filename = e.url.split("/").pop();
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
}
return {
...e,
url,
branches: availableBranches,
selectedBranch,
instanceId:
selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`,
};
}),
...customEditions.map((e) => ({ ...e, instanceId: e.id })),
];
}, [customEditions, dynamicUrls, branches, selectedBranches]);
const checkInstalls = useCallback(async () => {
const results = await Promise.all(
editions.map(async (e) => {
const isInstalled = await TauriService.checkGameInstalled(e.id);
return isInstalled ? e.id : null;
const isInstalled = await TauriService.checkGameInstalled(e.instanceId);
return isInstalled ? e.instanceId : null;
}),
);
setInstalls(results.filter((id): id is string => id !== null));
}, [editions]);
const [updatesAvailable, setUpdatesAvailable] = useState<
Record<string, boolean>
>({});
const checkForGameUpdates = useCallback(async () => {
const checks = await Promise.all(
editions.map(async (edition) => {
if (!installs.includes(edition.instanceId))
return [edition.instanceId, false] as const;
try {
const isUpdate = await TauriService.checkGameUpdate(
edition.instanceId,
edition.url,
);
return [edition.instanceId, isUpdate] as const;
} catch (e) {
console.error(e);
return [edition.instanceId, false] as const;
}
}),
);
const newUpdates: Record<string, boolean> = {};
for (const [id, hasUpdate] of checks) {
newUpdates[id as string] = hasUpdate as boolean;
}
setUpdatesAvailable(newUpdates);
const updatedGames = editions.filter((e) => newUpdates[e.id]);
if (updatedGames.length > 0) {
if (updatedGames.length === 1) {
setGameUpdateMessage(
`An update is available for ${updatedGames[0].name}!`,
);
} else {
setGameUpdateMessage(
`Updates are available for ${updatedGames.length} versions!`,
);
}
} else {
setGameUpdateMessage(null);
}
}, [editions, installs]);
useEffect(() => {
checkForGameUpdates();
}, [profile, installs, checkForGameUpdates]);
useEffect(() => {
checkInstalls();
const unlistenDownload = TauriService.onDownloadProgress((p) =>
@@ -114,7 +353,7 @@ export function useGameManager({
const toggleInstall = useCallback(
async (id: string) => {
if (downloadingId) return;
const edition = editions.find((e) => e.id === id);
const edition = editions.find((e) => e.instanceId === id);
if (!edition) return;
setError(null);
try {
@@ -186,12 +425,20 @@ export function useGameManager({
}, [profile]);
const addCustomEdition = useCallback(
(edition: { name: string; desc: string; url: string }) => {
const id = `custom_${Date.now()}`;
(edition: {
name: string;
desc: string;
url: string;
path?: string;
category?: string[];
logo?: string;
id?: string;
}) => {
const id = edition.id || `custom_${Date.now()}`;
const newEdition = {
...edition,
id,
titleImage: "/images/minecraft_title_tucustom.png",
titleImage: "/images/MenuTitle.png",
};
setCustomEditions([...customEditions, newEdition]);
return id;
@@ -208,7 +455,10 @@ export function useGameManager({
);
const updateCustomEdition = useCallback(
(id: string, updated: { name: string; desc: string; url: string }) => {
(
id: string,
updated: { name: string; desc: string; url: string; path?: string },
) => {
setCustomEditions(
customEditions.map((e) => (e.id === id ? { ...e, ...updated } : e)),
);
@@ -216,6 +466,30 @@ export function useGameManager({
[customEditions, setCustomEditions],
);
const addToSteam = useCallback(
async (
id: string,
name: string,
titleImage: string,
panoramaImage: string,
) => {
try {
const titleBase64 = await imageUrlToBase64(titleImage);
const panoramaBase64 = await imageUrlToBase64(panoramaImage);
await TauriService.addToSteam(id, name, titleBase64, panoramaBase64);
setSteamSuccessMessage(
`Added ${name} to Steam! (Restart Steam to see it)`,
);
} catch (e: any) {
console.error(e);
setError(
typeof e === "string" ? e : e.message || "Failed to add to Steam",
);
}
},
[setError, setSteamSuccessMessage],
);
return {
installs,
isGameRunning,
@@ -236,5 +510,12 @@ export function useGameManager({
updateCustomEdition,
downloadRunner,
checkInstalls,
gameUpdateMessage,
setGameUpdateMessage,
steamSuccessMessage,
setSteamSuccessMessage,
updatesAvailable,
addToSteam,
cycleBranch,
};
}
+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 () => {
+66
View File
@@ -0,0 +1,66 @@
import { useState, useEffect, useRef } from "react";
import { lceLiveService } from "../services/LceLiveService";
export function useLceLiveNotifications() {
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
const [gameInviteMessage, setGameInviteMessage] = useState<string | null>(null);
const seenRequests = useRef<Set<string>>(new Set());
const seenInvites = useRef<Set<string>>(new Set());
useEffect(() => {
let pollInterval: any;
const init = async () => {
if (lceLiveService.signedIn) {
try {
await lceLiveService.refreshSession();
} catch (e) { }
}
if (lceLiveService.signedIn) {
try {
const [reqs, invs] = await Promise.all([
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites()
]);
reqs.incoming.forEach((r: any) => seenRequests.current.add(r.accountId));
invs.filter((i: any) => i.status === "pending").forEach((i: any) => seenInvites.current.add(i.inviteId));
} catch (e) { }
}
pollInterval = setInterval(async () => {
if (!lceLiveService.signedIn) return;
try {
const [reqs, invs] = await Promise.all([
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites()
]);
reqs.incoming.forEach((r: any) => {
if (!seenRequests.current.has(r.accountId)) {
seenRequests.current.add(r.accountId);
setFriendRequestMessage(`New request from ${r.displayName}`);
}
});
invs.filter((i: any) => i.status === "pending").forEach((i: any) => {
if (!seenInvites.current.has(i.inviteId)) {
seenInvites.current.add(i.inviteId);
const fromName = typeof i.from === 'string' ? "Unknown" : i.from.displayName;
setGameInviteMessage(`${fromName} invited you to play!`);
}
});
} catch (e) { }
}, 10000);
};
init();
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, []);
return {
friendRequestMessage,
gameInviteMessage,
clearFriendRequestMessage: () => setFriendRequestMessage(null),
clearGameInviteMessage: () => setGameInviteMessage(null),
};
}
-1
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,
};
}
+33 -28
View File
@@ -1,37 +1,41 @@
import { useState, useEffect, useCallback } from "react";
import pkg from "../../package.json";
const CURRENT_VERSION = pkg.version;
const REPO_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/latest";
function isNewerVersion(latest: string, current: string): boolean {
const latestParts = latest.split('.').map(Number);
const currentParts = current.split('.').map(Number);
for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
const latestPart = latestParts[i] || 0;
const currentPart = currentParts[i] || 0;
if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}
return false;
}
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export function useUpdateCheck() {
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
const [updateUrl, _setUpdateUrl] = useState<string | null>(null);
const checkUpdates = useCallback(async () => {
try {
const response = await fetch(REPO_URL);
if (!response.ok) return;
const update = await check();
if (update) {
setUpdateMessage(`Downloading update: ${update.version}...`);
let downloaded = 0;
let contentLength = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength || 0;
setUpdateMessage(`Starting download of ${update.version}...`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
if (contentLength > 0) {
const percent = Math.round((downloaded / contentLength) * 100);
setUpdateMessage(`Downloading update: ${percent}%`);
}
break;
case 'Finished':
setUpdateMessage(`Installing update...`);
break;
}
});
const data = await response.json();
const latestVersion = data.tag_name.replace(/^v/, '');
if (isNewerVersion(latestVersion, CURRENT_VERSION)) {
setUpdateMessage(`Version ${data.tag_name} is now available!`);
setUpdateMessage("Update installed. Restarting...");
try {
await relaunch();
} catch (e) {
console.warn("Could not relaunch immediately:", e);
}
}
} catch (e) {
console.error("Failed to check for updates:", e);
@@ -44,6 +48,7 @@ export function useUpdateCheck() {
return {
updateMessage,
updateUrl,
clearUpdateMessage: () => setUpdateMessage(null),
};
}
+121 -12
View File
@@ -9,6 +9,13 @@ import WorkshopView from "../components/views/WorkshopView";
import SetupView from "../components/views/SetupView";
import PckEditorView from "../components/views/PckEditorView";
import { ArcEditorView } from "../components/views/ArcEditorView";
import LocEditorView from "../components/views/LocEditorView";
import GrfEditorView from "../components/views/GrfEditorView";
import ColEditorView from "../components/views/ColEditorView";
import OptionsEditorView from "../components/views/OptionsEditorView";
import ScreenshotsView from "../components/views/ScreenshotsView";
import SwfView from "../components/views/SwfView";
import LceLiveView from "../components/views/LceLiveView";
import SkinViewer from "../components/common/SkinViewer";
import TeamModal from "../components/modals/TeamModal";
import PanoramaBackground from "../components/common/PanoramaBackground";
@@ -23,8 +30,8 @@ import {
useSkin,
} from "../context/LauncherContext";
import { TauriService } from "../services/TauriService";
import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications";
import pkg from "../../package.json";
export default function App() {
const {
showIntro,
@@ -40,29 +47,32 @@ export default function App() {
focusSection,
onNavigateToMenu,
updateMessage,
updateUrl,
clearUpdateMessage,
} = useUI();
const config = useConfig();
const audio = useAudio();
const game = useGame();
const { skinUrl, setSkinUrl } = useSkin();
const { skinUrl, setSkinUrl, capeUrl } = useSkin();
const {
friendRequestMessage,
gameInviteMessage,
clearFriendRequestMessage,
clearGameInviteMessage,
} = useLceLiveNotifications();
const [showSetup, setShowSetup] = useState(true);
const [displayIsDay, setDisplayIsDay] = useState(config.isDayTime);
useEffect(() => {
setDisplayIsDay(config.isDayTime);
}, [config.isDayTime]);
const selectedEdition = game.editions.find(
(e: any) => e.id === config.profile,
(e: any) => e.instanceId === config.profile,
);
const selectedVersionName = selectedEdition?.name || "";
const hasAnyInstall = game.installs.length > 0;
const titleImage = hasAnyInstall
? (selectedEdition?.titleImage || "/images/MenuTitle.png")
? selectedEdition?.titleImage || "/images/MenuTitle.png"
: "/images/MenuTitle.png";
useEffect(() => {
@@ -122,7 +132,7 @@ export default function App() {
{...backgroundFade}
>
<PanoramaBackground
profile={config.profile}
profile={selectedEdition.panorama}
isDay={displayIsDay}
/>
</motion.div>
@@ -158,12 +168,33 @@ export default function App() {
message={updateMessage}
onClose={clearUpdateMessage}
onClick={() =>
TauriService.openUrl("https://lce-hub.github.io/")
TauriService.openUrl(
updateUrl ||
"https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest",
)
}
title="Update Available!"
variant="update"
/>
<AchievementToast
message={game.gameUpdateMessage}
onClose={() => game.setGameUpdateMessage(null)}
onClick={() => {
game.setGameUpdateMessage(null);
setActiveView("versions");
}}
title="Game Update Available!"
variant="update"
/>
<AchievementToast
message={game.steamSuccessMessage}
onClose={() => game.setSteamSuccessMessage(null)}
title="Steam Integration"
variant="steam"
/>
<AnimatePresence>
{showSetup ? (
<SetupView
@@ -254,6 +285,40 @@ export default function App() {
</button>
</motion.div>
)}
{isUiHidden &&
!displayIsDay &&
activeView == "devtools" && (
<motion.div
key="secret-swf-btn"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none"
>
<button
onClick={() => {
audio.playPressSound();
setIsUiHidden(false);
setActiveView("swf-editor");
}}
className="pointer-events-auto hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none flex flex-col items-center gap-2 group"
>
<img
src="/images/tools/pck.png"
className="w-16 h-16 cursor-pointer object-contain opacity-50 group-hover:opacity-100 drop-shadow-[0_4px_4px_rgba(0,0,0,1)] grayscale group-hover:grayscale-0"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
(e.target as HTMLImageElement).src =
"/images/Button_Background.png";
}}
/>
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
SWF Editor
</span>
</button>
</motion.div>
)}
</>
)}
</AnimatePresence>
@@ -318,6 +383,7 @@ export default function App() {
setUsername={config.setUsername}
playPressSound={audio.playPressSound}
skinUrl={skinUrl}
capeUrl={config.legacyMode ? null : capeUrl}
setSkinUrl={setSkinUrl}
setActiveView={setActiveView}
isFocusedSection={focusSection === "skin"}
@@ -326,7 +392,7 @@ export default function App() {
)}
</AnimatePresence>
<div className="w-full max-w-4xl relative flex justify-center items-center">
<div className="w-full h-full max-w-4xl relative flex justify-center items-center overflow-hidden">
<AnimatePresence mode="wait">
{activeView === "main" && <HomeView key="main-view" />}
{activeView === "settings" && (
@@ -347,7 +413,28 @@ export default function App() {
{activeView === "arc-editor" && (
<ArcEditorView key="arc-editor-view" />
)}
{activeView === "loc-editor" && (
<LocEditorView key="loc-editor-view" />
)}
{activeView === "grf-editor" && (
<GrfEditorView key="grf-editor-view" />
)}
{activeView === "col-editor" && (
<ColEditorView key="col-editor-view" />
)}
{activeView === "options-editor" && (
<OptionsEditorView key="options-editor-view" />
)}
{activeView === "swf-editor" && (
<SwfView key="swf-editor-view" />
)}
{activeView === "lcelive" && (
<LceLiveView key="lcelive-view" />
)}
{activeView === "skins" && <SkinsView key="skins-view" />}
{activeView === "screenshots" && (
<ScreenshotsView key="screenshots-view" />
)}
</AnimatePresence>
</div>
</div>
@@ -362,7 +449,7 @@ export default function App() {
style={{ fontWeight: "normal" }}
>
<div className="flex-1 text-left whitespace-nowrap">
Version: {pkg.version}
Version: {pkg.version} ({__BUILD_DATE__})
</div>
<div className="flex-[2] text-center whitespace-nowrap">
Not affiliated with Mojang AB or Microsoft. "Minecraft" is
@@ -377,6 +464,28 @@ export default function App() {
</motion.div>
)}
</AnimatePresence>
<AchievementToast
message={friendRequestMessage}
onClose={clearFriendRequestMessage}
onClick={() => {
clearFriendRequestMessage();
setActiveView("lcelive");
}}
title="Friend Request"
variant="update"
/>
<AchievementToast
message={gameInviteMessage}
onClose={clearGameInviteMessage}
onClick={() => {
clearGameInviteMessage();
setActiveView("lcelive");
}}
title="Game Invite"
variant="update"
/>
</div>
</MotionConfig>
);
+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: []
}
};
}
}
+377
View File
@@ -0,0 +1,377 @@
const LOCAL_STORAGE_KEY = "lcelive_session";
const DEFAULT_BASE_URL = "https://api.lcelive.co.uk";
const MAX_REFRESH_RETRIES = 1;
import { TauriService } from "./TauriService";
export interface LceLiveAccount {
accountId: string;
username: string;
displayName: string;
}
export interface SessionData {
accessToken: string;
refreshToken: string;
account: LceLiveAccount;
}
export interface DeviceLinkStartResponse {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
intervalSeconds: number;
expiresInSeconds?: number;
}
export interface DeviceLinkPollResponse {
status: string;
isLinked?: boolean;
accessToken?: string;
refreshToken?: string;
account?: LceLiveAccount;
}
export interface GameInvite {
inviteId: string;
from: LceLiveAccount | string;
hostIp: string;
hostPort: number;
hostName: string;
signalingSessionId?: string;
status: string;
}
export interface FriendRequest {
accountId: string;
username: string;
displayName: string;
}
export interface PendingRequests {
incoming: FriendRequest[];
outgoing: FriendRequest[];
}
export class LceLiveService {
private _session: SessionData | null = null;
private baseUrl: string = DEFAULT_BASE_URL;
private _refreshPromise: Promise<void> | null = null;
constructor() {
this.loadSession();
}
setBaseUrl(url: string) {
this.baseUrl = url.replace(/\/$/, "");
}
get signedIn(): boolean {
return this._session !== null;
}
get account(): LceLiveAccount | null {
return this._session?.account || null;
}
get displayUsername(): string {
if (!this._session) return "Not signed in";
return (
this._session.account.displayName ||
this._session.account.username ||
"Unknown"
);
}
get apiBaseUrl(): string {
return this.baseUrl;
}
get accessToken(): string | null {
return this._session?.accessToken || null;
}
public generateDeviceId(): string {
let id = localStorage.getItem("lcelive_device_id");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("lcelive_device_id", id);
}
return id;
}
public getDeviceName(): string {
return "LCE Emerald Launcher";
}
private loadSession() {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
if (data) {
this._session = JSON.parse(data);
}
} catch (e) {
console.warn("Failed to load LceLive session", e);
}
}
private saveSession() {
if (this._session) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this._session));
} else {
localStorage.removeItem(LOCAL_STORAGE_KEY);
}
}
private async request(
method: string,
path: string,
body?: any,
authed: boolean = true,
retryCount: number = 0,
): Promise<any> {
if (authed && this._session?.refreshToken && retryCount === 0) {
try {
await this.refreshSession(); //neo: i do this on every request only because it doesnt always return 401
} catch (err) {}
}
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": "MCLCE-LceLive/1.0",
};
if (body) {
headers["Content-Type"] = "application/json";
}
if (authed && this._session?.accessToken) {
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
}
const bodyStr = body ? JSON.stringify(body) : null;
let res;
try {
res = await TauriService.httpProxyRequest(
method,
`${this.baseUrl}${path}`,
bodyStr,
headers,
);
} catch (e) {
throw new Error(`Network error when calling ${path}: ${e}`);
}
if (
res.status === 401 &&
authed &&
this._session?.refreshToken &&
retryCount < MAX_REFRESH_RETRIES
) {
try {
await this.refreshSession();
return this.request(method, path, body, authed, retryCount + 1);
} catch (err) {
this.logoutLocal();
throw new Error("Session expired. Please log in again.");
}
}
let data;
try {
data = res.body ? JSON.parse(res.body) : {};
} catch {
data = { message: res.body };
}
if (res.status >= 400) {
const errorMsg =
data.message ||
data.detail ||
data.title ||
data.error ||
`HTTP ${res.status}`;
throw new Error(errorMsg);
}
return data;
}
async startDeviceLink(): Promise<DeviceLinkStartResponse> {
return this.request(
"POST",
"/api/auth/device/start",
{
deviceId: this.generateDeviceId(),
deviceName: this.getDeviceName(),
},
false,
);
}
async pollDeviceLink(deviceCode: string): Promise<DeviceLinkPollResponse> {
const data = await this.request(
"GET",
`/api/auth/device/poll/${deviceCode}`,
null,
false,
);
if (data.isLinked && data.accessToken) {
this._session = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
account: data.account,
};
this.saveSession();
}
return data;
}
async refreshSession(): Promise<void> {
if (!this._session?.refreshToken) throw new Error("No refresh token");
if (this._refreshPromise) {
return this._refreshPromise;
}
this._refreshPromise = (async () => {
try {
const data = await this.request(
"POST",
"/api/auth/refresh",
{
refreshToken: this._session!.refreshToken,
},
false,
);
this._session = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
account: data.account,
};
this.saveSession();
} finally {
this._refreshPromise = null;
}
})();
return this._refreshPromise;
}
async logout(): Promise<void> {
if (this._session?.refreshToken) {
try {
await this.request(
"POST",
"/api/auth/logout",
{
refreshToken: this._session.refreshToken,
},
true,
);
} catch (e) {
console.warn("Server logout failed", e);
}
}
this.logoutLocal();
}
logoutLocal(): void {
this._session = null;
this.saveSession();
}
async getFriends(): Promise<LceLiveAccount[]> {
const data = await this.request("GET", "/api/social/friends");
return data.friends || [];
}
async removeFriend(accountId: string): Promise<void> {
await this.request("DELETE", `/api/social/friends/${accountId}`);
}
async sendFriendRequest(username: string): Promise<void> {
await this.request("POST", "/api/social/request", { username });
}
async getPendingRequests(): Promise<PendingRequests> {
const data = await this.request("GET", "/api/social/requests");
return {
incoming: (data.incoming || []).map((r: any) => ({
accountId: r.requesterUserId || r.accountId || r.userId,
username: r.requesterUsername || r.username,
displayName: r.requesterDisplayName || r.displayName,
})),
outgoing: (data.outgoing || []).map((r: any) => ({
accountId: r.targetUserId || r.accountId || r.userId,
username: r.targetUsername || r.username,
displayName: r.targetDisplayName || r.displayName,
})),
};
}
async acceptFriendRequest(accountId: string): Promise<void> {
await this.request("POST", `/api/social/requests/${accountId}/accept`, {});
}
async declineFriendRequest(accountId: string): Promise<void> {
await this.request("POST", `/api/social/requests/${accountId}/decline`, {});
}
async getGameInvites(): Promise<GameInvite[]> {
const data = await this.request("GET", "/api/sessions/invites");
const incoming = data.incoming || [];
return incoming.map((inv: any) => ({
inviteId: inv.inviteId,
from: {
accountId: inv.senderAccountId,
username: inv.senderUsername,
displayName: inv.senderDisplayName,
},
hostIp: inv.hostIp,
hostPort: inv.hostPort,
hostName: inv.hostName,
status: inv.status,
signalingSessionId: inv.signalingSessionId,
}));
}
async sendGameInvite(
recipientAccountId: string,
hostIp: string,
hostPort: number,
hostName: string,
signalingSessionId?: string,
): Promise<void> {
await this.request("POST", "/api/sessions/invites", {
recipientAccountId,
hostIp,
hostPort,
hostName,
signalingSessionId,
});
}
async acceptGameInvite(inviteId: string): Promise<any> {
return this.request("POST", `/api/sessions/invites/${inviteId}/accept`, {});
}
async declineGameInvite(inviteId: string): Promise<void> {
await this.request("POST", `/api/sessions/invites/${inviteId}/decline`, {});
}
async deactivateGameInvites(): Promise<void> {
await this.request("POST", "/api/sessions/invites/deactivate", {});
}
async requestJoinTicket(): Promise<string> {
const data = await this.request("POST", "/api/sessions/ticket", {});
return data.ticket;
}
async validateJoinTicket(ticket: string): Promise<LceLiveAccount> {
return this.request("POST", "/api/sessions/validate", { ticket }, false);
}
}
export const lceLiveService = new LceLiveService();
+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;
}
}
+16 -11
View File
@@ -1,14 +1,18 @@
import { setActivity, start } from "tauri-plugin-drpc";
import { Activity, ActivityType, Assets, Timestamps, Button } from "tauri-plugin-drpc/activity";
import {
Activity,
ActivityType,
Assets,
Timestamps,
Button,
} from "tauri-plugin-drpc/activity";
class RPC {
private startTime: number = Date.now();
private initializationPromise: Promise<void> | null = null;
private initialized: boolean = false;
public async StartRPC() {
if (this.initialized) return;
if (sessionStorage.getItem('lce_rpc_started') === 'true') {
if (sessionStorage.getItem("lce_rpc_started") === "true") {
this.initialized = true;
return;
}
@@ -17,7 +21,7 @@ class RPC {
this.initializationPromise = (async () => {
try {
await start("1482504445152460871");
sessionStorage.setItem('lce_rpc_started', 'true');
sessionStorage.setItem("lce_rpc_started", "true");
this.initialized = true;
} catch (e) {
console.error("Failed to start RPC:", e);
@@ -28,7 +32,11 @@ class RPC {
return this.initializationPromise;
}
public async updateActivity(details: string, state: string, isPlaying: boolean = false) {
public async updateActivity(
details: string,
state: string,
isPlaying: boolean = false,
) {
if (!this.initialized) {
await this.StartRPC();
if (!this.initialized) return;
@@ -38,19 +46,16 @@ class RPC {
activity.setDetails(details);
activity.setState(state);
activity.setActivity(ActivityType.Playing);
const assets = new Assets();
assets.setLargeImage("logo");
assets.setLargeText("LCE Emerald Launcher");
assets.setSmallImage("app-icon");
assets.setSmallText(isPlaying ? "Playing" : "In Menus");
activity.setAssets(assets);
activity.setTimestamps(new Timestamps(this.startTime));
activity.setButton([
new Button("Discord", "https://discord.gg/vD8akRU24f"),
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher")
new Button("Discord", "https://discord.gg/cQVKhQXcCx"),
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher"),
]);
try {
+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;
});
}
}
+117 -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 {
@@ -36,6 +39,7 @@ export interface AppConfig {
musicVol?: number;
sfxVol?: number;
legacyMode?: boolean;
mangohudEnabled?: boolean;
}
export interface ThemePalette {
@@ -57,6 +61,12 @@ export interface MacOSSetupProgress {
percent?: number;
}
export interface InstalledWorkshopPackage {
instanceId: string;
packageId: string;
version: string;
}
export class TauriService {
static async saveConfig(config: AppConfig): Promise<void> {
return invoke("save_config", { config });
@@ -124,8 +134,23 @@ export class TauriService {
return invoke("sync_dlc", { instanceId });
}
static async workshopInstall(instanceId: string, packageId: string, zips: Record<string, string>): Promise<void> {
return invoke("workshop_install", { request: { instanceId, packageId, zips } });
static async workshopInstall(
instanceId: string,
packageId: string,
zips: Record<string, string>,
version: string,
): Promise<void> {
return invoke("workshop_install", {
request: { instanceId, packageId, zips, version },
});
}
static async workshopUninstall(instanceId: string, packageId: string): Promise<void> {
return invoke("workshop_uninstall", { instanceId, packageId });
}
static async workshopListInstalled(): Promise<InstalledWorkshopPackage[]> {
return invoke("workshop_list_installed");
}
static onDownloadProgress(callback: (percent: number) => void) {
@@ -169,4 +194,94 @@ export class TauriService {
static async fetchSkin(username: string): Promise<[string, string]> {
return invoke("fetch_skin", { username });
}
static async saveGlobalSkinPck(pckData: Uint8Array): Promise<void> {
return invoke("save_global_skin_pck", { pckData: Array.from(pckData) });
}
static async checkGameUpdate(instanceId: string, url: string): Promise<boolean> {
return invoke("check_game_update", { instanceId, url });
}
static async pickFolder(): Promise<string> {
return invoke("pick_folder");
}
static async pickFile(title: string, filters: string[]): Promise<string> {
return invoke("pick_file", { title, filters });
}
static async saveFileDialog(title: string, filename: string, filters: string[]): Promise<string> {
return invoke("save_file_dialog", { title, filename, filters });
}
static async writeBinaryFile(path: string, data: Uint8Array): Promise<void> {
return invoke("write_binary_file", { path, data: Array.from(data) });
}
static async readBinaryFile(path: string): Promise<Uint8Array> {
const data: number[] = await invoke("read_binary_file", { path });
return new Uint8Array(data);
}
static async downloadLogo(id: string, url: string): Promise<string> {
return invoke("download_logo", { id, url });
}
static async addToSteam(
instanceId: string,
name: string,
titleBase64: string,
panoramaBase64: string
): Promise<void> {
return invoke("add_to_steam", { instanceId, name, titleBase64, panoramaBase64 });
}
static async stunDiscover(): Promise<{ ip: string; port: number }> {
return invoke("stun_discover");
}
static async startDirectProxy(targetIp: string, targetPort: number): Promise<number> {
return invoke("start_direct_proxy", { targetIp, targetPort });
}
static async startRelayProxy(apiBaseUrl: string, accessToken: string, sessionId: string): Promise<number> {
return invoke("start_relay_proxy", { apiBaseUrl, accessToken, sessionId });
}
static async startHostRelay(apiBaseUrl: string, accessToken: string, sessionId: string, gamePort: number): Promise<void> {
return invoke("start_host_relay", { apiBaseUrl, accessToken, sessionId, gamePort });
}
static async stopProxy(): Promise<void> {
return invoke("stop_proxy");
}
static async joinGame(
apiBaseUrl: string,
accessToken: string,
hostIp: string,
hostPort: number,
sessionId: string,
instanceId: string,
): Promise<void> {
return invoke("join_game", { apiBaseUrl, accessToken, hostIp, hostPort, sessionId, instanceId });
}
static async httpProxyRequest(
method: string,
url: string,
body: string | null,
headers: Record<string, string>
): Promise<{ status: number; body: string }> {
return invoke("http_proxy_request", { method, url, body, headers });
}
static async getInstancePath(instanceId: string): Promise<string> {
return invoke("get_instance_path", { instanceId });
}
static async readScreenshotAsDataUrl(path: string): Promise<string> {
return invoke("read_screenshot_as_data_url", { path });
}
}
-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,