Add basic singleplayer support to launcher - Needs to make it so updating doesn't wipe player saves before release

This commit is contained in:
dginovker 2023-10-30 06:28:57 +09:00
parent 97254662d7
commit f0e08d2628
11 changed files with 546 additions and 11 deletions

View file

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=Singleplayer/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -20,7 +20,7 @@ namespace Saradomin.Infrastructure.Services
public string ClientHashURL => "https://gitlab.com/2009scape/rt4-client/-/jobs/artifacts/master/raw/client/build/libs/rt4-client.jar.sha256?job=build";
public string PreferredTargetFilePath =>
CrossPlatform.Locate2009scapeExecutable(_settingsService.Launcher.InstallationDirectory);
CrossPlatform.Locate2009scapeExecutable();
public event EventHandler<float> DownloadProgressChanged;

View file

@ -0,0 +1,12 @@
using System;
using System.Threading.Tasks;
using Glitonea.Mvvm;
namespace Saradomin.Infrastructure.Services
{
public interface ISingleplayerUpdateService : IService
{
event EventHandler<Tuple<float, bool>> SingleplayerDownloadProgressChanged;
Task DownloadSingleplayer();
}
}

View file

@ -4,6 +4,7 @@ using System.IO.Compression;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Saradomin.Model.Settings.Launcher;
using Saradomin.Utilities;
namespace Saradomin.Infrastructure.Services
@ -59,7 +60,8 @@ namespace Saradomin.Infrastructure.Services
if (Path.GetExtension(downloadUrl) == ".zip")
{
string tempDir = Path.Combine(Path.GetTempPath(), "jre11_temp");
// Don't use /tmp because Directory.Move doesn't work cross-partition
string tempDir = Path.Combine(CrossPlatform.LocateDefault2009scapeHome(), "jre11_temp");
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
await Task.Run(() => ZipFile.ExtractToDirectory(downloadPath, tempDir));
Directory.Move(Directory.GetDirectories(tempDir)[0], extractedPath);

View file

@ -0,0 +1,74 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Threading.Tasks;
using Saradomin.Utilities;
namespace Saradomin.Infrastructure.Services
{
public class SingleplayerUpdateService : ISingleplayerUpdateService
{
public event EventHandler<Tuple<float, bool>> SingleplayerDownloadProgressChanged;
public async Task DownloadSingleplayer()
{
SingleplayerDownloadProgressChanged?.Invoke(this, new Tuple<float, bool>(0f, false));
string downloadUrl =
"https://gitlab.com/2009scape/singleplayer/windows/-/archive/master/windows-master.zip";
string downloadPath = Path.Combine(
CrossPlatform.LocateDefault2009scapeHome(),
"singleplayer" + Path.GetExtension(downloadUrl)
);
using (HttpClient httpClient = new HttpClient())
{
var response = await httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead);
var contentLength = response.Content.Headers.ContentLength ?? 40 * 1024 * 1024L;
var totalRead = 0L;
var buffer = new byte[8192];
// Create a FileStream to write the downloaded bytes to
await using (var fileStream =
new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await using (var stream = await response.Content.ReadAsStreamAsync())
{
int bytesRead;
do
{
bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
totalRead += bytesRead;
// Write the bytes to the FileStream
await fileStream.WriteAsync(buffer, 0, bytesRead);
var progress = (float)totalRead / contentLength;
SingleplayerDownloadProgressChanged?.Invoke(this,
new Tuple<float, bool>(progress, false));
} while (bytesRead > 0);
}
}
}
SingleplayerDownloadProgressChanged?.Invoke(this, new Tuple<float, bool>(1f, false));
if (Directory.Exists(CrossPlatform.LocateSingleplayerHome())) Directory.Delete(CrossPlatform.LocateSingleplayerHome(), true);
// Don't use /tmp because Directory.Move doesn't work cross-partition
string tempDir = Path.Combine(CrossPlatform.LocateDefault2009scapeHome(), "singleplayer_temp");
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
await Task.Run(() => ZipFile.ExtractToDirectory(downloadPath, tempDir));
foreach (string directory in Directory.GetDirectories(tempDir))
{
Console.WriteLine("Found directory " + directory + " in " + tempDir);
}
Directory.Move(Directory.GetDirectories(tempDir)[0], CrossPlatform.LocateSingleplayerHome());
Directory.Delete(tempDir, true);
File.Delete(downloadPath);
SingleplayerDownloadProgressChanged?.Invoke(this, new Tuple<float, bool>(1f, true));
}
}
}

View file

