feat: GNU/Linux support and more (#10)
Co-authored-by: Santiago Fisela <santyfisela@gmail.com>
60
.github/workflows/publish.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: 'publish'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: pnpm install
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tagName: app-v__VERSION__
|
||||
releaseName: 'Emerald v__VERSION__'
|
||||
releaseBody: '*pls edit me*'
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
6
.gitignore
vendored
|
|
@ -24,11 +24,5 @@
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build output
|
||||
/app-icon.png
|
||||
/src-tauri/icons/*.png
|
||||
/src-tauri/icons/*.ico
|
||||
/src-tauri/icons/*.icns
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
|
|
|||
24
BUILD.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Building Emerald Launcher
|
||||
## Requirements
|
||||
|
||||
- Node.js (and NPM)
|
||||
- Rust
|
||||
- WebKit2GTK-4.1 (GNU/Linux only)
|
||||
- WebKit (macOS only, preinstalled)
|
||||
- Microsoft Edge WebView2 Runtime (Windows only)
|
||||
- PNPM (optional, but recommended)
|
||||
- Windows, GNU/Linux or a macOS system.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
pnpm install # or npm
|
||||
pnpm tauri build # or npm
|
||||
```
|
||||
|
||||
## Notes
|
||||
On GNU/Linux, the build might fail due to missing logos (because still Alpha, contributions to fix this are welcome).
|
||||
To fix that, build with this command instead:
|
||||
```sh
|
||||
pnpm tauri build --no-bundle
|
||||
```
|
||||
23
README.md
|
|
@ -1,10 +1,10 @@
|
|||
# Emerald Legacy Launcher
|
||||
<div align="center">
|
||||
<img height="150" src="https://raw.githubusercontent.com/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher/refs/heads/main/public/images/logo.png">
|
||||
<h1>Emerald Legacy Launcher</h1>
|
||||
<p>FOSS cross-platform launcher for Minecraft Legacy Console Edition</p>
|
||||
</div>
|
||||
|
||||
**A free and open-source Minecraft Legacy Console Edition launcher, built with Rust and Tauri.**
|
||||
|
||||
---
|
||||
|
||||
<p align="left">
|
||||
<p align="center">
|
||||
<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>
|
||||
|
|
@ -21,10 +21,13 @@
|
|||
|
||||
---
|
||||
|
||||
<img width="1355" height="922" alt="image" src="https://github.com/user-attachments/assets/6798dd00-4c7c-4b57-9116-7648ce90ae93" />
|
||||
|
||||
|
||||
### PROJECT OVERVIEW
|
||||
Emerald Legacy Launcher started as a solo project to create a better way to play Legacy Console Edition. Over time, other developers joined the effort, bringing expertise from various preservation projects into one place.
|
||||
|
||||
**Our Philosophy:** For too long, the LCE scene has been fragmented across different, often resource-heavy launchers. Emerald was built to stop this fragmentation and centralize everything into a single, definitive hub. By avoiding bloated frameworks and utilizing a modern Rust/Tauri stack, we deliver a high-performance, cross-platform experience that uses only ~15MB of RAM—leaving all your PC's resources dedicated to the game itself.
|
||||
**Our Philosophy:** For too long, the LCE scene has been fragmented across different, often resource-heavy launchers. Emerald was built to stop this fragmentation and centralize everything into a single, definitive hub. By avoiding bloated frameworks and utilizing a modern Rust/Tauri stack, we deliver a high-performance, cross-platform experience that uses only ~15MB of RAM, leaving all your PC's resources dedicated to the game itself.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -68,8 +71,4 @@ Click the heading above to track progress, view active tasks, and see upcoming l
|
|||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
***This project is licensed under the GNU GPL v3 License - see the LICENSE file for details.***
|
||||
***This project is licensed under the [GNU GPL v3 License](LICENSE)***
|
||||
|
|
|
|||
2102
pnpm-lock.yaml
Normal file
BIN
public/images/logo.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
2477
src-tauri/gen/schemas/linux-schema.json
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 9 KiB |
BIN
src-tauri/icons/app-icon.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 987 B After Width: | Height: | Size: 1,014 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 7.5 KiB |
|
|
@ -7,7 +7,23 @@ use futures_util::StreamExt;
|
|||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppConfig {
|
||||
pub username: String,
|
||||
pub linux_runner: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Runner {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
pub struct DownloadState { pub token: Arc<Mutex<Option<CancellationToken>>> }
|
||||
|
||||
|
|
@ -17,17 +33,78 @@ fn get_app_dir(app: &AppHandle) -> PathBuf {
|
|||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config(app: AppHandle, username: String) {
|
||||
let path = get_app_dir(&app).join("emerald_legacy_config.txt");
|
||||
let _ = fs::create_dir_all(path.parent().unwrap());
|
||||
let _ = fs::write(path, &username);
|
||||
fn get_config_path(app: &AppHandle) -> PathBuf {
|
||||
get_app_dir(app).join("emerald_legacy_config.json")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_config(app: AppHandle) -> String {
|
||||
let path = get_app_dir(&app).join("emerald_legacy_config.txt");
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
fn save_config(app: AppHandle, config: AppConfig) {
|
||||
let path = get_config_path(&app);
|
||||
let _ = fs::create_dir_all(path.parent().unwrap());
|
||||
if let Ok(json) = serde_json::to_string(&config) {
|
||||
let _ = fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_config(app: AppHandle) -> AppConfig {
|
||||
let path = get_config_path(&app);
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Ok(config) = serde_json::from_str(&content) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
let old_path = get_app_dir(&app).join("emerald_legacy_config.txt");
|
||||
let username = fs::read_to_string(old_path).unwrap_or_else(|_| "Player".into());
|
||||
AppConfig { username, linux_runner: None }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_available_runners() -> Vec<Runner> {
|
||||
let mut runners = Vec::new();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("wine").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
runners.push(Runner {
|
||||
id: "wine".to_string(),
|
||||
name: "System Wine".to_string(),
|
||||
path,
|
||||
r#type: "wine".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let steam_paths = [
|
||||
format!("{}/.steam/steam/steamapps/common", home),
|
||||
format!("{}/.local/share/Steam/steamapps/common", home),
|
||||
];
|
||||
|
||||
for base_path in steam_paths {
|
||||
if let Ok(entries) = fs::read_dir(base_path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("Proton") {
|
||||
runners.push(Runner {
|
||||
id: format!("proton_{}", name),
|
||||
name: name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
r#type: "proton".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runners
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -89,6 +166,13 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u
|
|||
drop(file);
|
||||
{ *state.token.lock().await = None; }
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let status = Command::new("unzip")
|
||||
.args(["-q", zip_path.to_str().unwrap(), "-d", instance_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let status = Command::new("tar")
|
||||
.args(["-xf", zip_path.to_str().unwrap(), "-C", instance_dir.to_str().unwrap()])
|
||||
.status()
|
||||
|
|
@ -125,8 +209,8 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u
|
|||
async fn launch_game(app: AppHandle, instanceId: String) -> Result<(), String> {
|
||||
let root = get_app_dir(&app);
|
||||
let instance_dir = root.join("instances").join(&instanceId);
|
||||
let config_path = root.join("emerald_legacy_config.txt");
|
||||
let username = fs::read_to_string(config_path).unwrap_or_else(|_| "Player".into());
|
||||
let config = load_config(app.clone());
|
||||
let username = config.username;
|
||||
|
||||
let _ = fs::write(instance_dir.join("username.txt"), &username);
|
||||
|
||||
|
|
@ -135,7 +219,38 @@ async fn launch_game(app: AppHandle, instanceId: String) -> Result<(), String> {
|
|||
return Err("Game executable not found in instance folder.".into());
|
||||
}
|
||||
|
||||
let _ = Command::new(&game_exe).spawn().map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(runner_id) = config.linux_runner {
|
||||
let runners = get_available_runners();
|
||||
if let Some(runner) = runners.into_iter().find(|r| r.id == runner_id) {
|
||||
let mut cmd = if runner.r#type == "proton" {
|
||||
let proton_exe = PathBuf::from(&runner.path).join("proton");
|
||||
let mut c = Command::new(proton_exe);
|
||||
let compat_data = instance_dir.join("proton_prefix");
|
||||
fs::create_dir_all(&compat_data).map_err(|e| e.to_string())?;
|
||||
c.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", "");
|
||||
c.env("STEAM_COMPAT_DATA_PATH", compat_data.to_str().unwrap());
|
||||
c.arg("run");
|
||||
c
|
||||
} else {
|
||||
Command::new(runner.path)
|
||||
};
|
||||
|
||||
cmd.arg(&game_exe)
|
||||
.current_dir(&instance_dir)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
return Err("No Linux runner selected in settings.".into());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = Command::new(&game_exe).spawn().map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -144,7 +259,7 @@ pub fn run() {
|
|||
tauri::Builder::default()
|
||||
.manage(DownloadState { token: Arc::new(Mutex::new(None)) })
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![launch_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download])
|
||||
.invoke_handler(tauri::generate_handler![launch_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error");
|
||||
}
|
||||
222
src/App.css
|
|
@ -1,38 +1,210 @@
|
|||
@font-face { font-family: 'Minecraft'; src: url('/fonts/Mojangles.ttf') format('truetype'); }
|
||||
@tailwind base; @tailwind components; @tailwind utilities;
|
||||
@font-face {
|
||||
font-family: 'Minecraft';
|
||||
src: url('/fonts/Mojangles.ttf') format('truetype');
|
||||
}
|
||||
|
||||
* { border-radius: 0 !important; font-family: 'Minecraft', sans-serif !important; image-rendering: pixelated; font-weight: normal !important; }
|
||||
body { background: #000; margin: 0; }
|
||||
main { background: url('/images/dirt-background.png') repeat !important; background-size: 2000px !important; position: relative; }
|
||||
main::before { content: ""; position: absolute; inset: 0; background: rgba(0, 0, 0, 0.7); z-index: 0; }
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
font-family: 'Minecraft', sans-serif !important;
|
||||
image-rendering: pixelated;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
background: url('/images/dirt-background.png') repeat !important;
|
||||
background-size: 2000px !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
main::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/*background: rgba(0, 0, 0, 0.7);*/
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.legacy-btn {
|
||||
@apply transition-all duration-75 flex items-center justify-center;
|
||||
background: #bebebe; border: 4px solid #000 !important;
|
||||
background: #bebebe;
|
||||
border: 4px solid #000 !important;
|
||||
box-shadow: inset 4px 4px #ffffff, inset -4px -4px #555555 !important;
|
||||
color: #3e3e3e !important; cursor: pointer;
|
||||
color: #3e3e3e !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legacy-btn:hover:not(:disabled) { background: #d0d0d0; outline: 4px solid #fff; outline-offset: -8px; }
|
||||
.legacy-btn:active:not(:disabled) { box-shadow: inset -4px -4px #ffffff, inset 4px 4px #555555 !important; }
|
||||
.legacy-btn:disabled { opacity: 0.6; cursor: not-allowed; filter: grayscale(1); }
|
||||
.legacy-btn:hover:not(:disabled) {
|
||||
background: #d0d0d0;
|
||||
outline: 4px solid #fff;
|
||||
outline-offset: -8px;
|
||||
}
|
||||
|
||||
.active-tab { background: #388e3c !important; box-shadow: inset 4px 4px #55ff55, inset -4px -4px #1a4d0a !important; color: #fff !important; }
|
||||
.reinstall-btn, .confirm-red-btn, .cancel-download-btn { background: #ff5555 !important; box-shadow: inset 4px 4px #ffaaaa, inset -4px -4px #aa0000 !important; color: #fff !important; }
|
||||
.legacy-btn:active:not(:disabled) {
|
||||
box-shadow: inset -4px -4px #ffffff, inset 4px 4px #555555 !important;
|
||||
}
|
||||
|
||||
.mc-range { -webkit-appearance: none; background: #000; height: 12px; border: 3px solid #555; width: 100%; }
|
||||
.mc-range::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 24px; background: #bebebe; border: 3px solid #000; box-shadow: inset 2px 2px #fff, inset -2px -2px #555; cursor: pointer; }
|
||||
.legacy-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.social-btn { background: transparent; border: 4px solid #000; box-shadow: inset 4px 4px #555, inset -4px -4px #111; width: 64px; height: 64px; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: transform 0.1s; }
|
||||
.social-btn:hover { transform: scale(1.1); }
|
||||
.social-btn svg { fill: white; width: 32px; height: 32px; }
|
||||
.legacy-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background: #bebebe url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%233e3e3e' stroke-width='3' stroke-linecap='square' stroke-linejoin='miter'%3E%3Cpath d='M7 10l5 5 5-5'/%3E%3C/svg%3E") no-repeat right 12px center;
|
||||
border: 4px solid #000 !important;
|
||||
box-shadow: inset 4px 4px #ffffff, inset -4px -4px #555555 !important;
|
||||
color: #3e3e3e !important;
|
||||
cursor: pointer;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.btn-discord { background: #5865F2 !important; box-shadow: inset 4px 4px #8ea1e1, inset -4px -4px #313338 !important; }
|
||||
.btn-github { background: #333 !important; box-shadow: inset 4px 4px #666, inset -4px -4px #000 !important; }
|
||||
.btn-reddit { background: #FF4500 !important; box-shadow: inset 4px 4px #ff8b60, inset -4px -4px #942700 !important; }
|
||||
.legacy-select:focus {
|
||||
outline: 4px solid #fff;
|
||||
outline-offset: -8px;
|
||||
}
|
||||
|
||||
.splash-text { color: #ffff00; text-shadow: 4px 4px #3f3f00; white-space: nowrap; pointer-events: none; z-index: 20; transform: rotate(-20deg); animation: splash-pulse 0.5s infinite alternate; transform-origin: center; }
|
||||
@keyframes splash-pulse { from { transform: rotate(-20deg) scale(1); } to { transform: rotate(-20deg) scale(1.05); } }
|
||||
.sidebar-progress {
|
||||
background: #2a2a2a;
|
||||
border-top: 4px solid #000;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
box-shadow: inset 0 4px #444;
|
||||
}
|
||||
|
||||
.mc-progress-container {
|
||||
background: #313131 !important;
|
||||
border: 4px solid #000 !important;
|
||||
height: 32px !important;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.mc-progress-bar {
|
||||
background: #55FF55 !important;
|
||||
height: 16px !important;
|
||||
box-shadow: inset -2px -2px #00AA00, inset 2px 2px #AAFFAA !important;
|
||||
}
|
||||
|
||||
.mc-progress-text {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
text-shadow: 2px 2px #000;
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.active-tab {
|
||||
background: #388e3c !important;
|
||||
box-shadow: inset 4px 4px #55ff55, inset -4px -4px #1a4d0a !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.reinstall-btn,
|
||||
.confirm-red-btn,
|
||||
.cancel-download-btn {
|
||||
background: #ff5555 !important;
|
||||
box-shadow: inset 4px 4px #ffaaaa, inset -4px -4px #aa0000 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.mc-range {
|
||||
-webkit-appearance: none;
|
||||
background: #000;
|
||||
height: 12px;
|
||||
border: 3px solid #555;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mc-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
background: #bebebe;
|
||||
border: 3px solid #000;
|
||||
box-shadow: inset 2px 2px #fff, inset -2px -2px #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
background: transparent;
|
||||
border: 4px solid #000;
|
||||
box-shadow: inset 4px 4px #555, inset -4px -4px #111;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.social-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.social-btn svg {
|
||||
fill: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.btn-discord {
|
||||
background: #5865F2 !important;
|
||||
box-shadow: inset 4px 4px #8ea1e1, inset -4px -4px #313338 !important;
|
||||
}
|
||||
|
||||
.btn-github {
|
||||
background: #333 !important;
|
||||
box-shadow: inset 4px 4px #666, inset -4px -4px #000 !important;
|
||||
}
|
||||
|
||||
.btn-reddit {
|
||||
background: #FF4500 !important;
|
||||
box-shadow: inset 4px 4px #ff8b60, inset -4px -4px #942700 !important;
|
||||
}
|
||||
|
||||
.splash-text {
|
||||
color: #ffff00;
|
||||
text-shadow: 4px 4px #3f3f00;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
transform: rotate(-20deg);
|
||||
animation: splash-pulse 0.5s infinite alternate;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes splash-pulse {
|
||||
from {
|
||||
transform: rotate(-20deg) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(-20deg) scale(1.05);
|
||||
}
|
||||
}
|
||||
170
src/App.tsx
|
|
@ -5,14 +5,27 @@ import { useState, useEffect, useRef } from "react";
|
|||
import "./App.css";
|
||||
|
||||
const Icons = {
|
||||
Discord: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M6 6h20v4h2v12h-2v4h-4v-4h-8v4H6v-4H4V10h2V6zm4 6v4h4v-4h-4zm8 0v4h4v-4h-4z"/></svg>,
|
||||
Github: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M12 4h8v4h4v4h4v8h-4v4h-4v4h-8v-4H8v-4H4v-8h4V8h4V4zm2 8v4h4v-4h-4z"/></svg>,
|
||||
Reddit: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M10 4h12v4h4v4h2v12h-2v4H10v-4H4V12h2V8h4V4zm2 10v4h8v-4h-8z"/></svg>,
|
||||
Discord: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M6 6h20v4h2v12h-2v4h-4v-4h-8v4H6v-4H4V10h2V6zm4 6v4h4v-4h-4zm8 0v4h4v-4h-4z" /></svg>,
|
||||
Github: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M12 4h8v4h4v4h4v8h-4v4h-4v4h-8v-4H8v-4H4v-8h4V8h4V4zm2 8v4h4v-4h-4z" /></svg>,
|
||||
Reddit: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M10 4h12v4h4v4h2v12h-2v4H10v-4H4V12h2V8h4V4zm2 10v4h8v-4h-8z" /></svg>,
|
||||
Volume: ({ level }: { level: number }) => (
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M4 12h8l8-8v24l-8-8H4v-8z"/>{level > 0 && <path d="M24 12h2v8h-2z"/>}{level > 0.5 && <path d="M28 8h2v16h-2z"/>}</svg>
|
||||
)
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M4 12h8l8-8v24l-8-8H4v-8z" />{level > 0 && <path d="M24 12h2v8h-2z" />}{level > 0.5 && <path d="M28 8h2v16h-2z" />}</svg>
|
||||
),
|
||||
Linux: () => <svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor"><path d="M16 4c-3.3 0-6 2.7-6 6 0 1.2.4 2.3 1 3.2C8.7 15.1 7 18.3 7 22h2c0-3.9 3.1-7 7-7s7 3.1 7 7h2c0-3.7-1.7-6.9-4-8.8.6-.9 1-2 1-3.2 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z" /></svg>
|
||||
};
|
||||
|
||||
interface Runner {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
username: string;
|
||||
linuxRunner?: string;
|
||||
}
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
let sfxGain: GainNode | null = null;
|
||||
const buffers: Record<string, AudioBuffer> = {};
|
||||
|
|
@ -23,16 +36,19 @@ export default function App() {
|
|||
const [isFirstRun, setIsFirstRun] = useState(true);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [installingInstance, setInstallingInstance] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [installedStatus, setInstalledStatus] = useState<Record<string, boolean>>({ vanilla_tu19: false, vanilla_tu24: false });
|
||||
const [selectedInstance, setSelectedInstance] = useState<string>("vanilla_tu19");
|
||||
const [reinstallModal, setReinstallModal] = useState<{ id: string, url: string } | null>(null);
|
||||
const [mcNotif, setMcNotif] = useState<{t: string, m: string} | null>(null);
|
||||
|
||||
const [mcNotif, setMcNotif] = useState<{ t: string, m: string } | null>(null);
|
||||
const [availableRunners, setAvailableRunners] = useState<Runner[]>([]);
|
||||
const [selectedRunner, setSelectedRunner] = useState<string>("");
|
||||
const [isLinux, setIsLinux] = useState(false);
|
||||
|
||||
const [musicVol, setMusicVol] = useState(parseFloat(localStorage.getItem("musicVol") || "0.4"));
|
||||
const [sfxVol, setSfxVol] = useState(parseFloat(localStorage.getItem("sfxVol") || "0.7"));
|
||||
const [isMuted, setIsMuted] = useState(localStorage.getItem("isMuted") === "true");
|
||||
|
||||
|
||||
const musicRef = useRef<HTMLAudioElement | null>(null);
|
||||
const lastTrack = useRef<number>(0);
|
||||
|
||||
|
|
@ -80,7 +96,7 @@ export default function App() {
|
|||
lastTrack.current = track;
|
||||
musicRef.current.src = `/music/music${track}.ogg`;
|
||||
musicRef.current.volume = isMuted ? 0 : musicVol;
|
||||
musicRef.current.play().catch(() => {});
|
||||
musicRef.current.play().catch(() => { });
|
||||
};
|
||||
|
||||
const fadeAndLaunch = async () => {
|
||||
|
|
@ -97,17 +113,17 @@ export default function App() {
|
|||
}, 50);
|
||||
}
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await invoke("launch_game", { instanceId: selectedInstance });
|
||||
} catch (e) {
|
||||
alert(`Failed to launch game: ${e}`);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
if (musicRef.current) {
|
||||
musicRef.current.volume = isMuted ? 0 : musicVol;
|
||||
playRandomMusic();
|
||||
}
|
||||
try {
|
||||
await invoke("launch_game", { instanceId: selectedInstance });
|
||||
} catch (e) {
|
||||
alert(`Failed to launch game: ${e}`);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
if (musicRef.current) {
|
||||
musicRef.current.volume = isMuted ? 0 : musicVol;
|
||||
playRandomMusic();
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
|
|
@ -118,7 +134,7 @@ export default function App() {
|
|||
setMcNotif({ t: "Success!", m: "Ready to play." }); playSfx('orb.ogg');
|
||||
setTimeout(() => setMcNotif(null), 4000);
|
||||
updateAllStatus();
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Error during installation: " + e);
|
||||
}
|
||||
|
|
@ -132,14 +148,24 @@ export default function App() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
invoke("load_config").then((n) => {
|
||||
const savedName = n as string;
|
||||
if (savedName && savedName.trim() !== "") {
|
||||
setUsername(savedName);
|
||||
invoke("load_config").then((c) => {
|
||||
const config = c as AppConfig;
|
||||
if (config.username && config.username.trim() !== "") {
|
||||
setUsername(config.username);
|
||||
setIsFirstRun(false);
|
||||
setTimeout(playRandomMusic, 1000);
|
||||
}
|
||||
if (config.linuxRunner) setSelectedRunner(config.linuxRunner);
|
||||
});
|
||||
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
if (platform.includes("linux")) {
|
||||
setIsLinux(true);
|
||||
invoke<Runner[]>("get_available_runners").then(runners => {
|
||||
setAvailableRunners(runners);
|
||||
});
|
||||
}
|
||||
|
||||
updateAllStatus();
|
||||
const unlisten = listen<number>("download-progress", (e) => setDownloadProgress(Math.round(e.payload)));
|
||||
return () => { unlisten.then(f => f()); };
|
||||
|
|
@ -152,7 +178,7 @@ export default function App() {
|
|||
<div className="bg-[#2a2a2a] p-10 border-4 border-black w-full max-w-2xl text-center shadow-[inset_4px_4px_#555,inset_-4px_-4px_#111]">
|
||||
<h2 className="text-4xl text-emerald-400 mb-4">Welcome to Emerald Legacy!</h2>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} className="w-full bg-black border-4 border-emerald-900 p-4 text-3xl text-center mb-8 outline-none" placeholder="Username..." />
|
||||
<button onClick={() => { ensureAudio(); playSfx('click.wav'); invoke("save_config", { username }); setIsFirstRun(false); setTimeout(playRandomMusic, 500); }} disabled={!username.trim()} className="legacy-btn py-4 px-12 text-3xl w-full">Start Setup</button>
|
||||
<button onClick={() => { ensureAudio(); playSfx('click.wav'); invoke("save_config", { config: { username, linuxRunner: isLinux ? selectedRunner : undefined } }); setIsFirstRun(false); setTimeout(playRandomMusic, 500); }} disabled={!username.trim() || (isLinux && availableRunners.length === 0)} className="legacy-btn py-4 px-12 text-3xl w-full">Start Setup</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -163,8 +189,7 @@ export default function App() {
|
|||
<audio ref={musicRef} onEnded={playRandomMusic} />
|
||||
<aside className="w-64 bg-[#2a2a2a] border-r-4 border-black p-6 flex flex-col gap-2 z-20 shadow-[inset_-4px_0_#555]">
|
||||
<div className="mb-10 px-2">
|
||||
<h2 className="text-emerald-500 text-3xl italic tracking-tighter">EMERALD LEGACY</h2>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-widest">Launcher</p>
|
||||
<img src="/images/logo.png" />
|
||||
</div>
|
||||
<nav className="flex flex-col gap-3">
|
||||
<button onClick={() => { playSfx('click.wav'); setActiveTab("home"); updateAllStatus(); }} className={`p-4 legacy-btn justify-start ${activeTab === "home" ? "active-tab" : ""}`}>HOME</button>
|
||||
|
|
@ -172,7 +197,20 @@ export default function App() {
|
|||
<button onClick={() => { playSfx('click.wav'); setActiveTab("settings"); }} className={`p-4 legacy-btn justify-start ${activeTab === "settings" ? "active-tab" : ""}`}>SETTINGS</button>
|
||||
</nav>
|
||||
|
||||
<div onClick={() => { playSfx('click.wav'); openUrl("https://github.com/KayJannOnGit"); }} className="mt-auto pt-6 flex flex-col items-center border-t-4 border-black/30 cursor-pointer group">
|
||||
{installingInstance && (
|
||||
<div className="sidebar-progress mt-auto">
|
||||
<div className="flex justify-between mb-3 text-slate-300 font-bold text-[10px] uppercase tracking-widest px-1">
|
||||
<span>Installing</span>
|
||||
<button onClick={() => { playSfx('back.ogg'); invoke("cancel_download"); }} className="text-red-500 hover:underline">CANCEL</button>
|
||||
</div>
|
||||
<div className="mc-progress-container">
|
||||
<div className="mc-progress-bar transition-all duration-300" style={{ width: `${downloadProgress}%` }}></div>
|
||||
<div className="mc-progress-text">{downloadProgress}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div onClick={() => { playSfx('click.wav'); openUrl("https://github.com/KayJannOnGit"); }} className={`${installingInstance ? "pt-6" : "mt-auto pt-6"} flex flex-col items-center border-t-4 border-black/30 cursor-pointer group`}>
|
||||
<span className="text-slate-500 text-[10px] uppercase">Developed by</span>
|
||||
<span className="text-emerald-500 text-sm font-bold group-hover:underline">KayJann</span>
|
||||
</div>
|
||||
|
|
@ -189,11 +227,11 @@ export default function App() {
|
|||
<div className="bg-black/80 p-8 border-4 border-black w-[550px] flex flex-col gap-6 mt-12">
|
||||
{installedStatus.vanilla_tu19 || installedStatus.vanilla_tu24 ? (
|
||||
<>
|
||||
<select value={selectedInstance} onChange={e => { playSfx('click.wav'); setSelectedInstance(e.target.value); }} className="w-full bg-[#2a2a2a] border-4 border-black p-3 text-2xl text-white outline-none">
|
||||
{installedStatus.vanilla_tu19 && <option value="vanilla_tu19">Vanilla Nightly (TU19)</option>}
|
||||
{installedStatus.vanilla_tu24 && <option value="vanilla_tu24">Vanilla TU24</option>}
|
||||
<select value={selectedInstance} onChange={e => { playSfx('click.wav'); setSelectedInstance(e.target.value); }} className="w-full legacy-select p-3 text-2xl outline-none">
|
||||
{installedStatus.vanilla_tu19 && <option value="vanilla_tu19">Vanilla Nightly (TU19)</option>}
|
||||
{installedStatus.vanilla_tu24 && <option value="vanilla_tu24">Vanilla TU24</option>}
|
||||
</select>
|
||||
<button onClick={fadeAndLaunch} disabled={isRunning || !!installingInstance} className="legacy-btn py-4 text-6xl w-full">{installingInstance ? "DOWNLOADING..." : isRunning ? "RUNNING..." : "PLAY"}</button>
|
||||
<button onClick={fadeAndLaunch} disabled={isRunning || !!installingInstance} className="legacy-btn py-4 text-6xl w-full">{installingInstance ? "WAITING..." : isRunning ? "RUNNING..." : "PLAY"}</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
|
|
@ -209,7 +247,7 @@ export default function App() {
|
|||
<div className="w-full max-w-3xl bg-black/80 p-12 border-4 border-black h-full overflow-y-auto no-scrollbar animate-in fade-in">
|
||||
<h2 className="text-5xl mb-8 border-b-4 border-white/20 pb-4">Instances</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
|
||||
{/* TU19 */}
|
||||
<div className="flex justify-between items-center bg-[#2a2a2a] border-4 border-black p-6">
|
||||
<div><h3 className="text-2xl font-bold">Vanilla Nightly (TU19)</h3><p className="text-slate-400 text-sm">Leaked 4J Studios build.</p></div>
|
||||
|
|
@ -247,17 +285,6 @@ export default function App() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{installingInstance && (
|
||||
<div className="mt-8 p-6 border-4 border-emerald-500 bg-[#1a2a1a] relative">
|
||||
<div className="flex justify-between mb-3 text-emerald-400 font-bold text-xl uppercase">
|
||||
<span>Downloading {downloadProgress}%</span>
|
||||
<button onClick={() => { playSfx('back.ogg'); invoke("cancel_download"); }} className="legacy-btn px-3 py-1 text-sm cancel-download-btn">Cancel</button>
|
||||
</div>
|
||||
<div className="w-full bg-black border-4 border-emerald-900 h-8">
|
||||
<div className="bg-emerald-500 h-full transition-all duration-300" style={{ width: `${downloadProgress}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -269,19 +296,40 @@ export default function App() {
|
|||
<label className="text-xl text-slate-400 italic">In-game Username</label>
|
||||
<div className="flex gap-4">
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} className="flex-1 bg-black border-4 border-slate-700 p-4 text-3xl outline-none focus:border-emerald-500" />
|
||||
<button onClick={() => { playSfx('wood click.wav'); invoke("save_config", { username }); }} className="legacy-btn px-8 text-2xl relative">Save</button>
|
||||
<button onClick={() => { playSfx('wood click.wav'); invoke("save_config", { config: { username, linuxRunner: selectedRunner || undefined } }); }} className="legacy-btn px-8 text-2xl relative">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{isLinux && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="text-xl text-slate-400 italic flex items-center gap-2"><Icons.Linux /> Linux Runner</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<select
|
||||
value={selectedRunner}
|
||||
onChange={e => { playSfx('click.wav'); setSelectedRunner(e.target.value); }}
|
||||
className="w-full legacy-select p-4 text-2xl outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="" disabled>Select a runner...</option>
|
||||
{availableRunners.map(r => (
|
||||
<option key={r.id} value={r.id}>{r.name} ({r.type})</option>
|
||||
))}
|
||||
</select>
|
||||
{availableRunners.length === 0 && (
|
||||
<p className="text-red-500 text-sm">No Proton or Wine installations found. Please install Steam or Wine.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 bg-[#2a2a2a] p-6 border-4 border-black shadow-[inset_4px_4px_#555]">
|
||||
<label className="text-xl flex items-center gap-4"><Icons.Volume level={musicVol}/> Audio Controls</label>
|
||||
<label className="text-xl flex items-center gap-4"><Icons.Volume level={musicVol} /> Audio Controls</label>
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm uppercase opacity-50">Music {Math.round(musicVol*100)}%</span>
|
||||
<span className="text-sm uppercase opacity-50">Music {Math.round(musicVol * 100)}%</span>
|
||||
<input type="range" min="0" max="1" step="0.01" value={musicVol} onChange={e => setMusicVol(parseFloat(e.target.value))} className="mc-range" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm uppercase opacity-50">SFX {Math.round(sfxVol*100)}%</span>
|
||||
<span className="text-sm uppercase opacity-50">SFX {Math.round(sfxVol * 100)}%</span>
|
||||
<input type="range" min="0" max="1" step="0.01" value={sfxVol} onChange={e => setSfxVol(parseFloat(e.target.value))} className="mc-range" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -289,17 +337,17 @@ export default function App() {
|
|||
</div>
|
||||
|
||||
<div className="about-section border-4 border-black bg-[#2a2a2a] p-6 shadow-[inset_4px_4px_#555]">
|
||||
<h3 className="text-2xl text-[#ffff55] mb-2 uppercase tracking-wide">About the project</h3>
|
||||
<p className="text-xl text-white leading-relaxed mb-6 opacity-90">
|
||||
I'm <span className="text-emerald-400">KayJann</span>, and I absolutely love this project! It's my very first one,
|
||||
and my goal is to create a central hub for the LCE community to bring us all together.
|
||||
</p>
|
||||
<h3 className="text-sm text-slate-500 mb-4 uppercase tracking-widest">Social Links</h3>
|
||||
<div className="flex gap-6">
|
||||
<button onClick={() => openUrl("https://discord.gg/nzbxB8Hxjh")} className="social-btn btn-discord" title="Discord"><Icons.Discord /></button>
|
||||
<button onClick={() => openUrl("https://github.com/KayJannOnGit")} className="social-btn btn-github" title="GitHub"><Icons.Github /></button>
|
||||
<button onClick={() => openUrl("https://reddit.com/user/KayJann")} className="social-btn btn-reddit" title="Reddit"><Icons.Reddit /></button>
|
||||
</div> </div>
|
||||
<h3 className="text-2xl text-[#ffff55] mb-2 uppercase tracking-wide">About the project</h3>
|
||||
<p className="text-xl text-white leading-relaxed mb-6 opacity-90">
|
||||
I'm <span className="text-emerald-400">KayJann</span>, and I absolutely love this project! It's my very first one,
|
||||
and my goal is to create a central hub for the LCE community to bring us all together.
|
||||
</p>
|
||||
<h3 className="text-sm text-slate-500 mb-4 uppercase tracking-widest">Social Links</h3>
|
||||
<div className="flex gap-6">
|
||||
<button onClick={() => openUrl("https://discord.gg/nzbxB8Hxjh")} className="social-btn btn-discord" title="Discord"><Icons.Discord /></button>
|
||||
<button onClick={() => openUrl("https://github.com/KayJannOnGit")} className="social-btn btn-github" title="GitHub"><Icons.Github /></button>
|
||||
<button onClick={() => openUrl("https://reddit.com/user/KayJann")} className="social-btn btn-reddit" title="Reddit"><Icons.Reddit /></button>
|
||||
</div> </div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||