22 Commits

Author SHA1 Message Date
/home/odd 7d530fe258 More splashes! (#23) 2026-05-08 13:27:38 +03:00
neoapps-dev 5f8f084064 fix: panorama 2026-05-06 15:21:00 +03:00
neoapps-dev a24330ae2b fix: revelations using Nightly 2026-05-06 15:12:36 +03:00
neoapps-dev b04d1f06eb fix: branch cycling logic 2026-05-05 14:12:52 +03:00
neoapps-dev 1411f78388 fix: 360revived and hellish ends downloading 2026-05-04 22:29:25 +03:00
neoapps-dev c5a05ca3e2 feat: branches support, and redesigned a bit the versions menu 2026-05-04 20:38:29 +03:00
neoapps-dev f9487ce632 feat: dynamic link for neoLegacy 2026-05-04 19:43:30 +03:00
neoapps-dev e17fbd2354 fix 2026-05-03 23:37:04 +03:00
neoapps-dev c087dbcd69 feat: use latest instead of Stable 2026-05-03 19:14:59 +03:00
neoapps-dev 5fb311859a revert steamworks, causes problems for now 2026-05-01 23:13:37 +03:00
neoapps-dev b01b1273b1 feat: initial steamworks support 2026-05-01 22:31:29 +03:00
neoapps-dev 1b02b443d7 feat: switch neoLegacy to stable release 2026-05-01 20:45:57 +03:00
neoapps-dev d874b194a2 fix: Steam integration grids 2026-05-01 15:46:34 +03:00
neoapps-dev f648cbbcc4 fix: macos build 2026-04-30 23:10:01 +03:00
neoapps-dev 212413ff1a fix: windows and macos builds 2026-04-30 23:02:38 +03:00
neoapps-dev 7414efce47 feat: initial support for steam integration 2026-04-30 22:51:28 +03:00
neoapps-dev eddba38829 fix: preserve profileX.dat for achievements 2026-04-30 20:06:11 +03:00
neoapps-dev 365a8143ff feat: enhanced update checker for game and launcher 2026-04-30 19:02:34 +03:00
neoapps-dev 1ecc47c32f feat: cleaner UI for version detection 2026-04-30 18:35:39 +03:00
neoapps-dev b589e43d3e fix(devtools/grf): offset issues 2026-04-28 13:03:57 +03:00
neoapps-dev 74d2b71084 feat(devtools/grf): fix undefined error 2026-04-28 12:53:01 +03:00
neoapps-dev 972a5958c6 feat(devtools/grf): support for editing and creating new GRF/GRH files 2026-04-28 12:28:32 +03:00
17 changed files with 824 additions and 418 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+52
View File
@@ -47,6 +47,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "ashpd"
version = "0.11.1"
@@ -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"
+1
View File
@@ -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
View File
@@ -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(&timestamp_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");
}
+2
View File
@@ -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")
+9 -4
View File
@@ -6,7 +6,7 @@ interface AchievementToastProps {
onClose: () => void;
onClick?: () => void;
title?: string;
variant?: "error" | "update";
variant?: "error" | "update" | "steam";
}
export function AchievementToast({
@@ -43,6 +43,11 @@ export function AchievementToast({
</svg>
);
}
if (variant === "steam") {
return (
<img src="/images/steam.png" alt="Steam" className="w-8 h-8 object-contain" style={{ imageRendering: "pixelated", filter: "brightness(0) invert(1)" }} />
);
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -72,9 +77,9 @@ export function AchievementToast({
onClick={
onClick
? () => {
onClick();
onClose();
}
onClick();
onClose();
}
: undefined
}
className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`}
+3 -3
View File
@@ -8,10 +8,10 @@ interface PanoramaProps {
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
const { isWindowVisible } = useUI();
const PANORAMA_PROFILES = ['legacy_evolved', '360revived'];
const profileId = PANORAMA_PROFILES.includes(profile) ? profile : 'legacy_evolved';
const PANORAMA_PROFILES = ['legacy_evolved', 'vanilla_tu19', '360revived', 'vanilla_tu24'];
const baseId = profile;
const profileId = PANORAMA_PROFILES.includes(baseId) ? baseId : 'vanilla_tu19';
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? 'Day' : 'Night'}.png`;
const [bgWidth, setBgWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
+113 -28
View File
@@ -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>
)}
+15 -5
View File
@@ -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>
))}
+185 -329
View File
@@ -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%",
+9 -6
View File
@@ -26,15 +26,14 @@ interface UIContextType {
onNavigateToMenu: () => void;
connected: boolean;
updateMessage: string | null;
updateUrl: string | null;
clearUpdateMessage: () => void;
}
const UIContext = createContext<UIContextType | undefined>(undefined);
export const ConfigContext = createContext<ReturnType<typeof useAppConfig> | undefined>(undefined);
export const AudioContext = createContext<ReturnType<typeof useAudioController> | undefined>(undefined);
export const GameContext = createContext<ReturnType<typeof useGameManager> | undefined>(undefined);
export const SkinContext = createContext<ReturnType<typeof useSkinSync> | undefined>(undefined);
export function LauncherProvider({ children }: { children: React.ReactNode }) {
const [showIntro, setShowIntro] = useState(true);
const [logoAnimDone, setLogoAnimDone] = useState(false);
@@ -44,7 +43,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
const [showCredits, setShowCredits] = useState(false);
const [focusSection, setFocusSection] = useState<"menu" | "skin">("menu");
const { updateMessage, clearUpdateMessage } = useUpdateCheck();
const { updateMessage, updateUrl, clearUpdateMessage } = useUpdateCheck();
const configRaw = useAppConfig();
const gameRaw = useGameManager({
@@ -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}>
+9 -1
View File
@@ -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
View File
@@ -1,12 +1,27 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { TauriService } from "../services/TauriService";
import { getCurrentWindow } from "@tauri-apps/api/window";
async function imageUrlToBase64(url: string): Promise<string> {
const response = await fetch(url);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
const BASE_EDITIONS = [
{
id: "legacy_evolved", //neo: yes. we're not changing the ID. that will break user data.
id: "legacy_evolved",
name: "neoLegacy",
desc: "Backporting newer title updates and Minigames back to LCE",
url: "https://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,
};
}
+42 -8
View File
@@ -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
View File
@@ -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
+18 -1
View File
@@ -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: []
}
};
}
}
+9
View File
@@ -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 });
}
}