@ -66,6 +66,10 @@
<DependentUpon>NotificationBox.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\Controls\SingleplayerView.axaml.cs">
<DependentUpon>MainWindow.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\Controls\SettingsView.axaml.cs">
<DependentUpon>MainWindow.axaml</DependentUpon>
<SubType>Code</SubType>
@ -77,6 +81,10 @@
<Compile Update="View\Controls\PluginManagerView.axaml.cs">
<DependentUpon>PluginManagerView.axaml</DependentUpon>
</Compile>
<Compile Update="View\Controls\SingleplayerView.axaml.cs">
<DependentUpon>SingleplayerView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<ItemGroup>

View file

@ -3,6 +3,7 @@ using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Win32;
using Mono.Unix;
@ -206,11 +207,31 @@ namespace Saradomin.Utilities
);
}
}
public static string Locate2009scapeExecutable(string baseDirectory)
public static string LocateSingleplayerHome()
{
baseDirectory ??= LocateDefault2009scapeHome();
return Path.Combine(baseDirectory, "2009scape.jar");
return Path.Combine(LocateDefault2009scapeHome(), "singleplayer");
}
public static string LocateSingleplayerExecutable()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return Path.Combine(LocateSingleplayerHome(), "launch.bat");
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return Path.Combine(LocateSingleplayerHome(), "launch.sh");
}
throw new InvalidOperationException("Unsupported OS for singleplayer: " +
RuntimeInformation.OSArchitecture);
}
public static string Locate2009scapeExecutable()
{
return Path.Combine(LocateDefault2009scapeHome(), "2009scape.jar");
}
public static string LocateServerProfilesPath(string baseDirectory)
@ -219,11 +240,10 @@ namespace Saradomin.Utilities
return Path.Combine(baseDirectory, "server_profiles.json");
}
public static string RunCommandAndGetOutput(string command)
public static string RunCommandAndGetOutput(string command, Action<string> onOutputReceived = null, Action<string> onErrorReceived = null)
{
Process process = new Process();
StringBuilder output = new StringBuilder();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
process.StartInfo = new ProcessStartInfo("cmd.exe", "/c " + command)
@ -250,12 +270,16 @@ namespace Saradomin.Utilities
process.OutputDataReceived += (_, e) =>
{
if (e.Data != null) output.AppendLine(e.Data);
if (e.Data == null) return;
output.AppendLine(e.Data);
onOutputReceived?.Invoke(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null) output.AppendLine(e.Data);
if (e.Data == null) return;
output.AppendLine(e.Data);
onErrorReceived?.Invoke(e.Data);
};
process.Start();

View file

@ -0,0 +1,236 @@
<UserControl x:Class="Saradomin.View.Controls.SingleplayerView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:Saradomin.View.Controls"
xmlns:converters="clr-namespace:Glitonea.Mvvm.Converters;assembly=Glitonea"
xmlns:glitonea="clr-namespace:Glitonea;assembly=Glitonea"
xmlns:mvvm="clr-namespace:Glitonea.Mvvm;assembly=Glitonea"
xmlns:vm="clr-namespace:Saradomin.ViewModel.Controls"
DataContext="{mvvm:DataContextSource vm:SingleplayerViewModel}">
<Grid Margin="0,0,2,2"
ColumnDefinitions="400,*">
<DockPanel Grid.Column="0">
<HeaderedContentControl Classes="GroupBox"
DockPanel.Dock="Top"
Header="singleplayer logs"
IsVisible="{Binding IsRunning}"
>
<ScrollViewer HorizontalAlignment="Left"
ClipToBounds="True"
Height="300"
Width="390"
Content="{Binding SingleplayerLogsTextBox}"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Visible" />
</HeaderedContentControl>
<HeaderedContentControl Classes="GroupBox"
DockPanel.Dock="Top"
Header="singleplayer info"
IsVisible="{Binding !IsRunning}"
>
<StackPanel Orientation="Vertical">
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="Singleplayer is provided by the 2009scape team" />
<TextBlock
Margin="0,0,4,8"
Foreground="{StaticResource DarkForegroundBrush}"
Text="for advanced users only." />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="2009scape official servers have free membership," />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="multiple exp rates, and are hosted 24/7." />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="In addition, your player save file" />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="is always exportable from the multiplayer server" />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="if you ever choose" />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="to switch to singleplayer - However," />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="we will not import your singleplayer save" />
<TextBlock
Margin="0,0,4,8"
Foreground="{StaticResource DarkForegroundBrush}"
Text="file into the multiplayer world." />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="It is our strong recommendation" />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="to play the multiplayer server instead." />
<TextBlock
Margin="0,0,4,0"
Foreground="{StaticResource DarkForegroundBrush}"
Text="If you have any questions," />
<TextBlock
Margin="0,0,4,8"
Foreground="{StaticResource DarkForegroundBrush}"
Text="feel free to ask on our Discord or Forums." />
</StackPanel>
</HeaderedContentControl>
<HeaderedContentControl Classes="GroupBox"
DockPanel.Dock="Top"
Header="singleplayer links">
<StackPanel Orientation="Vertical">
<Button
Margin="0,0,0,0"
Width="140"
Height="25"
BorderBrush="{StaticResource DarkBorderBrush}"
BorderThickness="1"
Classes="OutsideNavigator"
Command="{Binding LaunchFaq}"
Opacity="1"
FontSize="11"
Content="singleplayer faq"
ZIndex="9999" />
<Button
Margin="0,0,0,0"
Width="140"
Height="25"
BorderBrush="{StaticResource DarkBorderBrush}"
BorderThickness="1"
Classes="OutsideNavigator"
Command="{Binding LaunchForums}"
Opacity="1"
FontSize="11"
Content="support forums"
ZIndex="9999" />
</StackPanel>
</HeaderedContentControl>
</DockPanel>
<DockPanel Grid.Column="1">
<HeaderedContentControl Classes="GroupBox"
DockPanel.Dock="Top"
Header="singleplayer launch">
<StackPanel Orientation="Vertical">
<Button
Margin="0,0,0,0"
Width="140"
Height="25"
BorderBrush="{StaticResource DarkBorderBrush}"
BorderThickness="1"
Classes="OutsideNavigator"
Command="{Binding DownloadSingleplayer}"
Opacity="1"
FontSize="11"
Content="{Binding SingleplayerDownloadText}"
ZIndex="9999"
IsEnabled="{Binding CanDownload}" />
<Button
Margin="0,0,0,0"
Width="140"
Height="25"
BorderBrush="{StaticResource DarkBorderBrush}"
BorderThickness="1"
Classes="OutsideNavigator"
Command="{Binding LaunchSingleplayer}"
Opacity="1"
FontSize="11"
Content="Start Singleplayer"
ZIndex="9999"
IsEnabled="{Binding CanLaunch}" />
<Grid Margin="0,2,0,4"
ColumnDefinitions="*,Auto"
RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0"
Grid.ColumnSpan="2"
Margin="3,0,0,2"
Foreground="{StaticResource DarkForegroundBrush}"
Text="2009scape installation directory" />
<!--
Don't let the user edit it directly to avoid creating a ton of directories,
since we get a notification every time the textbox is updated.
-->
<TextBox Grid.Row="1"
Grid.Column="0"
Width="300"
Margin="2,0,0,0"
HorizontalAlignment="Left"
Classes="NormalTextBox"
IsReadOnly="True"
Text="{Binding Launcher.InstallationDirectory, Mode=TwoWay}"
Watermark="Enter a path to your preferred installation directory..." />
<Button Grid.Row="1"
Grid.Column="1"
Width="22"
Height="24"
Margin="2,0,0,0"
Classes="OutsideNavigator"
Command="{Binding BrowseForInstallationDirectory}"
Content="..."
ToolTip.Tip="Browse..." />
</Grid>
</StackPanel>
</HeaderedContentControl>
<HeaderedContentControl Margin="2,2,0,0"
Classes="GroupBox"
DockPanel.Dock="Top"
Header="singleplayer config">
<HeaderedContentControl.HeaderTemplate>
<DataTemplate>
<TextBlock HorizontalAlignment="Center"
Text="{Binding Header}" />
</DataTemplate>
</HeaderedContentControl.HeaderTemplate>
<StackPanel>
<TextBlock HorizontalAlignment="Center"
Foreground="{StaticResource DarkForegroundBrush}"
Text="Not configurable via launcher for now. Edit config file manually." />
<CheckBox Content="Place 'X' button on the left"
IsEnabled="False"
IsChecked="{Binding Launcher.PlaceCloseButtonOnLeft, Mode=TwoWay}" />
<CheckBox Content="Close the launcher after executing the client"
IsEnabled="False"
IsChecked="{Binding Launcher.ExitAfterLaunchingClient, Mode=TwoWay}" />
<CheckBox Content="Check for client updates on launch"
IsEnabled="False"
IsChecked="{Binding Launcher.CheckForClientUpdatesOnLaunch, Mode=TwoWay}" />
<CheckBox Content="Check for updated server profiles on launch"
IsEnabled="False"
IsChecked="{Binding Launcher.CheckForServerProfilesOnLaunch, Mode=TwoWay}" />
<CheckBox Content="Allow multiple client instances to run"
IsEnabled="False"
IsChecked="{Binding Launcher.AllowMultiboxing, Mode=TwoWay}" />
</StackPanel>
</HeaderedContentControl>
</DockPanel>
</Grid>
</UserControl>

View file

@ -0,0 +1,20 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PropertyChanged;
namespace Saradomin.View.Controls
{
[DoNotNotify]
public class SingleplayerView : UserControl
{
public SingleplayerView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}

View file

@ -111,7 +111,7 @@
<TabItem Width="95"
HorizontalContentAlignment="Center"
CornerRadius="0,0,8,0"
CornerRadius="0,0,0,0"
Header="settings">
<Border Margin="4,0,4,0"
Background="{StaticResource MediumBackgroundBrush}"
@ -125,6 +125,23 @@
</ScrollViewer>
</Border>
</TabItem>
<TabItem Width="95"
HorizontalContentAlignment="Center"
CornerRadius="0,0,8,0"
Header="singleplayer">
<Border Margin="4,0,4,0"
Background="{StaticResource MediumBackgroundBrush}"
BorderBrush="{StaticResource SemiLightMediumBorderBrush}"
BorderThickness="1,1,1,0"
CornerRadius="6,6,0,0">
<ScrollViewer Classes="MainViewScrollViewer"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Visible">
<controls:SingleplayerView VerticalAlignment="Stretch" />
</ScrollViewer>
</Border>
</TabItem>
</TabControl>
<Border Grid.Row="2"

View file

@ -0,0 +1,140 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Glitonea.Extensions;
using Glitonea.Mvvm;
using Glitonea.Mvvm.Messaging;
using Saradomin.Infrastructure.Messaging;
using Saradomin.Infrastructure.Services;
using Saradomin.Model.Settings.Launcher;
using Saradomin.Utilities;
using Saradomin.View.Windows;
namespace Saradomin.ViewModel.Controls
{
public class SingleplayerViewModel : ViewModelBase
{
private readonly ISingleplayerUpdateService _singleplayerUpdateService;
private readonly ISettingsService _settingsService;
public string SingleplayerDownloadText { get; private set; } = "Download Singleplayer";
public bool CanDownload { get; private set; } = true;
public bool CanLaunch => File.Exists(CrossPlatform.LocateSingleplayerExecutable());
public bool IsRunning {get ; private set; }
public TextBox SingleplayerLogsTextBox { get; private set; }
public LauncherSettings Launcher => _settingsService.Launcher;
public SingleplayerViewModel(ISettingsService settingsService, ISingleplayerUpdateService iSingleplayerUpdateService)
{
_settingsService = settingsService;
Message.Subscribe<MainViewLoadedMessage>(this, OnMainViewLoaded);
_singleplayerUpdateService = iSingleplayerUpdateService;
_singleplayerUpdateService.SingleplayerDownloadProgressChanged += OnSingleplayerDownloadProgressChanged;
SingleplayerLogsTextBox = new TextBox
{
Foreground = new SolidColorBrush(Colors.Black),
FontSize = 12,
Margin = new Thickness(8, 0, 0, 0),
IsReadOnly = true,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
};
}
private void OnSingleplayerDownloadProgressChanged(object sender, Tuple<float, bool> e)
{
float progress = e.Item1;
bool finished = e.Item2;
CanDownload = finished;
if (finished)
{
SingleplayerDownloadText = "Download Singleplayer";
return;
}
if (progress >= 1f)
{
SingleplayerDownloadText = "Extracting...";
return;
}
SingleplayerDownloadText = $"Downloading... {progress * 100:F2}%";
}
private void OnMainViewLoaded(MainViewLoadedMessage _)
{
Message.Subscribe<SettingsModifiedMessage>(this, OnSettingsModified);
}
private void OnSettingsModified(SettingsModifiedMessage _)
{
Console.WriteLine("Saving settings");
_settingsService.SaveAll();
}
public void DownloadSingleplayer()
{
_singleplayerUpdateService.DownloadSingleplayer();
Console.WriteLine("Download singleplayer button clicked");
}
public void PrintLog(string message)
{
Dispatcher.UIThread.Post(() =>
{
SingleplayerLogsTextBox.Text += message + Environment.NewLine;
}, DispatcherPriority.Background);
}
public void LaunchSingleplayer()
{
IsRunning = true;
new Task(() => CrossPlatform.RunCommandAndGetOutput(CrossPlatform.LocateSingleplayerExecutable(), PrintLog, PrintLog)).Start();
}
private void LaunchFaq()
{
CrossPlatform.LaunchURL("https://2009scape.org/site/game_guide/singleplayer.html");
}
private void LaunchForums()
{
CrossPlatform.LaunchURL("https://forum.2009scape.org/viewforum.php?f=8-support");
}
private async Task BrowseForInstallationDirectory()
{
var ofd = new OpenFolderDialog
{
Title = "Browse for Installation Directory...",
Directory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
};
var path = await ofd.ShowAsync(Application.Current.GetMainWindow());
if (path != null)
{
if (CrossPlatform.IsDirectoryWritable(path))
{
Launcher.InstallationDirectory = path;
}
else
{
NotificationBox.DisplayNotification(
"Access denied",
"The location you have selected is not writable. Select the one you have permissions for.",
Application.Current.GetMainWindow()
);
}
}
}
}
}