Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d530fe258 | |||
| 5f8f084064 | |||
| a24330ae2b | |||
| b04d1f06eb | |||
| 1411f78388 | |||
| c5a05ca3e2 | |||
| f9487ce632 | |||
| e17fbd2354 | |||
| c087dbcd69 | |||
| 5fb311859a | |||
| b01b1273b1 | |||
| 1b02b443d7 | |||
| d874b194a2 | |||
| f648cbbcc4 | |||
| 212413ff1a | |||
| 7414efce47 | |||
| eddba38829 | |||
| 365a8143ff | |||
| 1ecc47c32f | |||
| b589e43d3e | |||
| 74d2b71084 | |||
| 972a5958c6 |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Generated
+52
@@ -47,6 +47,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
@@ -363,6 +369,12 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -987,6 +999,7 @@ dependencies = [
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"steam_shortcuts_util",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-drpc",
|
||||
@@ -2505,6 +2518,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2628,6 +2647,27 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom_locate"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"memchr",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -4202,6 +4242,18 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "steam_shortcuts_util"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"crc32fast",
|
||||
"nom",
|
||||
"nom_locate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
|
||||
@@ -28,3 +28,4 @@ rfd = "0.15"
|
||||
libc = "0.2"
|
||||
image = "0.24"
|
||||
percent-encoding = "2.3"
|
||||
steam_shortcuts_util = "1.1.8"
|
||||
|
||||
+138
-2
@@ -2,6 +2,7 @@ use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use steam_shortcuts_util::{Shortcut, parse_shortcuts, shortcuts_to_bytes};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Stdio;
|
||||
@@ -13,6 +14,7 @@ use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct McServer {
|
||||
@@ -763,6 +765,9 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u
|
||||
"Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat",
|
||||
"servers.dat", "servers.txt", "server.properties", "options.txt", "servers.db",
|
||||
"workshop_files.json", "screenshots", "update_timestamp.txt",
|
||||
"profile0.dat", "profile1.dat", "profile2.dat", "profile3.dat",
|
||||
"profile4.dat", "profile5.dat", "profile6.dat", "profile7.dat",
|
||||
"profile8.dat", "profile9.dat", "profile10.dat"
|
||||
].iter().copied().collect();
|
||||
if !instance_dir.exists() {
|
||||
fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?;
|
||||
@@ -1282,7 +1287,7 @@ async fn check_game_update(app: AppHandle, instance_id: String, url: String) ->
|
||||
|
||||
let local_timestamp = fs::read_to_string(×tamp_file).unwrap_or_default();
|
||||
if local_timestamp.is_empty() {
|
||||
return Ok(false);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new().head(&url).send().await.map_err(|e| e.to_string())?;
|
||||
@@ -1677,6 +1682,116 @@ fn delete_screenshot(path: String) -> Result<(), String> {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_to_steam(
|
||||
_app: AppHandle,
|
||||
instance_id: String,
|
||||
name: String,
|
||||
title_base64: String,
|
||||
panorama_base64: String,
|
||||
) -> Result<(), String> {
|
||||
let exe_path = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let exe_str = exe_path.to_string_lossy().to_string();
|
||||
let launch_options = format!("\"{}\"", instance_id);
|
||||
let start_dir = exe_path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let app_id_32 = steam_shortcuts_util::app_id_generator::calculate_app_id(&exe_str, &name);
|
||||
//let app_id_64 = ((app_id_32 as u64) << 32) | 0x02000000; //neo: just in case we'll need later.
|
||||
let mut userdata_dirs: Vec<PathBuf> = Vec::new();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let h = PathBuf::from(home);
|
||||
userdata_dirs.push(h.join(".steam/steam/userdata"));
|
||||
userdata_dirs.push(h.join(".local/share/Steam/userdata"));
|
||||
userdata_dirs.push(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam/userdata"));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
userdata_dirs.push(PathBuf::from("C:\\Program Files\\Steam\\userdata"));
|
||||
userdata_dirs.push(PathBuf::from("C:\\Program Files (x86)\\Steam\\userdata"));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let h = PathBuf::from(home);
|
||||
userdata_dirs.push(h.join("Library/Application Support/Steam/userdata"));
|
||||
}
|
||||
}
|
||||
|
||||
let valid_userdata_dirs: Vec<PathBuf> = userdata_dirs.into_iter().filter(|d| d.exists()).collect();
|
||||
if valid_userdata_dirs.is_empty() {
|
||||
return Err("Steam userdata directory not found.".into());
|
||||
}
|
||||
|
||||
for userdata_root in valid_userdata_dirs {
|
||||
let entries = fs::read_dir(&userdata_root).map_err(|e| e.to_string())?;
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
let user_config_dir = entry.path().join("config");
|
||||
let shortcuts_path = user_config_dir.join("shortcuts.vdf");
|
||||
if !user_config_dir.exists() { continue; }
|
||||
let content = if shortcuts_path.exists() {
|
||||
fs::read(&shortcuts_path).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let shortcuts = if !content.is_empty() {
|
||||
parse_shortcuts(&content).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut owned_shortcuts: Vec<steam_shortcuts_util::shortcut::ShortcutOwned> = shortcuts.iter().map(|s| s.to_owned()).collect();
|
||||
if owned_shortcuts.iter().any(|s| s.app_name == name && s.exe == exe_str) {
|
||||
continue;
|
||||
}
|
||||
|
||||
owned_shortcuts.push(steam_shortcuts_util::shortcut::ShortcutOwned {
|
||||
order: owned_shortcuts.len().to_string(),
|
||||
app_id: app_id_32,
|
||||
app_name: name.clone(),
|
||||
exe: exe_str.clone(),
|
||||
start_dir: start_dir.clone(),
|
||||
icon: "".to_string(),
|
||||
shortcut_path: "".to_string(),
|
||||
launch_options: launch_options.clone(),
|
||||
is_hidden: false,
|
||||
allow_desktop_config: true,
|
||||
allow_overlay: true,
|
||||
open_vr: 0,
|
||||
dev_kit: 0,
|
||||
dev_kit_game_id: "".to_string(),
|
||||
dev_kit_overrite_app_id: 0,
|
||||
last_play_time: 0,
|
||||
tags: Vec::new(),
|
||||
});
|
||||
|
||||
let final_shortcuts: Vec<Shortcut> = owned_shortcuts.iter().map(|s| s.borrow()).collect();
|
||||
let new_content = shortcuts_to_bytes(&final_shortcuts);
|
||||
fs::write(&shortcuts_path, new_content).map_err(|e| e.to_string())?;
|
||||
let grid_dir = user_config_dir.join("grid");
|
||||
if !grid_dir.exists() {
|
||||
let _ = fs::create_dir_all(&grid_dir);
|
||||
}
|
||||
|
||||
if grid_dir.exists() {
|
||||
let panorama_data = general_purpose::STANDARD.decode(panorama_base64.clone()).map_err(|e| e.to_string())?;
|
||||
let title_data = general_purpose::STANDARD.decode(title_base64.clone()).map_err(|e| e.to_string())?;
|
||||
let _ = fs::write(grid_dir.join(format!("{}p.png", app_id_32)), &panorama_data);
|
||||
let _ = fs::write(grid_dir.join(format!("{}_hero.png", app_id_32)), &panorama_data);
|
||||
let _ = fs::write(grid_dir.join(format!("{}.png", app_id_32)), &panorama_data);
|
||||
let _ = fs::write(grid_dir.join(format!("{}_logo.png", app_id_32)), &title_data);
|
||||
let _ = fs::write(grid_dir.join(format!("{}.json", app_id_32)), "{\"nVersion\":1,\"logoPosition\":{\"pinnedPosition\":\"CenterCenter\",\"nWidthPct\":50,\"nHeightPct\":50}}".as_bytes()); //neo: if you're confused, this is for logo position
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_screenshot_folder(app: AppHandle, path: String) {
|
||||
let p = std::path::Path::new(&path);
|
||||
@@ -1728,7 +1843,28 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file])
|
||||
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file, add_to_steam])
|
||||
.setup(|app| {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() > 1 && !args[1].starts_with('-') {
|
||||
let instance_id = args[1].clone();
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
let state = app_handle.state::<GameState>();
|
||||
match launch_game(app_handle.clone(), state, instance_id, Vec::new()).await {
|
||||
Ok(_) => { app_handle.exit(0); }
|
||||
Err(e) => {
|
||||
eprintln!("Auto-launch error: {}", e);
|
||||
app_handle.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ fn main() {
|
||||
let stage = env::var("EMERALD_LAUNCH_STAGE").unwrap_or_else(|_| "0".to_string());
|
||||
if stage == "0" {
|
||||
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"];
|
||||
@@ -76,6 +77,7 @@ fn main() {
|
||||
println!("Emerald: Automatic recovery triggered for graphics crash/invisible launch.");
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -6,7 +6,7 @@ interface AchievementToastProps {
|
||||
onClose: () => void;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
variant?: "error" | "update";
|
||||
variant?: "error" | "update" | "steam";
|
||||
}
|
||||
|
||||
export function AchievementToast({
|
||||
@@ -43,6 +43,11 @@ export function AchievementToast({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (variant === "steam") {
|
||||
return (
|
||||
<img src="/images/steam.png" alt="Steam" className="w-8 h-8 object-contain" style={{ imageRendering: "pixelated", filter: "brightness(0) invert(1)" }} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -72,9 +77,9 @@ export function AchievementToast({
|
||||
onClick={
|
||||
onClick
|
||||
? () => {
|
||||
onClick();
|
||||
onClose();
|
||||
}
|
||||
onClick();
|
||||
onClose();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`}
|
||||
|
||||
@@ -8,10 +8,10 @@ interface PanoramaProps {
|
||||
|
||||
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
|
||||
const { isWindowVisible } = useUI();
|
||||
const PANORAMA_PROFILES = ['legacy_evolved', '360revived'];
|
||||
const profileId = PANORAMA_PROFILES.includes(profile) ? profile : 'legacy_evolved';
|
||||
const PANORAMA_PROFILES = ['legacy_evolved', 'vanilla_tu19', '360revived', 'vanilla_tu24'];
|
||||
const baseId = profile;
|
||||
const profileId = PANORAMA_PROFILES.includes(baseId) ? baseId : 'vanilla_tu19';
|
||||
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? 'Day' : 'Night'}.png`;
|
||||
|
||||
const [bgWidth, setBgWidth] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ 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 } from "../../types/grf";
|
||||
|
||||
import { GrfFile, GrfNode, GrfFileEntry } from "../../types/grf";
|
||||
export default function GrfEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
@@ -12,9 +11,8 @@ export default function GrfEditorView() {
|
||||
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);
|
||||
@@ -37,6 +35,60 @@ export default function GrfEditorView() {
|
||||
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();
|
||||
@@ -65,12 +117,20 @@ export default function GrfEditorView() {
|
||||
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"
|
||||
@@ -115,31 +175,49 @@ export default function GrfEditorView() {
|
||||
{activeTab === "rules" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{grf.root.children.map((node, i) => (
|
||||
<GrfNodeView key={i} node={node} level={0} />
|
||||
<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" && (
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grf.files.length === 0 && (
|
||||
<tr><td colSpan={2} 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>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
@@ -174,11 +252,13 @@ export default function GrfEditorView() {
|
||||
);
|
||||
}
|
||||
|
||||
function GrfNodeView({ node, level }: { node: GrfNode, level: number }) {
|
||||
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
|
||||
<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)}
|
||||
@@ -206,15 +286,20 @@ function GrfNodeView({ node, level }: { node: GrfNode, level: number }) {
|
||||
{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-1 text-sm">
|
||||
<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>
|
||||
<span className="text-white flex-1 whitespace-pre-wrap font-mono">{p.value}</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} />
|
||||
<GrfNodeView key={i} node={child} level={level + 1} path={currentPath} onUpdate={onUpdate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ const HomeView = memo(function HomeView() {
|
||||
downloadingId,
|
||||
isGameRunning,
|
||||
stopGame,
|
||||
updatesAvailable,
|
||||
} = useGame();
|
||||
|
||||
const isFocusedSection = focusSection === "menu";
|
||||
@@ -54,10 +55,10 @@ 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,
|
||||
@@ -131,7 +132,16 @@ const HomeView = memo(function HomeView() {
|
||||
opacity: btn.disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="w-full text-center">{btn.label}</span>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -26,9 +26,8 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${
|
||||
isDanger ? "text-red-500" : "text-white"
|
||||
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
|
||||
className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${isDanger ? "text-red-500" : "text-white"
|
||||
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
|
||||
style={{
|
||||
backgroundImage: isHovered
|
||||
? "url('/images/button_highlighted.png')"
|
||||
@@ -61,7 +60,11 @@ const VersionsView = memo(function VersionsView() {
|
||||
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);
|
||||
@@ -71,6 +74,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
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);
|
||||
@@ -99,7 +103,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
const edition = editions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1;
|
||||
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
|
||||
setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1));
|
||||
}
|
||||
} else if (e.key === "ArrowRight") {
|
||||
@@ -108,53 +112,30 @@ 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();
|
||||
@@ -184,6 +165,8 @@ const VersionsView = memo(function VersionsView() {
|
||||
setActiveView,
|
||||
toggleInstall,
|
||||
handleCancelDownload,
|
||||
addToSteam,
|
||||
isDayTime
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -198,11 +181,10 @@ 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);
|
||||
};
|
||||
@@ -247,24 +229,22 @@ const VersionsView = memo(function VersionsView() {
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{editions.map((edition: any, i: number) => {
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
const hasAnyInstall = installedVersions.length > 0;
|
||||
const isSelected =
|
||||
hasAnyInstall && selectedProfile === edition.id;
|
||||
hasAnyInstall && selectedProfile === edition.instanceId;
|
||||
const isFocused = focusIndex === i;
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const isDownloading = downloadingId === edition.id;
|
||||
const isDownloading = downloadingId === edition.instanceId;
|
||||
const isComingSoon = edition.comingSoon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={edition.id}
|
||||
data-index={i}
|
||||
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${
|
||||
isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
|
||||
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${
|
||||
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
|
||||
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""
|
||||
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
|
||||
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
|
||||
>
|
||||
<div className="w-6 flex items-center justify-center flex-shrink-0">
|
||||
@@ -296,23 +276,21 @@ const VersionsView = memo(function VersionsView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<div
|
||||
onClick={() =>
|
||||
!isComingSoon && handleEditionClick(edition, i)
|
||||
}
|
||||
disabled={isComingSoon}
|
||||
className={`flex-1 text-left min-w-0 outline-none rounded ${
|
||||
focusIndex === i && focusBtn === 0 && !isComingSoon
|
||||
? "ring-2 ring-white"
|
||||
: ""
|
||||
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
|
||||
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.startsWith("http") ||
|
||||
edition.logo.startsWith("/images")
|
||||
edition.logo.startsWith("/images")
|
||||
? edition.logo
|
||||
: `screenshots://localhost/${edition.logo.replace(/\\/g, "/")}`
|
||||
}
|
||||
@@ -322,9 +300,8 @@ const VersionsView = memo(function VersionsView() {
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`text-xl tracking-wide truncate ${
|
||||
isSelected ? "text-white" : "text-black"
|
||||
}`}
|
||||
className={`text-xl tracking-wide truncate ${isSelected ? "text-white" : "text-black"
|
||||
}`}
|
||||
style={{ textShadow: "none" }}
|
||||
>
|
||||
{edition.name}
|
||||
@@ -345,288 +322,167 @@ const VersionsView = memo(function VersionsView() {
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`text-base font-medium leading-tight ${
|
||||
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
|
||||
}`}
|
||||
className={`text-base font-medium leading-tight ${isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
|
||||
}`}
|
||||
>
|
||||
{edition.desc}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 relative">
|
||||
{!isInstalled ? (
|
||||
isDownloading ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isDownloading && !downloadingId) {
|
||||
toggleInstall(edition.instanceId);
|
||||
} else if (isDownloading) {
|
||||
handleCancelDownload();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "cancel" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "cancel") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="square"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
) : edition.comingSoon ? (
|
||||
<div className="w-8 h-8" />
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!downloadingId) toggleInstall(edition.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "download" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "download") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: downloadingId ? 0.5 : 1,
|
||||
}}
|
||||
disabled={!!downloadingId}
|
||||
>
|
||||
<img
|
||||
src="/images/Download_Icon.png"
|
||||
alt="Download"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({ row: i, btn: "main" })}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-9 h-9 flex items-center justify-center ${(isDownloading || (!!downloadingId && !isInstalled)) ? "opacity-50" : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "main") || (focusIndex === i && focusBtn === 0)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={isDownloading ? "/images/Trash_Bin_Icon.png" : "/images/Download_Icon.png"}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain"
|
||||
style={{ imageRendering: "pixelated", filter: isDownloading ? "hue-rotate(300deg)" : "none" }}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{isDownloading ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBtn({ row: i, btn: "menu" })}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative"
|
||||
style={{
|
||||
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "menu") || (focusIndex === i && (focusBtn === 0 || focusBtn === 1))
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
filter: updatesAvailable?.[edition.instanceId] ? "drop-shadow(0 0 4px rgba(255,255,0,0.8))" : "none"
|
||||
}}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} />
|
||||
<div className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} />
|
||||
<div className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{openMenuId === edition.id && (
|
||||
<div
|
||||
className="absolute right-0 top-11 w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75"
|
||||
style={{
|
||||
imageRendering: "pixelated"
|
||||
}}
|
||||
>
|
||||
{updatesAvailable?.[edition.instanceId] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelDownload();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "cancel" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "cancel") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
playPressSound();
|
||||
toggleInstall(edition.instanceId);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-[#ffff55] hover:text-white hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="square"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
<img src="/images/Download_Icon.png" alt="" className="w-3 h-3 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
Update Available!
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!downloadingId) toggleInstall(edition.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "update" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "update") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: downloadingId ? 0.5 : 1,
|
||||
}}
|
||||
disabled={!!downloadingId}
|
||||
>
|
||||
<img
|
||||
src="/images/Update_Icon.png"
|
||||
alt="Update"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "folder" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "folder") ||
|
||||
(focusIndex === i && focusBtn === 2)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/Folder_Icon.png"
|
||||
alt="Folder"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/Trash_Bin_Icon.png"
|
||||
alt="Delete"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{Array.isArray(edition.branches) && edition.branches.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
cycleBranch(edition.id);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow hover:bg-white/10 flex items-center justify-between group transition-colors"
|
||||
>
|
||||
<span className="text-[#AAAAAA] group-hover:text-white font-bold">
|
||||
Channel
|
||||
</span>
|
||||
<span className="text-[#ffff55] font-bold">
|
||||
{edition.selectedBranch ?? "Latest"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="h-[1px] bg-white/5 my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.instanceId);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<img src="/images/Folder_Icon.png" alt="" className="w-3.5 h-3.5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
Open Folder
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
const PANORAMA_PROFILES = ['legacy_evolved', 'vanilla_tu19', '360revived', 'vanilla_tu24'];
|
||||
const panoId = PANORAMA_PROFILES.includes(edition.id) ? edition.id : 'vanilla_tu19';
|
||||
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? 'Day' : 'Night'}.png`;
|
||||
addToSteam(edition.instanceId, edition.name, edition.titleImage, panoramaUrl);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<img src="/images/steam.png" alt="" className="w-3.5 h-3.5 object-contain invert brightness-0" style={{ imageRendering: "pixelated" }} />
|
||||
Add to Steam
|
||||
</button>
|
||||
{isCustom ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setEditingEdition(edition);
|
||||
setIsImportModalOpen(true);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#aaaaaa] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-3.5 h-3.5"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
|
||||
Edit Custom
|
||||
</button>
|
||||
) : null}
|
||||
<div className="h-[2px] bg-[#555] my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playBackSound();
|
||||
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="" className="w-3.5 h-3.5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
{isCustom ? "Remove Custom" : "Uninstall"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -647,7 +503,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === editions.length &&
|
||||
hoveredBtn?.btn === "add") ||
|
||||
focusIndex === editions.length
|
||||
focusIndex === editions.length
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
@@ -680,7 +536,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === editions.length &&
|
||||
hoveredBtn?.btn === "folder_import") ||
|
||||
focusIndex === editions.length + 1
|
||||
focusIndex === editions.length + 1
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
|
||||
@@ -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({
|
||||
@@ -73,7 +72,11 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
|
||||
gameRaw.downloadingId, gameRaw.editions, gameRaw.isRunnerDownloading,
|
||||
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
|
||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile
|
||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
|
||||
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
|
||||
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
|
||||
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
|
||||
gameRaw.deleteCustomEdition, gameRaw.downloadRunner
|
||||
]);
|
||||
|
||||
const audio = useMemo(() => audioRaw, [
|
||||
@@ -180,8 +183,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
isWindowVisible,
|
||||
showCredits, setShowCredits, focusSection, setFocusSection,
|
||||
onNavigateToSkin, onNavigateToMenu, connected,
|
||||
updateMessage, clearUpdateMessage
|
||||
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, clearUpdateMessage]);
|
||||
updateMessage, updateUrl, clearUpdateMessage
|
||||
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]);
|
||||
|
||||
return (
|
||||
<UIContext.Provider value={uiValue}>
|
||||
|
||||
@@ -106,7 +106,7 @@ const SPLASHES = [
|
||||
"Traduis-moi !",
|
||||
"May contains Mr_Anilex",
|
||||
"Neoapps didn't write this splash",
|
||||
"Where's Kinger ?",
|
||||
"Where's Kinger?",
|
||||
"KayJann, Breakcore and code",
|
||||
"Hey Goku!",
|
||||
"Vegeta is a DZ mashallah",
|
||||
@@ -144,14 +144,22 @@ const SPLASHES = [
|
||||
"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 {
|
||||
|
||||
+208
-28
@@ -1,12 +1,27 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
async function imageUrlToBase64(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
const BASE_EDITIONS = [
|
||||
{
|
||||
id: "legacy_evolved", //neo: yes. we're not changing the ID. that will break user data.
|
||||
id: "legacy_evolved",
|
||||
name: "neoLegacy",
|
||||
desc: "Backporting newer title updates and Minigames back to LCE",
|
||||
url: "https://github.com/pieeebot/neoLegacy/releases/download/Nightly/neoLegacyWindows64.zip",
|
||||
url: "https://github.com/pieeebot/neoLegacy/releases/download/latest/neoLegacyWindows64.zip",
|
||||
titleImage: "/images/minecraft_title_neoLegacy.png",
|
||||
supportsSlimSkins: true,
|
||||
logo: "/images/neoLegacy.png"
|
||||
@@ -29,7 +44,7 @@ const BASE_EDITIONS = [
|
||||
logo: "/images/360_revived.png"
|
||||
},
|
||||
{
|
||||
id: "legacy_nether_fork", //kay: not changing this one also
|
||||
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",
|
||||
@@ -43,7 +58,7 @@ const PARTNERSHIP_SERVERS = [
|
||||
{
|
||||
name: "Kowhaifans Clubhouse",
|
||||
ip: "lce.kowhaifan.net",
|
||||
port: 1026,
|
||||
port: 25565,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,6 +69,23 @@ interface GameManagerProps {
|
||||
setCustomEditions: (editions: any[]) => void;
|
||||
}
|
||||
|
||||
function compareVersions(v1: string, v2: string) {
|
||||
const parts1 = v1.replace(/^v/, "").split(/[.-]/);
|
||||
const parts2 = v2.replace(/^v/, "").split(/[.-]/);
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const p1 = parts1[i] || "0";
|
||||
const p2 = parts2[i] || "0";
|
||||
const n1 = parseInt(p1);
|
||||
const n2 = parseInt(p2);
|
||||
if (!isNaN(n1) && !isNaN(n2)) {
|
||||
if (n1 !== n2) return n1 - n2;
|
||||
} else {
|
||||
if (p1 !== p2) return p1 > p2 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function useGameManager({
|
||||
profile,
|
||||
setProfile,
|
||||
@@ -70,42 +102,170 @@ export function useGameManager({
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(null);
|
||||
const [steamSuccessMessage, setSteamSuccessMessage] = useState<string | null>(null);
|
||||
const [dynamicUrls, setDynamicUrls] = useState<Record<string, string>>({});
|
||||
const [branches, setBranches] = useState<Record<string, string[]>>({});
|
||||
const [selectedBranches, setSelectedBranches] = useState<Record<string, string>>({});
|
||||
const branchesFetched = useRef<Set<string>>(new Set());
|
||||
const initialBranchesSet = useRef(false);
|
||||
|
||||
const editions = useMemo(
|
||||
() => [...BASE_EDITIONS, ...customEditions],
|
||||
[customEditions],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (initialBranchesSet.current || !profile) return;
|
||||
BASE_EDITIONS.forEach(e => {
|
||||
if (profile.startsWith(e.id + "_")) {
|
||||
const branch = profile.replace(e.id + "_", "");
|
||||
setSelectedBranches(prev => ({ ...prev, [e.id]: branch }));
|
||||
} else if (profile === e.id) {
|
||||
setSelectedBranches(prev => ({ ...prev, [e.id]: "Stable" }));
|
||||
}
|
||||
});
|
||||
initialBranchesSet.current = true;
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestReleases() {
|
||||
try {
|
||||
const response = await fetch("https://api.github.com/repos/pieeebot/neoLegacy/releases/latest");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const asset = data.assets.find((a: any) => a.name === "neoLegacyWindows64.zip");
|
||||
if (asset) {
|
||||
setDynamicUrls(prev => ({ ...prev, legacy_evolved: asset.browser_download_url }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch latest releases:", e);
|
||||
}
|
||||
}
|
||||
fetchLatestReleases();
|
||||
}, []);
|
||||
|
||||
const fetchBranchesForEdition = useCallback(async (editionId: string, url: string) => {
|
||||
if (branchesFetched.current.has(editionId)) return;
|
||||
if (!url.includes("github.com")) return;
|
||||
const parts = url.split("github.com/")[1].split("/");
|
||||
const owner = parts[0];
|
||||
const repo = parts[1];
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
let tags: string[] = data
|
||||
.map((r: any) => r.tag_name)
|
||||
.filter((t: string) => !t.toLowerCase().includes("server"));
|
||||
|
||||
const vTags = tags.filter(t => t.startsWith("v")).sort(compareVersions);
|
||||
const bestVTag = vTags[vTags.length - 1];
|
||||
if (!tags.includes("Stable")) tags.unshift("Stable");
|
||||
if (bestVTag) {
|
||||
setDynamicUrls(prev => ({ ...prev, [`${editionId}_Stable`]: bestVTag }));
|
||||
}
|
||||
|
||||
setBranches(prev => ({ ...prev, [editionId]: tags }));
|
||||
branchesFetched.current.add(editionId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch branches for ${editionId}:`, e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
BASE_EDITIONS.forEach(e => fetchBranchesForEdition(e.id, e.url));
|
||||
}, [fetchBranchesForEdition]);
|
||||
|
||||
const cycleBranch = useCallback((editionId: string) => {
|
||||
const available = branches[editionId] || ["Stable"];
|
||||
if (available.length <= 1) return;
|
||||
setSelectedBranches(prev => {
|
||||
const current = prev[editionId] || available[0];
|
||||
let currentIndex = available.indexOf(current);
|
||||
let nextIndex = currentIndex;
|
||||
let nextBranch = current;
|
||||
do {
|
||||
nextIndex = (nextIndex + 1) % available.length;
|
||||
nextBranch = available[nextIndex];
|
||||
} while (nextBranch.startsWith("v") && nextIndex !== currentIndex);
|
||||
const oldInstanceId =
|
||||
current === "Stable" ? editionId : `${editionId}_${current}`;
|
||||
const newInstanceId =
|
||||
nextBranch === "Stable" ? editionId : `${editionId}_${nextBranch}`;
|
||||
if (profile === oldInstanceId) {
|
||||
setProfile(newInstanceId);
|
||||
}
|
||||
return { ...prev, [editionId]: nextBranch };
|
||||
});
|
||||
}, [branches, profile, setProfile]);
|
||||
|
||||
const editions = useMemo(() => {
|
||||
return [...BASE_EDITIONS.map(e => {
|
||||
const availableBranches = branches[e.id] || ["Stable"];
|
||||
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
|
||||
let url = dynamicUrls[e.id] || e.url;
|
||||
const defaultBranchFromUrl = e.url.includes("/releases/download/")
|
||||
? e.url.split("/releases/download/")[1].split("/")[0]
|
||||
: "nightly";
|
||||
const branchToUse = selectedBranch === "Stable" ? (dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl) : selectedBranch;
|
||||
if (e.url.includes("github.com")) {
|
||||
const baseUrl = e.url.split("/releases/download/")[0];
|
||||
const filename = e.url.split("/").pop();
|
||||
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
...e,
|
||||
url,
|
||||
branches: availableBranches,
|
||||
selectedBranch,
|
||||
instanceId: selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`
|
||||
};
|
||||
}), ...customEditions.map(e => ({ ...e, instanceId: e.id }))];
|
||||
}, [customEditions, dynamicUrls, branches, selectedBranches]);
|
||||
|
||||
const checkInstalls = useCallback(async () => {
|
||||
const results = await Promise.all(
|
||||
editions.map(async (e) => {
|
||||
const isInstalled = await TauriService.checkGameInstalled(e.id);
|
||||
return isInstalled ? e.id : null;
|
||||
const isInstalled = await TauriService.checkGameInstalled(e.instanceId);
|
||||
return isInstalled ? e.instanceId : null;
|
||||
}),
|
||||
);
|
||||
setInstalls(results.filter((id): id is string => id !== null));
|
||||
}, [editions]);
|
||||
|
||||
const [updatesAvailable, setUpdatesAvailable] = useState<Record<string, boolean>>({});
|
||||
const checkForGameUpdates = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
const edition = editions.find(e => e.id === profile);
|
||||
if (!edition) return;
|
||||
try {
|
||||
const isUpdate = await TauriService.checkGameUpdate(profile, edition.url);
|
||||
if (isUpdate) {
|
||||
setGameUpdateMessage(`An update is available for ${edition.name}!`);
|
||||
} else {
|
||||
setGameUpdateMessage(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
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;
|
||||
}
|
||||
}, [profile, editions]);
|
||||
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(() => {
|
||||
if (installs.includes(profile)) {
|
||||
checkForGameUpdates();
|
||||
}
|
||||
checkForGameUpdates();
|
||||
}, [profile, installs, checkForGameUpdates]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,7 +306,7 @@ export function useGameManager({
|
||||
const toggleInstall = useCallback(
|
||||
async (id: string) => {
|
||||
if (downloadingId) return;
|
||||
const edition = editions.find((e) => e.id === id);
|
||||
const edition = editions.find((e) => e.instanceId === id);
|
||||
if (!edition) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -248,6 +408,21 @@ export function useGameManager({
|
||||
[customEditions, setCustomEditions],
|
||||
);
|
||||
|
||||
const addToSteam = useCallback(
|
||||
async (id: string, name: string, titleImage: string, panoramaImage: string) => {
|
||||
try {
|
||||
const titleBase64 = await imageUrlToBase64(titleImage);
|
||||
const panoramaBase64 = await imageUrlToBase64(panoramaImage);
|
||||
await TauriService.addToSteam(id, name, titleBase64, panoramaBase64);
|
||||
setSteamSuccessMessage(`Added ${name} to Steam! (Restart Steam to see it)`);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(typeof e === "string" ? e : e.message || "Failed to add to Steam");
|
||||
}
|
||||
},
|
||||
[setError, setSteamSuccessMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
installs,
|
||||
isGameRunning,
|
||||
@@ -270,5 +445,10 @@ export function useGameManager({
|
||||
checkInstalls,
|
||||
gameUpdateMessage,
|
||||
setGameUpdateMessage,
|
||||
steamSuccessMessage,
|
||||
setSteamSuccessMessage,
|
||||
updatesAvailable,
|
||||
addToSteam,
|
||||
cycleBranch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
declare const __BUILD_DATE__: string;
|
||||
const REPO_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/latest";
|
||||
const LATEST_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/latest";
|
||||
const NIGHTLY_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/tags/nightly";
|
||||
export function useUpdateCheck() {
|
||||
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
||||
const [updateUrl, setUpdateUrl] = useState<string | null>(null);
|
||||
const checkUpdates = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(REPO_URL);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const latestDate = new Date(data.published_at);
|
||||
const buildDate = new Date(__BUILD_DATE__);
|
||||
if (latestDate > buildDate) {
|
||||
setUpdateMessage(`Version ${data.tag_name} is now available!`);
|
||||
} 9
|
||||
let isNightly = false;
|
||||
let latestData = null;
|
||||
let latestDate = new Date(0);
|
||||
try {
|
||||
const latestResponse = await fetch(LATEST_URL);
|
||||
if (latestResponse.ok) {
|
||||
latestData = await latestResponse.json();
|
||||
latestDate = new Date(latestData.published_at);
|
||||
if (buildDate > latestDate) {
|
||||
isNightly = true;
|
||||
}
|
||||
} else {
|
||||
isNightly = true;
|
||||
}
|
||||
} catch (e) {
|
||||
isNightly = true;
|
||||
}
|
||||
|
||||
if (isNightly) {
|
||||
const nightlyResponse = await fetch(NIGHTLY_URL);
|
||||
if (!nightlyResponse.ok) return;
|
||||
const nightlyData = await nightlyResponse.json();
|
||||
let releaseDate = new Date(nightlyData.published_at || nightlyData.updated_at);
|
||||
if (nightlyData.assets && nightlyData.assets.length > 0) {
|
||||
const assetDate = new Date(nightlyData.assets[0].updated_at);
|
||||
if (assetDate > releaseDate) releaseDate = assetDate;
|
||||
}
|
||||
|
||||
if (releaseDate > buildDate) {
|
||||
setUpdateMessage(`A new Nightly build is available!`);
|
||||
setUpdateUrl("https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/tag/nightly");
|
||||
}
|
||||
} else {
|
||||
if (latestDate > buildDate && latestData) {
|
||||
setUpdateMessage(`Version ${latestData.tag_name} is now available!`);
|
||||
setUpdateUrl(latestData.html_url || LATEST_URL);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to check for updates:", e);
|
||||
}
|
||||
@@ -24,6 +57,7 @@ export function useUpdateCheck() {
|
||||
|
||||
return {
|
||||
updateMessage,
|
||||
updateUrl,
|
||||
clearUpdateMessage: () => setUpdateMessage(null),
|
||||
};
|
||||
}
|
||||
|
||||
+11
-3
@@ -45,6 +45,7 @@ export default function App() {
|
||||
focusSection,
|
||||
onNavigateToMenu,
|
||||
updateMessage,
|
||||
updateUrl,
|
||||
clearUpdateMessage,
|
||||
} = useUI();
|
||||
const config = useConfig();
|
||||
@@ -58,7 +59,7 @@ export default function App() {
|
||||
}, [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;
|
||||
@@ -159,7 +160,7 @@ export default function App() {
|
||||
message={updateMessage}
|
||||
onClose={clearUpdateMessage}
|
||||
onClick={() =>
|
||||
TauriService.openUrl("https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest")
|
||||
TauriService.openUrl(updateUrl || "https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest")
|
||||
}
|
||||
title="Update Available!"
|
||||
variant="update"
|
||||
@@ -170,12 +171,19 @@ export default function App() {
|
||||
onClose={() => game.setGameUpdateMessage(null)}
|
||||
onClick={() => {
|
||||
game.setGameUpdateMessage(null);
|
||||
game.toggleInstall(config.profile);
|
||||
setActiveView("versions");
|
||||
}}
|
||||
title="Game Update Available!"
|
||||
variant="update"
|
||||
/>
|
||||
|
||||
<AchievementToast
|
||||
message={game.steamSuccessMessage}
|
||||
onClose={() => game.setSteamSuccessMessage(null)}
|
||||
title="Steam Integration"
|
||||
variant="steam"
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSetup ? (
|
||||
<SetupView
|
||||
|
||||
@@ -286,7 +286,7 @@ export class GrfService {
|
||||
compressedSize = bodyData.length;
|
||||
}
|
||||
|
||||
const finalBuffer = new Uint8Array(10 + (header.compressionLevel >= GrfCompressionLevel.Compressed ? 8 : 0) + 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;
|
||||
@@ -302,4 +302,21 @@ export class GrfService {
|
||||
|
||||
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: []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,4 +226,13 @@ export class TauriService {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user