feat(gui): launcher

This commit is contained in:
capyb2222 committed 2026-10-17 20:42:15 -07:00
1 parent f21415486c
commit 679b041b09
33 files changed
+4934 -17

No files matched your search

+4
View File
@@ -11,3 +11,7 @@ src/net/cmd_ids.cpp
*.user
tools/
tests/
data/*.bak
data/*.tmp
data/*.broken
.tools/
+28 -11
View File
@@ -1,6 +1,6 @@
# CapySR - WIP
A server reimplementation written in C++ for a certain anime game with star and rails. Targets the `4.6.5x beta` (usually the latest). This server is in WIP, but you can still use it.
A server reimplementation written in C++ for a certain anime game with star and rails. Targets the `4.7 beta v3` (`4.6.53`, usually the latest beta). This server is in WIP, but a lot is implemented (mostly combat stuff).
![Screenshot](aha.png)
@@ -18,10 +18,12 @@ A server reimplementation written in C++ for a certain anime game with star and
| Challenge modes | 🟢 | MoC / PF / AS playable |
| Anomaly Arbitration | 🔴 | |
| Battle flow | 🟡 | Core combat loop works, Battle College missing |
| Techniques | 🟢 | Overworld effects, enemy debuffs, technique points |
| Shop | 🟡 | Buying works, roll shop missing |
| Friend system | 🟡 | List and display work, applying/deleting missing |
| Quest system | 🟡 | Quest list shows, rewards can't be claimed |
| Characters and gear | 🟡 | Built from srtools, in-game leveling/promotion/eidolons/upgrades missing |
| Characters and gear | 🟡 | Built in the launcher or srtools, in-game leveling/promotion/eidolons/upgrades missing |
| Launcher | 🟢 | Start/stop the server, play, console, builds and battle editor |
| Mail | 🔴 | Mail list only, no attachments or deleting |
| Items | 🔴 | Using, selling and composing items missing |
| Currency Wars | 🔴 | Not planned for implementation soon |
@@ -31,35 +33,49 @@ A server reimplementation written in C++ for a certain anime game with star and
Don't want to build it yourself? You can grab the prebuilt from [Releases](https://github.com/capyb2222/CapySR/releases), and skip to [Running it](#running-it).
## Requirements
## Requirements (C++ tools are heavy, and is complicated to set up so I recommend you to use prebuilt)
1. Visual Studio 2026 Community (or the smaller Build Tools): https://visualstudio.microsoft.com/downloads/
In the installer, tick "Desktop development with C++". That brings the compiler, the Windows SDK, CMake and Ninja; keep "C++ CMake tools for Windows" ticked, which it is by default.
2. Python 3: https://www.python.org/downloads/windows/
Tick "Add python.exe to PATH" during install.
3. protoc: https://github.com/protocolbuffers/protobuf/releases
Download protoc-<version>-win64.zip, unzip it, and add its bin folder to PATH. Pick the newest normal release, not an -rc one.
4. The Python protobuf package, which is required. tools/gen_proto.py imports google.protobuf, so the build fails without it. Install it with: `py -m pip install protobuf`
That's the only thing you need to install yourself, for others, `build.ps1` can get them for you.
## Build
```powershell
.\build.ps1 # to build
.\build.ps1 # to build the server and the launcher
```
Then find the outputs at `build\bin\`
Then find the outputs at `build\bin\` (`capysr.exe` and `CapyLauncher.exe`).
## Running it
Supports both `CN` and `OS` client. You can get the latest client here: [CN](https://gofile.io/d/cZx9oACJ) or [OS](https://gofile.io/d/7iY18uM4). Recommends you use 7z to extract.
### With CapyLauncher
1. Open `CapyLauncher.exe`.
2. Press **Browse** under Game and pick `StarRail.exe` inside your client folder.
3. Press **Play**. It starts the server, copies `launcher.exe` and `hkrpg.dll` into your client folder if needed (Thank you Reversed Rooms), then starts the game. Accept the admin prompt.
4. Log in with any account name and password.
Closing the launcher stops the server and saves your progress. Use **Fresh start** if you want to start over.
### Without the launcher
1. Copy `launcher.exe` and `hkrpg.dll` from launcher folder inside CapySR and paste them inside your client folder (Thank you Reversed Rooms)
2. Start the server: `build\bin\capysr.exe` (`capysr.exe` if you use a prebuilt).
3. Run the `launcher.exe` **as administrator**.
4. Log in with any account name and password; the SDK routes accept anything.
4. Log in with any account name and password.
Delete `data/player.json` to start over from the defaults in `config/config.json` if needed.
## Builds
You can edit your characters in the **Builds** tab of the launcher.
Press **Save** when you're done, then relog in game if you're already in. The supported game version is shown at the top of the Builds tab.
## srtools
The server also supports <https://srtools.neonteam.dev/>!
@@ -67,6 +83,7 @@ The server also supports <https://srtools.neonteam.dev/>!
## Credit
- capyb2222
- Various other server for references.
- [StarRailRes](https://github.com/Mar-7th/StarRailRes) for the icons in the launcher.
## Troubleshooting
- You can create an issue in this repository, but it is not guaranteed I will be there to help.
+120 -1
View File
@@ -26,13 +26,130 @@ if (-not (Get-Command cl.exe -ErrorAction SilentlyContinue)) {
if (-not (Get-Command cl.exe -ErrorAction SilentlyContinue)) { throw "vcvars64 did not put cl.exe on PATH" }
}
$tools = Join-Path $root ".tools"
function Get-File($url, $out, $sha) {
Write-Host "downloading $url"
$ProgressPreference = "SilentlyContinue"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $out
if (-not $sha) { return }
$stream = [IO.File]::OpenRead($out)
try { $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($stream)) -replace "-", "" }
finally { $stream.Dispose() }
if ($hash -ne $sha) {
Remove-Item -Force $out
throw "$url does not match its checksum"
}
}
function Expand-Zip($zip, $dir) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
if (Test-Path $dir) { Remove-Item -Recurse -Force $dir }
[IO.Compression.ZipFile]::ExtractToDirectory($zip, $dir)
Remove-Item -Force $zip
}
function Find-Python {
$ErrorActionPreference = "Continue"
foreach ($candidate in @("py -3", "python")) {
$exe, $prefix = $candidate -split " ", 2
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { continue }
$prefix = @($prefix | Where-Object { $_ })
$found = & $exe @prefix -c "import sys; print(sys.executable if sys.version_info >= (3, 8) else '')" 2>$null
if ($LASTEXITCODE -eq 0 -and $found) { return "$found".Trim() }
}
return $null
}
function Get-Python {
$portable = Join-Path $tools "python\python.exe"
if (Test-Path $portable) { return $portable }
$found = Find-Python
if ($found) { return $found }
Write-Host "Python not found, setting up a portable copy in .tools" -ForegroundColor Yellow
$dir = Join-Path $tools "python"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$zip = Join-Path $tools "python.zip"
Get-File "https://www.python.org/ftp/python/3.12.10/python-3.12.10-embed-amd64.zip" $zip "4ACBED6DD1C744B0376E3B1CF57CE906F9DC9E95E68824584C8099A63025A3C3"
Expand-Zip $zip $dir
Get-ChildItem $dir -Filter "python*._pth" | ForEach-Object { Add-Content -LiteralPath $_.FullName "import site" }
$getPip = Join-Path $tools "get-pip.py"
Get-File "https://bootstrap.pypa.io/get-pip.py" $getPip $null
$ErrorActionPreference = "Continue"
& $portable $getPip -q --no-warn-script-location --disable-pip-version-check
if ($LASTEXITCODE -ne 0) { throw "could not set up pip for the portable Python" }
Remove-Item -Force $getPip
return $portable
}
function Initialize-Protobuf($python) {
$ErrorActionPreference = "Continue"
& $python -c "import google.protobuf" 2>$null
if ($LASTEXITCODE -eq 0) { return }
Write-Host "installing the protobuf Python package" -ForegroundColor Yellow
& $python -m pip install -q --disable-pip-version-check --no-warn-script-location protobuf
if ($LASTEXITCODE -ne 0) { & $python -m pip install -q --disable-pip-version-check --no-warn-script-location --user protobuf }
if ($LASTEXITCODE -ne 0) { throw "could not install the protobuf Python package" }
}
function Get-Protoc {
$command = Get-Command protoc -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$portable = Join-Path $tools "protoc\bin\protoc.exe"
if (-not (Test-Path $portable)) {
Write-Host "protoc not found, downloading it into .tools" -ForegroundColor Yellow
New-Item -ItemType Directory -Force -Path $tools | Out-Null
$zip = Join-Path $tools "protoc.zip"
Get-File "https://github.com/protocolbuffers/protobuf/releases/download/v33.0/protoc-33.0-win64.zip" $zip "3742CD49C8B6BD78B6760540367EB0FF62FA70A1032E15DAFE131BFAF296986A"
Expand-Zip $zip (Join-Path $tools "protoc")
}
return $portable
}
function Initialize-Dotnet {
$env:DOTNET_NOLOGO = "1"
$env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false"
if (Get-Command dotnet -ErrorAction SilentlyContinue) { return }
$dir = Join-Path $tools "dotnet"
if (-not (Test-Path (Join-Path $dir "dotnet.exe"))) {
Write-Host ".NET SDK not found, installing it into .tools" -ForegroundColor Yellow
New-Item -ItemType Directory -Force -Path $tools | Out-Null
$script = Join-Path $tools "dotnet-install.ps1"
Get-File "https://dot.net/v1/dotnet-install.ps1" $script $null
& powershell -NoProfile -ExecutionPolicy Bypass -File $script -Channel LTS -InstallDir $dir -NoPath
Remove-Item -Force $script
if (-not (Test-Path (Join-Path $dir "dotnet.exe"))) { throw "could not install the .NET SDK" }
}
$env:DOTNET_ROOT = $dir
$env:PATH = "$dir;$env:PATH"
}
$python = Get-Python
Initialize-Protobuf $python
$protoc = Get-Protoc
New-Item -ItemType Directory -Force -Path $build | Out-Null
cmake -S "$root" -B "$build" -G Ninja "-DCMAKE_BUILD_TYPE=$Config"
cmake -S "$root" -B "$build" -G Ninja "-DCMAKE_BUILD_TYPE=$Config" "-DPython3_EXECUTABLE=$python" "-DPROTOC_EXECUTABLE=$protoc"
if ($LASTEXITCODE -ne 0) { throw "cmake configure failed" }
cmake --build $build --parallel
if ($LASTEXITCODE -ne 0) { throw "build failed" }
$launcher = $true
try {
Initialize-Dotnet
} catch {
if ($Package) { throw }
Write-Host "$($_.Exception.Message), skipping the launcher" -ForegroundColor Yellow
$launcher = $false
}
if ($launcher) {
& cmd /c "`"$(Join-Path $root 'gui\build.bat')`""
if ($LASTEXITCODE -ne 0) { throw "launcher build failed" }
}
if ($Test) {
& (Join-Path $build "bin\capysr_tests.exe")
if ($LASTEXITCODE -ne 0) { throw "tests failed" }
@@ -43,6 +160,7 @@ if ($Package) {
if (Test-Path $dist) { Remove-Item -Recurse -Force $dist }
New-Item -ItemType Directory -Force -Path (Join-Path $dist "data") | Out-Null
Copy-Item (Join-Path $build "bin\capysr.exe") $dist
Copy-Item (Join-Path $build "bin\CapyLauncher.exe") $dist
foreach ($dir in "config", "resources", "launcher") {
Copy-Item -Recurse (Join-Path $root $dir) (Join-Path $dist $dir)
}
@@ -54,3 +172,4 @@ if ($Package) {
}
Write-Host "built $build\bin\capysr.exe" -ForegroundColor Green
if ($launcher) { Write-Host "built $build\bin\CapyLauncher.exe" -ForegroundColor Green }
+2
View File
@@ -0,0 +1,2 @@
bin/
obj/
+704
View File
@@ -0,0 +1,704 @@
<Application x:Class="CapyLauncher.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<Color x:Key="BgColor">#17131F</Color>
<Color x:Key="RaisedColor">#2C2440</Color>
<Color x:Key="RaisedHoverColor">#3A2F55</Color>
<Color x:Key="LineColor">#352B4A</Color>
<Color x:Key="PinkColor">#FF9AA2</Color>
<SolidColorBrush x:Key="Bg" Color="#17131F"/>
<SolidColorBrush x:Key="Panel" Color="#D91E1929"/>
<SolidColorBrush x:Key="Field" Color="#130F1A"/>
<SolidColorBrush x:Key="Raised" Color="#2C2440"/>
<SolidColorBrush x:Key="RaisedHover" Color="#3A2F55"/>
<SolidColorBrush x:Key="Line" Color="#352B4A"/>
<SolidColorBrush x:Key="SoftLine" Color="#2A2239"/>
<SolidColorBrush x:Key="Text" Color="#ECE3DA"/>
<SolidColorBrush x:Key="Dim" Color="#9A8FA8"/>
<SolidColorBrush x:Key="Faint" Color="#6B6078"/>
<SolidColorBrush x:Key="Pink" Color="#FF9AA2"/>
<SolidColorBrush x:Key="Purple" Color="#D689FF"/>
<SolidColorBrush x:Key="Blue" Color="#7DC8FF"/>
<SolidColorBrush x:Key="Ok" Color="#9FE0A0"/>
<SolidColorBrush x:Key="Fail" Color="#FF6B7F"/>
<SolidColorBrush x:Key="Warn" Color="#FFD58A"/>
<LinearGradientBrush x:Key="Capy" StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#FFCB94" Offset="0"/>
<GradientStop Color="#FF9AA2" Offset="0.35"/>
<GradientStop Color="#D689FF" Offset="0.7"/>
<GradientStop Color="#7DC8FF" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="Danger" StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#FF8A8A" Offset="0"/>
<GradientStop Color="#FF6B9A" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="CardSheen" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#14FFFFFF" Offset="0"/>
<GradientStop Color="#00FFFFFF" Offset="0.35"/>
</LinearGradientBrush>
<FontFamily x:Key="Icons">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
<FontFamily x:Key="Mono">Cascadia Mono, Consolas</FontFamily>
<CubicEase x:Key="Out" EasingMode="EaseOut"/>
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Panel}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="16"/>
<Setter Property="Padding" Value="20"/>
</Style>
<Style x:Key="Caption" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Icons}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="Padding" Value="14,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" BorderBrush="{StaticResource Line}" BorderThickness="1" CornerRadius="9"
Padding="{TemplateBinding Padding}" RenderTransformOrigin="0.5,0.5">
<Border.Background>
<SolidColorBrush Color="{StaticResource RaisedColor}"/>
</Border.Background>
<Border.RenderTransform>
<ScaleTransform/>
</Border.RenderTransform>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color"
To="{StaticResource RaisedHoverColor}" Duration="0:0:0.16"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color"
Duration="0:0:0.24"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="RenderTransform.ScaleX" To="0.97" Duration="0:0:0.08"/>
<DoubleAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="RenderTransform.ScaleY" To="0.97" Duration="0:0:0.08"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="RenderTransform.ScaleX" Duration="0:0:0.18" EasingFunction="{StaticResource Out}"/>
<DoubleAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="RenderTransform.ScaleY" Duration="0:0:0.18" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="B" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Primary" TargetType="Button">
<Setter Property="Foreground" Value="#17131F"/>
<Setter Property="Background" Value="{StaticResource Capy}"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="Padding" Value="16,13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid x:Name="Root" RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<TransformGroup>
<ScaleTransform/>
<TranslateTransform/>
</TransformGroup>
</Grid.RenderTransform>
<Border x:Name="Glow" Background="{TemplateBinding Background}" CornerRadius="12" Opacity="0.35" Margin="6,8,6,-2">
<Border.Effect>
<BlurEffect Radius="18"/>
</Border.Effect>
</Border>
<Border x:Name="B" Background="{TemplateBinding Background}" CornerRadius="12" Padding="{TemplateBinding Padding}">
<Grid>
<Border CornerRadius="12" Margin="-16,-13" Background="{StaticResource CardSheen}"/>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Glow" Storyboard.TargetProperty="Opacity" To="0.75" Duration="0:0:0.22"/>
<DoubleAnimation Storyboard.TargetName="Root" Storyboard.TargetProperty="RenderTransform.Children[1].Y" To="-2" Duration="0:0:0.22" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Glow" Storyboard.TargetProperty="Opacity" Duration="0:0:0.3"/>
<DoubleAnimation Storyboard.TargetName="Root" Storyboard.TargetProperty="RenderTransform.Children[1].Y" Duration="0:0:0.3" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Root" Storyboard.TargetProperty="RenderTransform.Children[0].ScaleX" To="0.975" Duration="0:0:0.08"/>
<DoubleAnimation Storyboard.TargetName="Root" Storyboard.TargetProperty="RenderTransform.Children[0].ScaleY" To="0.975" Duration="0:0:0.08"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Root" Storyboard.TargetProperty="RenderTransform.Children[0].ScaleX" Duration="0:0:0.2" EasingFunction="{StaticResource Out}"/>
<DoubleAnimation Storyboard.TargetName="Root" Storyboard.TargetProperty="RenderTransform.Children[0].ScaleY" Duration="0:0:0.2" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.35"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Chrome" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="34"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="WindowChrome.IsHitTestVisibleInChrome" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" CornerRadius="8">
<Border.Background>
<SolidColorBrush Color="#00FFFFFF"/>
</Border.Background>
<TextBlock x:Name="T" Text="{TemplateBinding Content}" FontFamily="{StaticResource Icons}" FontSize="10"
Foreground="{TemplateBinding Foreground}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" To="#1FFFFFFF" Duration="0:0:0.12"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
<Setter TargetName="T" Property="Foreground" Value="{StaticResource Text}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ChromeClose" TargetType="Button" BasedOn="{StaticResource Chrome}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" CornerRadius="8">
<Border.Background>
<SolidColorBrush Color="#00E8556A"/>
</Border.Background>
<TextBlock x:Name="T" Text="{TemplateBinding Content}" FontFamily="{StaticResource Icons}" FontSize="10"
Foreground="{TemplateBinding Foreground}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" To="#FFE8556A" Duration="0:0:0.12"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
<Setter TargetName="T" Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Tile" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Margin" Value="4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" CornerRadius="12" BorderThickness="1" Padding="6,9,6,10" RenderTransformOrigin="0.5,0.5">
<Border.Background>
<SolidColorBrush Color="#0DFFFFFF"/>
</Border.Background>
<Border.BorderBrush>
<SolidColorBrush Color="{StaticResource LineColor}"/>
</Border.BorderBrush>
<Border.RenderTransform>
<TranslateTransform/>
</Border.RenderTransform>
<StackPanel>
<TextBlock x:Name="Icon" Text="{TemplateBinding Tag}" Style="{StaticResource Glyph}" FontSize="17"
Foreground="{StaticResource Pink}"/>
<ContentPresenter Margin="0,5,0,0" HorizontalAlignment="Center" TextBlock.FontSize="12"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" To="#1FFFFFFF" Duration="0:0:0.16"/>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="BorderBrush.Color" To="{StaticResource PinkColor}" Duration="0:0:0.16"/>
<DoubleAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="RenderTransform.Y" To="-2" Duration="0:0:0.2" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" Duration="0:0:0.25"/>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="BorderBrush.Color" Duration="0:0:0.25"/>
<DoubleAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="RenderTransform.Y" Duration="0:0:0.25" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="B" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Chip" TargetType="ToggleButton">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="FontSize" Value="11.5"/>
<Setter Property="Margin" Value="0,0,6,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="B" CornerRadius="11" BorderThickness="1" Padding="10,3">
<Border.Background>
<SolidColorBrush Color="#00FFFFFF"/>
</Border.Background>
<Border.BorderBrush>
<SolidColorBrush Color="{StaticResource LineColor}"/>
</Border.BorderBrush>
<StackPanel Orientation="Horizontal">
<Ellipse x:Name="Dot" Width="6" Height="6" Fill="{TemplateBinding Tag}" Margin="0,0,6,0" Opacity="0.4"/>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" To="#2C2440" Duration="0:0:0.16"/>
<DoubleAnimation Storyboard.TargetName="Dot" Storyboard.TargetProperty="Opacity" To="1" Duration="0:0:0.16"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="B" Storyboard.TargetProperty="Background.Color" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetName="Dot" Storyboard.TargetProperty="Opacity" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
<Setter Property="Foreground" Value="{StaticResource Text}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="BorderBrush" Value="{StaticResource Pink}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TextBox">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="Background" Value="{StaticResource Field}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="CaretBrush" Value="{StaticResource Pink}"/>
<Setter Property="SelectionBrush" Value="{StaticResource Pink}"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="B" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="9">
<Grid>
<TextBlock x:Name="Hint" Text="{TemplateBinding Tag}" Foreground="{StaticResource Faint}"
Margin="{TemplateBinding Padding}" Padding="2,0,0,0" VerticalAlignment="Center"
IsHitTestVisible="False" Visibility="Collapsed"/>
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="Text" Value="">
<Setter TargetName="Hint" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="B" Property="BorderBrush" Value="{StaticResource Pink}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="Track" Width="30" Height="17" CornerRadius="8.5" Background="{StaticResource Field}"
BorderBrush="{StaticResource Line}" BorderThickness="1" VerticalAlignment="Center">
<Ellipse x:Name="Knob" Width="11" Height="11" Fill="{StaticResource Dim}" HorizontalAlignment="Left" Margin="2,0,0,0">
<Ellipse.RenderTransform>
<TranslateTransform/>
</Ellipse.RenderTransform>
</Ellipse>
</Border>
<ContentPresenter Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Knob" Storyboard.TargetProperty="RenderTransform.X" To="13" Duration="0:0:0.18" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Knob" Storyboard.TargetProperty="RenderTransform.X" Duration="0:0:0.18" EasingFunction="{StaticResource Out}"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
<Setter TargetName="Track" Property="Background" Value="{StaticResource Pink}"/>
<Setter TargetName="Track" Property="BorderBrush" Value="{StaticResource Pink}"/>
<Setter TargetName="Knob" Property="Fill" Value="#17131F"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="ThumbLook" TargetType="Thumb">
<Border Background="#463865" CornerRadius="3" Margin="3"/>
</ControlTemplate>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Width" Value="10"/>
<Setter Property="MinWidth" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb Template="{StaticResource ThumbLook}"/>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb Template="{StaticResource ThumbLook}"/>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="LogItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="B" Background="Transparent" CornerRadius="5" Padding="6,1.5">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="#0FFFFFFF"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="B" Property="Background" Value="#22FF9AA2"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SliderThumb" TargetType="Thumb">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Grid Width="18" Height="18" Background="Transparent">
<Ellipse x:Name="Halo" Fill="#33FF9AA2" RenderTransformOrigin="0.5,0.5">
<Ellipse.RenderTransform><ScaleTransform ScaleX="0.6" ScaleY="0.6"/></Ellipse.RenderTransform>
</Ellipse>
<Ellipse Width="12" Height="12" Fill="#ECE3DA" Stroke="{StaticResource Pink}" StrokeThickness="2"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Halo" Storyboard.TargetProperty="RenderTransform.ScaleX" To="1.25" Duration="0:0:0.15"/>
<DoubleAnimation Storyboard.TargetName="Halo" Storyboard.TargetProperty="RenderTransform.ScaleY" To="1.25" Duration="0:0:0.15"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Halo" Storyboard.TargetProperty="RenderTransform.ScaleX" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetName="Halo" Storyboard.TargetProperty="RenderTransform.ScaleY" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="FlatRepeat" TargetType="RepeatButton">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Slider">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="IsMoveToPointEnabled" Value="True"/>
<Setter Property="IsSnapToTickEnabled" Value="True"/>
<Setter Property="TickFrequency" Value="1"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Slider">
<Grid Height="20">
<Border Height="4" CornerRadius="2" Background="{StaticResource Raised}" VerticalAlignment="Center"/>
<Track x:Name="PART_Track">
<Track.DecreaseRepeatButton>
<RepeatButton Command="Slider.DecreaseLarge" Focusable="False" Height="4" VerticalAlignment="Center">
<RepeatButton.Template>
<ControlTemplate TargetType="RepeatButton">
<Border Height="4" CornerRadius="2" Background="{StaticResource Capy}"/>
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Command="Slider.IncreaseLarge" Style="{StaticResource FlatRepeat}"/>
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource SliderThumb}"/>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Seg" TargetType="RadioButton">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="MinWidth" Value="34"/>
<Setter Property="Height" Value="30"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="Margin" Value="0,0,5,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Grid>
<Border x:Name="B" CornerRadius="8" BorderThickness="1" BorderBrush="{StaticResource Line}" Background="#0DFFFFFF"/>
<Border x:Name="Fill" CornerRadius="8" Background="{StaticResource Capy}" Opacity="0"/>
<ContentPresenter Margin="{TemplateBinding Padding}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="BorderBrush" Value="{StaticResource Pink}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Fill" Storyboard.TargetProperty="Opacity" To="1" Duration="0:0:0.18"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Fill" Storyboard.TargetProperty="Opacity" Duration="0:0:0.18"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
<Setter Property="Foreground" Value="#17131F"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="NavTab" TargetType="RadioButton">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="WindowChrome.IsHitTestVisibleInChrome" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border Background="Transparent" Padding="16,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{TemplateBinding Tag}" FontFamily="{StaticResource Icons}" FontSize="13" Margin="0,1,8,0" VerticalAlignment="Center"/>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PickItem" TargetType="ListBoxItem">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="B" Background="Transparent" CornerRadius="8" Padding="10,7" Margin="0,1">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="{StaticResource Raised}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="B" Property="Background" Value="{StaticResource RaisedHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Application.Resources>
</Application>
+8
View File
@@ -0,0 +1,8 @@
using System.Windows;
namespace CapyLauncher
{
public partial class App : Application
{
}
}
+23
View File
@@ -0,0 +1,23 @@
<UserControl x:Class="CapyLauncher.BattlePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Border Style="{StaticResource Card}" Padding="0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Padding="24,20,24,16" BorderBrush="{StaticResource SoftLine}" BorderThickness="0,0,0,1">
<Grid>
<StackPanel>
<TextBlock Text="Custom battle" FontSize="22" FontWeight="Bold" FontFamily="Segoe UI Variable Display, Segoe UI"/>
<TextBlock x:Name="SourceHint" Foreground="{StaticResource Dim}" Margin="0,4,0,0" TextWrapping="Wrap" MaxWidth="720" HorizontalAlignment="Left"/>
</StackPanel>
</Grid>
</Border>
<ScrollViewer x:Name="Scroll" Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,18,18,80">
<StackPanel x:Name="Stack"/>
</ScrollViewer>
</Grid>
</Border>
</UserControl>
+241
View File
@@ -0,0 +1,241 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
namespace CapyLauncher
{
public partial class BattlePage : UserControl
{
static readonly string[] Types = { "DEFAULT", "MOC", "PF", "AS", "SU", "AA" };
static readonly string[] TypeNames = { "Normal", "Memory of Chaos", "Pure Fiction", "Apocalyptic Shadow", "Simulated Universe", "Anomaly Arbitration" };
Catalog catalog;
BuildFile file;
Action changed;
List<PickItem> monsters, stages, buffs, subs;
public BattlePage()
{
InitializeComponent();
}
public void Init(Catalog catalog, BuildFile file, Action changed, string battleSource)
{
this.catalog = catalog;
this.file = file;
this.changed = changed;
monsters = catalog.Monsters.Select(m => new PickItem { Id = m.Id, Title = m.Name, Sub = m.Extra + " #" + m.Id, Accent = RankBrush(m.Extra), Tag = m }).ToList();
stages = catalog.Stages.Select(s => new PickItem { Id = s.Id, Title = s.Name, Sub = s.Extra + " #" + s.Id, Accent = Ui.Brush("Blue"), Tag = s }).ToList();
buffs = catalog.Buffs.Select(b => new PickItem { Id = b.Id, Title = b.Name, Sub = "#" + b.Id, Accent = Ui.Brush("Purple"), Tag = b }).ToList();
subs = catalog.SubAffixes.Select(a => new PickItem { Id = a.Id, Title = a.Property, Sub = a.Format(a.Base) + " per roll", Accent = Ui.Brush("Ok"), Tag = a, IconPath = Icons.Property(a.Prop) }).ToList();
SourceHint.Text = battleSource == "stage"
? "Your config has battle_source set to \"stage\", so fights use the game's own enemies and this setup is ignored."
: battleSource == "srtools"
? "Every fight uses this setup (battle_source is \"srtools\"), as long as it has a stage and at least one wave."
: "Overworld fights use this setup (battle_source is \"auto\"; calyxes keep their own enemies), as long as it has a stage and at least one wave.";
Draw(true);
}
public void Reload() => Draw(false);
static System.Windows.Media.Brush RankBrush(string rank) =>
Ui.Brush(rank == "Boss" ? "Fail" : rank == "Mini-boss" ? "Warn" : rank == "Elite" ? "Purple" : "Faint");
Dictionary<string, object> Battle => file.Battle;
List<List<object>> Waves()
{
var waves = BuildFile.List(Battle, "monsters");
for (int i = 0; i < waves.Count; i++)
{
if (!(waves[i] is List<object>))
waves[i] = waves[i] is System.Collections.IEnumerable items ? items.Cast<object>().ToList() : new List<object>();
}
return waves.Cast<List<object>>().ToList();
}
void Edit(Action action, bool redraw = true)
{
action();
file.Touch();
changed();
if (redraw)
Draw(false);
}
void Draw(bool fresh)
{
double offset = Scroll.VerticalOffset;
Stack.Children.Clear();
Stack.Children.Add(SetupSection());
Stack.Children.Add(WavesSection());
Stack.Children.Add(BlessingSection());
Stack.Children.Add(StatsSection());
Stack.Children.Add(LineupSection());
if (fresh)
{
Scroll.ScrollToTop();
Ui.FadeIn(Stack);
}
else
{
Scroll.UpdateLayout();
Scroll.ScrollToVerticalOffset(offset);
}
}
UIElement SetupSection()
{
var body = new StackPanel();
string type = BuildFile.Str(Battle, "battle_type", "DEFAULT");
body.Children.Add(Ui.Row((Ui.Text("Mode", "Dim", 12.5), Ui.Px(130)),
(Ui.Segments(TypeNames, Math.Max(0, Array.IndexOf(Types, type)), v => Edit(() => Battle["battle_type"] = Types[v])), Ui.Star())));
int stageId = BuildFile.Int(Battle, "stage_id");
var picker = new Picker("Pick a stage") { Items = stages };
var known = stages.FirstOrDefault(s => s.Id == stageId);
if (stageId != 0)
picker.Show(known ?? new PickItem { Title = "Stage " + stageId, Sub = "#" + stageId });
picker.Picked += item => Edit(() => Battle["stage_id"] = item.Id, false);
body.Children.Add(Ui.Row((Ui.Text("Stage", "Dim", 12.5), Ui.Px(130)), (picker, Ui.Star())));
body.Children.Add(Ui.Row((Ui.Text("Cycle limit", "Dim", 12.5), Ui.Px(130)),
(Ui.Stepper(0, 99, BuildFile.Int(Battle, "cycle_count"), v => Edit(() => Battle["cycle_count"] = v, false)), Ui.Auto),
(Ui.Text(" 0 keeps the stage's own limit", "Faint", 12), Ui.Star())));
if (type == "SU")
{
body.Children.Add(Ui.Row((Ui.Text("Path resonance", "Dim", 12.5), Ui.Px(130)),
(Ui.Stepper(0, 999999, BuildFile.Int(Battle, "path_resonance_id"), v => Edit(() => Battle["path_resonance_id"] = v, false), 140), Ui.Auto)));
}
return Ui.Section("Setup", body, "");
}
UIElement WavesSection()
{
var body = new StackPanel();
var waves = Waves();
for (int w = 0; w < waves.Count; w++)
{
int waveIndex = w;
var wave = waves[w];
var card = new StackPanel();
var head = new DockPanel { Margin = new Thickness(0, 0, 0, 10) };
var drop = Ui.Button("Remove wave", (s, e) => Edit(() => BuildFile.List(Battle, "monsters").RemoveAt(waveIndex)), "", size: 11.5);
DockPanel.SetDock(drop, Dock.Right);
head.Children.Add(drop);
head.Children.Add(Ui.Text("Wave " + (w + 1), "Text", 14));
card.Children.Add(head);
foreach (var entry in wave.OfType<Dictionary<string, object>>().ToList())
{
var monster = entry;
int id = BuildFile.Int(monster, "monster_id");
var info = catalog.Monsters.FirstOrDefault(m => m.Id == id);
var name = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
var title = new StackPanel { Orientation = Orientation.Horizontal };
title.Children.Add(new Border { Width = 8, Height = 8, CornerRadius = new CornerRadius(4), Background = RankBrush(info?.Extra), Margin = new Thickness(0, 0, 8, 0), VerticalAlignment = VerticalAlignment.Center });
title.Children.Add(Ui.Text(info?.Name ?? "Unknown enemy", "Text", 13));
name.Children.Add(title);
name.Children.Add(Ui.Text((info?.Extra ?? "") + " #" + id, "Faint", 11.5));
var level = Ui.Stepper(1, 100, BuildFile.Int(monster, "level", 1), v => Edit(() => monster["level"] = v, false), 92);
var amount = Ui.Stepper(1, 5, BuildFile.Int(monster, "amount", 1), v => Edit(() => monster["amount"] = v, false), 84);
var remove = Ui.Button("", (s, e) => Edit(() => wave.Remove(monster)), "", size: 10);
remove.Padding = new Thickness(8, 5, 8, 5);
card.Children.Add(Ui.Row((name, Ui.Star()),
(Ui.Text("Lv", "Faint", 11.5), Ui.Px(22)), (level, Ui.Auto), (null, Ui.Px(12)),
(Ui.Text("×", "Faint", 12), Ui.Px(14)), (amount, Ui.Auto), (null, Ui.Px(10)), (remove, Ui.Auto)));
}
var add = new Picker("Add an enemy…") { Items = monsters };
add.Picked += item => Edit(() => wave.Add(new Dictionary<string, object> { ["monster_id"] = item.Id, ["amount"] = 1, ["level"] = 95 }));
card.Children.Add(add);
body.Children.Add(new Border { Background = Ui.Brush("Field"), CornerRadius = new CornerRadius(12), Padding = new Thickness(14), Margin = new Thickness(0, 0, 0, 10), Child = card });
}
var addWave = Ui.Button("Add wave", (s, e) => Edit(() => BuildFile.List(Battle, "monsters").Add(new List<object>())), "");
addWave.HorizontalAlignment = HorizontalAlignment.Left;
body.Children.Add(addWave);
return Ui.Section("Enemies", body, "");
}
UIElement BlessingSection()
{
var body = new StackPanel();
var list = BuildFile.List(Battle, "blessings");
foreach (var entry in list.OfType<Dictionary<string, object>>().ToList())
{
var blessing = entry;
int id = BuildFile.Int(blessing, "id");
var info = catalog.Buffs.FirstOrDefault(b => b.Id == id);
var name = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
name.Children.Add(Ui.Text(info?.Name ?? "Buff " + id, "Text", 13));
name.Children.Add(Ui.Text("#" + id, "Faint", 11.5));
var level = Ui.Stepper(1, 20, BuildFile.Int(blessing, "level", 1), v => Edit(() => blessing["level"] = v, false), 92);
var remove = Ui.Button("", (s, e) => Edit(() => list.Remove(blessing)), "", size: 10);
remove.Padding = new Thickness(8, 5, 8, 5);
body.Children.Add(Ui.Row((name, Ui.Star()), (Ui.Text("Lv", "Faint", 11.5), Ui.Px(22)), (level, Ui.Auto), (null, Ui.Px(10)), (remove, Ui.Auto)));
}
var add = new Picker("Add a blessing or buff…") { Items = buffs };
add.Picked += item => Edit(() => list.Add(new Dictionary<string, object> { ["level"] = 1, ["id"] = item.Id }));
body.Children.Add(add);
return Ui.Section("Blessings", body, "");
}
UIElement StatsSection()
{
var body = new StackPanel();
body.Children.Add(Ui.Text("Added to every character in the fight, on top of their relics.", "Faint", 12));
body.Children.Add(new Border { Height = 10 });
var list = BuildFile.List(Battle, "custom_stats");
foreach (var entry in list.OfType<Dictionary<string, object>>().ToList())
{
var stat = entry;
int id = BuildFile.Int(stat, "sub_affix_id");
var affix = catalog.Sub(id);
var value = Ui.Text(affix == null ? "" : affix.Format(affix.Base * BuildFile.Int(stat, "count") + affix.Step * BuildFile.Int(stat, "step")), "Ok", 12.5);
var count = Ui.Stepper(0, 999, BuildFile.Int(stat, "count"), v => Edit(() => stat["count"] = v), 96);
var remove = Ui.Button("", (s, e) => Edit(() => list.Remove(stat)), "", size: 10);
remove.Padding = new Thickness(8, 5, 8, 5);
body.Children.Add(Ui.Row((Ui.Text(affix?.Property ?? "Stat " + id, "Text", 13), Ui.Star()), (value, Ui.Px(90)),
(Ui.Text("rolls", "Faint", 11.5), Ui.Px(36)), (count, Ui.Auto), (null, Ui.Px(10)), (remove, Ui.Auto)));
}
var add = new Picker("Add a stat…") { Items = subs };
add.Picked += item => Edit(() => list.Add(new Dictionary<string, object> { ["sub_affix_id"] = item.Id, ["count"] = 1, ["step"] = 0 }));
body.Children.Add(add);
return Ui.Section("Extra stats", body, "");
}
UIElement LineupSection()
{
var body = new StackPanel();
body.Children.Add(Ui.Text("Fight with this team instead of your current lineup. Leave all four empty to use your lineup.", "Faint", 12));
body.Children.Add(new Border { Height = 10 });
var lineup = BuildFile.Dict(Battle, "custom_battle_lineup");
var owned = catalog.Avatars.Where(a => file.Avatar(a.Id) != null).OrderBy(a => a.Name)
.Select(a => new PickItem { Id = a.Id, Title = a.Name, Sub = a.Element + " " + a.Path, Accent = Palette.Solid(a.Element), Tag = a, IconPath = Icons.Character(a.Id) }).ToList();
owned.Insert(0, new PickItem { Id = 0, Title = "Empty", Sub = "", Accent = Ui.Brush("Faint") });
var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 };
for (int slot = 0; slot < 4; slot++)
{
string key = slot.ToString();
int id = BuildFile.Int(lineup, key);
var picker = new Picker("Slot " + (slot + 1) + " empty") { Items = owned, Margin = new Thickness(0, 0, 10, 10) };
var who = catalog.Avatar(id);
if (who != null)
picker.Show(new PickItem { Title = who.Name, Sub = "Slot " + (slot + 1), IconPath = Icons.Character(who.Id) });
picker.Picked += item => Edit(() =>
{
var team = BuildFile.Dict(Battle, "custom_battle_lineup");
if (item.Id == 0) team.Remove(key);
else team[key] = item.Id;
if (team.Count == 0) Battle.Remove("custom_battle_lineup");
});
grid.Children.Add(picker);
}
body.Children.Add(grid);
if (lineup.Count == 0)
Battle.Remove("custom_battle_lineup");
return Ui.Section("Team", body, "");
}
}
}
+383
View File
@@ -0,0 +1,383 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace CapyLauncher
{
public class SubStat
{
public int Id;
public int Count = 1;
public int Step;
}
public class BuildFile
{
static readonly int[] PromotionCaps = { 20, 30, 40, 50, 60, 70 };
readonly JavaScriptSerializer serializer = new JavaScriptSerializer { MaxJsonLength = int.MaxValue, RecursionLimit = 256 };
Dictionary<string, object> root = new Dictionary<string, object>();
public string Path { get; }
public DateTime LoadedStamp { get; private set; }
public bool Dirty { get; set; }
public BuildFile(string path)
{
Path = path;
}
public void Load()
{
root = File.Exists(Path)
? serializer.DeserializeObject(File.ReadAllText(Path)) as Dictionary<string, object> ?? new Dictionary<string, object>()
: new Dictionary<string, object>();
if (root.TryGetValue("data", out object wrapped) && wrapped is Dictionary<string, object> inner && inner.ContainsKey("avatars"))
root = inner;
Dict(root, "avatars");
List(root, "relics");
List(root, "lightcones");
LoadedStamp = File.Exists(Path) ? File.GetLastWriteTimeUtc(Path) : DateTime.MinValue;
Dirty = false;
}
// ---- json tree helpers ----
public static Dictionary<string, object> Dict(Dictionary<string, object> parent, string key)
{
if (parent.TryGetValue(key, out object value) && value is Dictionary<string, object> dict)
return dict;
var fresh = new Dictionary<string, object>();
parent[key] = fresh;
return fresh;
}
public static List<object> List(Dictionary<string, object> parent, string key)
{
if (parent.TryGetValue(key, out object value) && value is List<object> list)
return list;
var fresh = value is IEnumerable items && !(value is string) ? items.Cast<object>().ToList() : new List<object>();
parent[key] = fresh;
return fresh;
}
public static int Int(Dictionary<string, object> d, string key, int fallback = 0) =>
d != null && d.TryGetValue(key, out object v) && v != null && !(v is string) && !(v is IEnumerable) ? Convert.ToInt32(v, CultureInfo.InvariantCulture) : fallback;
public static string Str(Dictionary<string, object> d, string key, string fallback = "") =>
d != null && d.TryGetValue(key, out object v) && v is string s ? s : fallback;
public static int PromotionFor(int level) => PromotionCaps.Count(cap => level > cap);
int NextUid(List<object> items) =>
items.OfType<Dictionary<string, object>>().Select(i => Int(i, "internal_uid")).DefaultIfEmpty(0).Max() + 1;
// ---- avatars ----
public Dictionary<string, object> Avatars => Dict(root, "avatars");
public Dictionary<string, object> Avatar(int id) =>
Avatars.TryGetValue(id.ToString(), out object value) ? value as Dictionary<string, object> : null;
public Dictionary<string, object> AddAvatar(AvatarInfo info)
{
var avatar = new Dictionary<string, object>
{
["avatar_id"] = info.Id,
["data"] = new Dictionary<string, object>
{
["rank"] = 0,
["skills"] = new Dictionary<string, object>(),
["skills_by_anchor_type"] = new Dictionary<string, object>(),
},
["level"] = 80,
["promotion"] = 6,
["sp_max"] = info.Sp,
["sp_value"] = info.Sp / 2,
["techniques"] = new List<object> { info.Id * 100 + 1 },
["enhanced_id"] = 0,
};
Avatars[info.Id.ToString()] = avatar;
foreach (Anchor anchor in info.Anchors)
SetAnchor(avatar, anchor, anchor.Max);
Dirty = true;
return avatar;
}
public void RemoveAvatar(int id)
{
Avatars.Remove(id.ToString());
foreach (var item in Relics.Concat(Lightcones).OfType<Dictionary<string, object>>())
{
if (Int(item, "equip_avatar") == id)
item["equip_avatar"] = 0;
}
Dirty = true;
}
public static int Rank(Dictionary<string, object> avatar) => Int(Dict(avatar, "data"), "rank");
public void SetRank(Dictionary<string, object> avatar, int rank)
{
Dict(avatar, "data")["rank"] = rank;
Dirty = true;
}
public void SetLevel(Dictionary<string, object> avatar, int level)
{
avatar["level"] = level;
avatar["promotion"] = PromotionFor(level);
Dirty = true;
}
public static int AnchorLevel(Dictionary<string, object> avatar, Anchor anchor) =>
Int(Dict(Dict(avatar, "data"), "skills_by_anchor_type"), anchor.Number.ToString());
public void SetAnchor(Dictionary<string, object> avatar, Anchor anchor, int level)
{
var data = Dict(avatar, "data");
Dict(data, "skills_by_anchor_type")[anchor.Number.ToString()] = level;
var skills = Dict(data, "skills");
foreach (int point in anchor.Points)
skills[point.ToString()] = level;
Dirty = true;
}
public void MaxAvatar(Dictionary<string, object> avatar, AvatarInfo info, bool eidolons)
{
SetLevel(avatar, 80);
if (eidolons)
SetRank(avatar, info.MaxRank);
foreach (Anchor anchor in info.Anchors)
SetAnchor(avatar, anchor, anchor.Max);
}
// ---- light cones ----
public List<object> Lightcones => List(root, "lightcones");
public Dictionary<string, object> LightconeOf(int avatarId) =>
Lightcones.OfType<Dictionary<string, object>>().FirstOrDefault(l => Int(l, "equip_avatar") == avatarId);
public Dictionary<string, object> EquipLightcone(int avatarId, int itemId)
{
var cone = LightconeOf(avatarId);
if (cone == null)
{
cone = new Dictionary<string, object>
{
["equip_avatar"] = avatarId,
["internal_uid"] = NextUid(Lightcones),
["item_id"] = itemId,
["level"] = 80,
["promotion"] = 6,
["rank"] = 1,
};
Lightcones.Add(cone);
}
cone["item_id"] = itemId;
Dirty = true;
return cone;
}
public void UnequipLightcone(int avatarId)
{
var cone = LightconeOf(avatarId);
if (cone != null)
Lightcones.Remove(cone);
Dirty = true;
}
public void SetLightconeLevel(Dictionary<string, object> cone, int level)
{
cone["level"] = level;
cone["promotion"] = PromotionFor(level);
Dirty = true;
}
// ---- relics ----
public List<object> Relics => List(root, "relics");
public static int Slot(Dictionary<string, object> relic) => Int(relic, "relic_id") % 10;
public Dictionary<string, object> RelicOf(int avatarId, int slot) =>
Relics.OfType<Dictionary<string, object>>().FirstOrDefault(r => Int(r, "equip_avatar") == avatarId && Slot(r) == slot);
public Dictionary<string, object> PutRelic(int avatarId, int slot, RelicSetInfo set, int mainAffix)
{
var relic = RelicOf(avatarId, slot);
if (relic == null)
{
relic = new Dictionary<string, object>
{
["equip_avatar"] = avatarId,
["internal_uid"] = NextUid(Relics),
["level"] = 15,
["sub_affixes"] = new List<object>(),
};
Relics.Add(relic);
}
relic["relic_id"] = set.Pieces[slot];
relic["relic_set_id"] = set.Id;
relic["main_affix_id"] = mainAffix;
Dirty = true;
return relic;
}
public void RemoveRelic(int avatarId, int slot)
{
var relic = RelicOf(avatarId, slot);
if (relic != null)
Relics.Remove(relic);
Dirty = true;
}
public static List<SubStat> Subs(Dictionary<string, object> relic) =>
List(relic, "sub_affixes").OfType<Dictionary<string, object>>()
.Select(s => new SubStat { Id = Int(s, "sub_affix_id"), Count = Int(s, "count", 1), Step = Int(s, "step") }).ToList();
public void SetSubs(Dictionary<string, object> relic, IEnumerable<SubStat> subs)
{
relic["sub_affixes"] = subs.Where(s => s.Id != 0).Select(s => (object)new Dictionary<string, object>
{
["sub_affix_id"] = s.Id,
["count"] = s.Count,
["step"] = s.Step,
}).ToList();
Dirty = true;
}
public void Touch() => Dirty = true;
public void Restamp() => LoadedStamp = File.Exists(Path) ? File.GetLastWriteTimeUtc(Path) : DateTime.MinValue;
// ---- battle ----
public Dictionary<string, object> Battle => Dict(root, "battle_config");
// ---- saving ----
public string ToJson()
{
var text = new StringBuilder();
Write(text, root, 0);
text.Append('\n');
return text.ToString();
}
static void Write(StringBuilder text, object value, int depth)
{
string pad = new string(' ', (depth + 1) * 2), close = new string(' ', depth * 2);
switch (value)
{
case null:
text.Append("null");
break;
case string s:
text.Append(new JavaScriptSerializer().Serialize(s));
break;
case bool b:
text.Append(b ? "true" : "false");
break;
case Dictionary<string, object> dict:
if (dict.Count == 0) { text.Append("{}"); break; }
text.Append("{\n");
int i = 0;
foreach (var pair in dict)
{
text.Append(pad).Append(new JavaScriptSerializer().Serialize(pair.Key)).Append(": ");
Write(text, pair.Value, depth + 1);
text.Append(++i < dict.Count ? ",\n" : "\n");
}
text.Append(close).Append('}');
break;
case IEnumerable list:
var items = list.Cast<object>().ToList();
if (items.Count == 0) { text.Append("[]"); break; }
bool flat = items.All(x => !(x is Dictionary<string, object>) && (x is string || !(x is IEnumerable)));
if (flat)
{
text.Append('[');
for (int j = 0; j < items.Count; j++)
{
if (j > 0) text.Append(", ");
Write(text, items[j], depth + 1);
}
text.Append(']');
break;
}
text.Append("[\n");
for (int j = 0; j < items.Count; j++)
{
text.Append(pad);
Write(text, items[j], depth + 1);
text.Append(j + 1 < items.Count ? ",\n" : "\n");
}
text.Append(close).Append(']');
break;
case double d:
text.Append(d.ToString("R", CultureInfo.InvariantCulture));
break;
case float f:
text.Append(f.ToString("R", CultureInfo.InvariantCulture));
break;
case IFormattable number:
text.Append(number.ToString(null, CultureInfo.InvariantCulture));
break;
default:
text.Append(new JavaScriptSerializer().Serialize(value));
break;
}
}
public void WriteToDisk()
{
string temp = Path + ".tmp";
File.WriteAllText(temp, ToJson(), new UTF8Encoding(false));
if (File.Exists(Path))
{
string backup = Path + ".bak";
File.Replace(temp, Path, backup, true);
}
else
{
File.Move(temp, Path);
}
LoadedStamp = File.GetLastWriteTimeUtc(Path);
Dirty = false;
}
public async Task<string> SendToServer(string dispatch)
{
string body = ToJson();
var request = (HttpWebRequest)WebRequest.Create(dispatch.TrimEnd('/') + "/srtools");
request.Method = "POST";
request.ContentType = "application/json";
request.Timeout = 10000;
byte[] bytes = Encoding.UTF8.GetBytes(body);
using (Stream stream = await request.GetRequestStreamAsync())
await stream.WriteAsync(bytes, 0, bytes.Length);
using (var response = (HttpWebResponse)await request.GetResponseAsync())
using (var reader = new StreamReader(response.GetResponseStream()))
{
var reply = serializer.DeserializeObject(await reader.ReadToEndAsync()) as Dictionary<string, object>;
string message = Str(reply, "message", "no reply");
if (message == "OK")
{
await Task.Delay(150);
LoadedStamp = File.Exists(Path) ? File.GetLastWriteTimeUtc(Path) : DateTime.MinValue;
Dirty = false;
}
return message;
}
}
}
}
+137
View File
@@ -0,0 +1,137 @@
<UserControl x:Class="CapyLauncher.BuildsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<DockPanel>
<Border x:Name="VersionBar" DockPanel.Dock="Top" Margin="0,0,0,14" CornerRadius="14" Padding="16,10" BorderThickness="1" BorderBrush="{StaticResource Line}">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#2A9FE0A0" Offset="0"/>
<GradientStop Color="#1A7DC8FF" Offset="0.5"/>
<GradientStop Color="#10D689FF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<DockPanel>
<Border DockPanel.Dock="Left" Width="30" Height="30" CornerRadius="9" Background="#339FE0A0" Margin="0,0,12,0">
<TextBlock Text="&#xE73E;" FontFamily="{StaticResource Icons}" FontSize="14" Foreground="{StaticResource Ok}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock x:Name="VersionCounts" DockPanel.Dock="Right" Foreground="{StaticResource Dim}" FontSize="12" VerticalAlignment="Center"/>
<StackPanel VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Supported version" FontSize="11" FontWeight="SemiBold" Foreground="{StaticResource Dim}" VerticalAlignment="Center"/>
<Border Background="#339FE0A0" CornerRadius="7" Padding="7,1" Margin="8,0,0,0">
<TextBlock x:Name="VersionTag" Text="—" FontSize="11" FontWeight="Bold" Foreground="{StaticResource Ok}"/>
</Border>
</StackPanel>
<TextBlock x:Name="VersionText" FontSize="14" FontWeight="SemiBold" Margin="0,2,0,0"/>
</StackPanel>
</DockPanel>
</Border>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="310"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Style="{StaticResource Card}" Padding="16">
<DockPanel>
<StackPanel DockPanel.Dock="Top">
<Grid Margin="2,0,0,10">
<TextBlock Style="{StaticResource Caption}" Text="CHARACTERS" VerticalAlignment="Center"/>
<TextBlock x:Name="OwnedCount" HorizontalAlignment="Right" Foreground="{StaticResource Faint}" FontSize="11"/>
</Grid>
<TextBox x:Name="SearchBox" Tag="Search characters" TextChanged="Filter_Changed"/>
<WrapPanel x:Name="ElementFilters" Margin="0,10,0,0"/>
<CheckBox x:Name="OwnedOnly" Content="Only my characters" Margin="2,6,0,10" Checked="Filter_Changed" Unchecked="Filter_Changed"/>
</StackPanel>
<UniformGrid DockPanel.Dock="Bottom" Columns="2" Margin="0,12,0,0">
<Button x:Name="MaxEveryoneButton" Margin="0,0,4,0" Padding="8,7" FontSize="12" Click="MaxEveryone_Click" ToolTip="Level 80 and every trace maxed, for everyone you have">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74A;" FontSize="12" Margin="0,1,7,0"/>
<TextBlock Text="Max everyone"/>
</StackPanel>
</Button>
<Button x:Name="E6S5Button" Margin="4,0,0,0" Padding="8,7" FontSize="12" Click="E6S5_Click" ToolTip="Every character E6, every light cone S5">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE734;" FontSize="12" Margin="0,1,7,0"/>
<TextBlock Text="E6 S5 all"/>
</StackPanel>
</Button>
</UniformGrid>
<ListBox x:Name="CharList" Background="Transparent" BorderThickness="0" ItemContainerStyle="{StaticResource PickItem}"
VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.ScrollUnit="Pixel"
ScrollViewer.HorizontalScrollBarVisibility="Disabled" SelectionChanged="CharList_SelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Opacity="{Binding Opacity}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid Width="40" Height="40">
<Border CornerRadius="12" Background="{Binding Badge}">
<TextBlock Text="{Binding Initials}" Foreground="#17131F" FontWeight="Bold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="{Binding InitialsVisibility}"/>
</Border>
<Border CornerRadius="12" ClipToBounds="True">
<Border.OpacityMask>
<VisualBrush>
<VisualBrush.Visual>
<Border Width="40" Height="40" CornerRadius="12" Background="White"/>
</VisualBrush.Visual>
</VisualBrush>
</Border.OpacityMask>
<Image Source="{Binding Icon}" Stretch="UniformToFill" RenderOptions.BitmapScalingMode="HighQuality"/>
</Border>
<Image Source="{Binding ElementIcon}" Width="17" Height="17" HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="0,0,-4,-4" RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
<StackPanel Grid.Column="1" Margin="11,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis" FontSize="13"/>
<TextBlock Text="{Binding Sub}" Foreground="{StaticResource Dim}" FontSize="11.5" Margin="0,1,0,0" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding Stars}" Foreground="{Binding StarBrush}" FontSize="11" VerticalAlignment="Center" Margin="6,0,2,0"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource Card}" Padding="0">
<Grid>
<Grid x:Name="Editor" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Padding="24,20,24,16" BorderBrush="{StaticResource SoftLine}" BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ContentControl x:Name="HeaderBadge"/>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock x:Name="HeaderName" FontSize="22" FontWeight="Bold" FontFamily="Segoe UI Variable Display, Segoe UI" TextTrimming="CharacterEllipsis"/>
<WrapPanel x:Name="HeaderChips" Margin="0,7,0,0"/>
</StackPanel>
<StackPanel x:Name="HeaderActions" Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"/>
</Grid>
</Border>
<ScrollViewer x:Name="EditorScroll" Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,18,18,80">
<StackPanel x:Name="EditorStack"/>
</ScrollViewer>
</Grid>
<StackPanel x:Name="NoPick" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="&#xE77B;" FontFamily="{StaticResource Icons}" FontSize="38" Foreground="{StaticResource Pink}" HorizontalAlignment="Center"/>
<TextBlock Text="Pick a character" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,14,0,0"/>
<TextBlock x:Name="NoPickText" Text="Their level, eidolons, traces, light cone and relics show up here." Foreground="{StaticResource Dim}" HorizontalAlignment="Center" Margin="0,4,0,0"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</DockPanel>
</UserControl>
+573
View File
@@ -0,0 +1,573 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
namespace CapyLauncher
{
public class CharRow : INotifyPropertyChanged
{
ImageSource icon, elementIcon;
bool requested;
public AvatarInfo Info { get; set; }
public Visibility InitialsVisibility => icon == null ? Visibility.Visible : Visibility.Hidden;
public ImageSource Icon
{
get { Request(); return icon; }
}
public ImageSource ElementIcon
{
get { Request(); return elementIcon; }
}
async void Request()
{
if (requested) return;
requested = true;
elementIcon = await Icons.Get(Icons.Element(Info.Element));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ElementIcon)));
icon = await Icons.Get(Icons.Character(Info.Id));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(InitialsVisibility)));
}
public string Name => Info.Name;
public string Initials => Info.Initials;
public Brush Badge { get; set; }
public string Stars => new string('★', Math.Max(0, Info.Rarity));
public Brush StarBrush => Palette.Rarity(Info.Rarity);
public string Sub { get; private set; }
public double Opacity { get; private set; }
public event PropertyChangedEventHandler PropertyChanged;
public void Refresh(BuildFile file)
{
var avatar = file.Avatar(Info.Id);
Sub = avatar == null ? "Not added · " + Info.Element : "E" + BuildFile.Rank(avatar) + " · Lv " + BuildFile.Int(avatar, "level", 1) + " · " + Info.Path;
Opacity = avatar == null ? 0.5 : 1;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Sub)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Opacity)));
}
}
public partial class BuildsPage : UserControl
{
static readonly string[] Elements = { "Physical", "Fire", "Ice", "Lightning", "Wind", "Quantum", "Imaginary" };
static readonly string[] SlotNames = { "", "Head", "Hands", "Body", "Feet", "Planar Sphere", "Link Rope" };
Catalog catalog;
BuildFile file;
Action changed;
Func<string, string, string, bool, System.Threading.Tasks.Task<bool>> ask;
readonly List<CharRow> rows = new List<CharRow>();
string element;
AvatarInfo current;
public BuildsPage()
{
InitializeComponent();
}
public void Init(Catalog catalog, BuildFile file, Action changed, Func<string, string, string, bool, System.Threading.Tasks.Task<bool>> ask)
{
this.catalog = catalog;
this.file = file;
this.changed = changed;
this.ask = ask;
string version = string.IsNullOrEmpty(catalog.Version) ? "Unknown" : catalog.Version;
VersionTag.Text = version.Contains("v3") ? "v3" : version;
VersionText.Text = "Honkai: Star Rail " + version;
VersionCounts.Text = catalog.Avatars.Count + " characters · " + catalog.Lightcones.Count + " light cones · "
+ catalog.RelicSets.Count + " relic sets · " + catalog.Monsters.Count.ToString("#,0") + " enemies";
rows.Clear();
foreach (AvatarInfo info in catalog.Avatars.OrderByDescending(a => a.Rarity).ThenBy(a => a.Name))
{
var row = new CharRow { Info = info, Badge = Palette.Element(info.Element) };
row.Refresh(file);
rows.Add(row);
}
BuildFilters();
Filter();
if (current != null)
Show(current);
}
void Redraw()
{
double offset = EditorScroll.VerticalOffset;
Show(current, false);
EditorScroll.UpdateLayout();
EditorScroll.ScrollToVerticalOffset(offset);
}
public void Reload()
{
foreach (CharRow row in rows)
row.Refresh(file);
Filter();
if (current != null)
Redraw();
}
void Changed()
{
changed();
rows.FirstOrDefault(r => r.Info == current)?.Refresh(file);
OwnedCount.Text = file.Avatars.Count + " of " + rows.Count;
}
// ---- list ----
void BuildFilters()
{
ElementFilters.Children.Clear();
string group = "elements";
var all = new RadioButton { Content = "All", GroupName = group, Style = (Style)FindResource("Seg"), IsChecked = element == null, Height = 26, MinWidth = 30, FontSize = 11.5, Margin = new Thickness(0, 0, 4, 4), Padding = new Thickness(0) };
all.Checked += (s, e) => { element = null; Filter(); };
ElementFilters.Children.Add(all);
foreach (string name in Elements)
{
var dot = Icons.View(Icons.Element(name), 18, new Border { Width = 10, Height = 10, CornerRadius = new CornerRadius(5), Background = Palette.Element(name) });
var option = new RadioButton { Content = dot, GroupName = group, Style = (Style)FindResource("Seg"), MinWidth = 26, Width = 28, Height = 26, Padding = new Thickness(0), ToolTip = name, IsChecked = element == name, Margin = new Thickness(0, 0, 4, 4) };
option.Checked += (s, e) => { element = name; Filter(); };
ElementFilters.Children.Add(option);
}
}
void Filter_Changed(object sender, RoutedEventArgs e) => Filter();
void Filter()
{
if (file == null)
return;
string query = SearchBox.Text.Trim();
var shown = rows.Where(r => (element == null || r.Info.Element == element)
&& (OwnedOnly.IsChecked != true || file.Avatar(r.Info.Id) != null)
&& (query.Length == 0 || r.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0
|| r.Info.Path.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0
|| r.Info.Id.ToString().StartsWith(query)))
.ToList();
CharRow selected = CharList.SelectedItem as CharRow;
CharList.ItemsSource = shown;
if (selected != null && shown.Contains(selected))
CharList.SelectedItem = selected;
OwnedCount.Text = file.Avatars.Count + " of " + rows.Count;
}
void CharList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (CharList.SelectedItem is CharRow row && row.Info != current)
Show(row.Info);
}
// ---- editor ----
void Show(AvatarInfo info, bool fresh = true)
{
current = info;
NoPick.Visibility = Visibility.Collapsed;
Editor.Visibility = Visibility.Visible;
var avatar = file.Avatar(info.Id);
HeaderBadge.Content = Ui.Portrait(info, 64);
HeaderName.Text = info.Name;
HeaderChips.Children.Clear();
HeaderChips.Children.Add(Ui.Chip(info.Element, Palette.Solid(info.Element), Icons.Element(info.Element)));
HeaderChips.Children.Add(Ui.Chip(info.Path, Ui.Brush("Pink"), Icons.Path(info.Path)));
HeaderChips.Children.Add(Ui.Chip(new string('★', info.Rarity), Palette.Rarity(info.Rarity)));
HeaderChips.Children.Add(Ui.Chip("#" + info.Id, Ui.Brush("Faint")));
HeaderActions.Children.Clear();
EditorStack.Children.Clear();
if (avatar == null)
{
var add = Ui.Button("Add to my characters", (s, e) => { file.AddAvatar(info); Changed(); Redraw(); }, "", primary: true);
HeaderActions.Children.Add(add);
var empty = new StackPanel { Margin = new Thickness(0, 60, 0, 0), HorizontalAlignment = HorizontalAlignment.Center };
empty.Children.Add(Ui.Text(info.Name + " isn't in your build yet.", "Dim", 14));
empty.Children.Add(Ui.Text("Adding them gives level 80, E0 and every trace maxed.", "Faint", 12.5));
EditorStack.Children.Add(empty);
if (fresh) Ui.FadeIn(EditorStack);
return;
}
HeaderActions.Children.Add(Ui.Button("Max out", (s, e) => { file.MaxAvatar(avatar, info, false); Changed(); Redraw(); }, ""));
var remove = Ui.Button("", async (s, e) =>
{
if (await ask("Remove " + info.Name + "?", "They leave your build, and their light cone and relics go back to the bag.", "Remove", true))
{
file.RemoveAvatar(info.Id);
Changed();
Redraw();
}
}, "");
remove.Margin = new Thickness(8, 0, 0, 0);
remove.ToolTip = "Remove from my characters";
HeaderActions.Children.Add(remove);
EditorStack.Children.Add(StatsSection(info, avatar));
EditorStack.Children.Add(CharacterSection(info, avatar));
EditorStack.Children.Add(TraceSection(info, avatar));
EditorStack.Children.Add(LightconeSection(info));
EditorStack.Children.Add(RelicSection(info));
if (fresh)
{
EditorScroll.ScrollToTop();
Dispatcher.BeginInvoke(new Action(() => EditorScroll.ScrollToTop()), System.Windows.Threading.DispatcherPriority.Loaded);
Ui.FadeIn(EditorStack);
}
}
UIElement StatsSection(AvatarInfo info, Dictionary<string, object> avatar)
{
var grid = new UniformGrid { Columns = 2 };
foreach (StatLine line in StatSheet.Build(catalog, file, info, avatar))
{
var row = new DockPanel { Margin = new Thickness(0, 0, 4, 0) };
var statIcon = Icons.View(line.Icon, 22);
statIcon.Margin = new Thickness(0, 0, 10, 0);
DockPanel.SetDock(statIcon, Dock.Left);
row.Children.Add(statIcon);
var values = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
values.Children.Add(new TextBlock { Text = line.Show(line.Total), FontWeight = FontWeights.SemiBold, FontSize = 14, VerticalAlignment = VerticalAlignment.Center });
if (line.Base > 0 && line.Bonus > 0 && !line.Percent)
{
values.Children.Add(new TextBlock { Text = " " + line.Show(line.Base), Foreground = Ui.Brush("Faint"), FontSize = 11.5, VerticalAlignment = VerticalAlignment.Center });
values.Children.Add(new TextBlock { Text = " +" + line.Show(line.Bonus), Foreground = Ui.Brush("Ok"), FontSize = 11.5, VerticalAlignment = VerticalAlignment.Center });
}
DockPanel.SetDock(values, Dock.Right);
row.Children.Add(values);
row.Children.Add(Ui.Text(line.Label, "Dim", 12.5));
grid.Children.Add(new Border { BorderBrush = Ui.Brush("SoftLine"), BorderThickness = new Thickness(0, 0, 0, 1), Padding = new Thickness(0, 8, 0, 8), Margin = new Thickness(0, 0, 14, 0), Child = row });
}
return Ui.Section("Stats", grid, "\uE9D2");
}
UIElement CharacterSection(AvatarInfo info, Dictionary<string, object> avatar)
{
var body = new StackPanel();
body.Children.Add(Ui.Row((Ui.Text("Level", "Dim", 12.5), Ui.Px(120)),
(Ui.Slider(1, 80, BuildFile.Int(avatar, "level", 1), v => { file.SetLevel(avatar, v); Changed(); }, v => "Lv " + v), Ui.Star())));
var ranks = Enumerable.Range(0, info.MaxRank + 1).Select(r => "E" + r);
body.Children.Add(Ui.Row((Ui.Text("Eidolons", "Dim", 12.5), Ui.Px(120)),
(Ui.Segments(ranks, BuildFile.Rank(avatar), v => { file.SetRank(avatar, v); Changed(); }), Ui.Star())));
if (info.Sp > 0)
{
int max = BuildFile.Int(avatar, "sp_max", info.Sp);
body.Children.Add(Ui.Row((Ui.Text("Starting energy", "Dim", 12.5), Ui.Px(120)),
(Ui.Slider(0, max, BuildFile.Int(avatar, "sp_value"), v => { avatar["sp_value"] = v; file.Touch(); Changed(); }, v => v + "/" + max), Ui.Star())));
}
if (info.Enhanced)
{
body.Children.Add(Ui.Row((Ui.Text("Version", "Dim", 12.5), Ui.Px(120)),
(Ui.Toggle("Enhanced", BuildFile.Int(avatar, "enhanced_id") != 0, on => { avatar["enhanced_id"] = on ? 1 : 0; file.Touch(); Changed(); }), Ui.Star())));
}
return Ui.Section("Character", body, "");
}
UIElement TraceSection(AvatarInfo info, Dictionary<string, object> avatar)
{
var body = new StackPanel();
foreach (Anchor anchor in info.Anchors.Where(a => a.Max > 1))
{
Anchor mine = anchor;
body.Children.Add(Ui.Row((Labelled(Icons.Skill(info.Id, anchor), anchor.Label), Ui.Px(150)),
(Ui.Slider(1, anchor.Max, Math.Max(1, BuildFile.AnchorLevel(avatar, anchor)), v => { file.SetAnchor(avatar, mine, v); Changed(); }, v => "Lv " + v), Ui.Star())));
}
var minor = new WrapPanel { Margin = new Thickness(0, 4, 0, 0) };
foreach (Anchor anchor in info.Anchors.Where(a => a.Max <= 1))
{
Anchor mine = anchor;
string label = anchor.Stats.Count > 0 ? StatSheet.Describe(anchor.Stats[0]) : anchor.Label;
var toggle = Ui.Toggle("", BuildFile.AnchorLevel(avatar, anchor) > 0, on => { file.SetAnchor(avatar, mine, on ? 1 : 0); Changed(); });
toggle.Content = Labelled(Icons.Skill(info.Id, anchor), label, "Text");
toggle.Width = 200;
toggle.Margin = new Thickness(0, 0, 8, 10);
minor.Children.Add(toggle);
}
body.Children.Add(minor);
var buttons = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 4, 0, 0) };
buttons.Children.Add(Ui.Button("Max all traces", (s, e) =>
{
foreach (Anchor anchor in info.Anchors) file.SetAnchor(avatar, anchor, anchor.Max);
Changed();
Redraw();
}, ""));
body.Children.Add(buttons);
return Ui.Section("Traces", body, "");
}
static FrameworkElement Labelled(string icon, string text, string color = "Dim")
{
var row = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
var view = Icons.View(icon, 24, new Border { Width = 6, Height = 6, CornerRadius = new CornerRadius(3), Background = Ui.Brush("Faint") });
view.Margin = new Thickness(0, 0, 9, 0);
row.Children.Add(view);
row.Children.Add(Ui.Text(text, color, 12.5));
return row;
}
UIElement LightconeSection(AvatarInfo info)
{
var body = new StackPanel();
var cone = file.LightconeOf(info.Id);
var picker = new Picker("No light cone")
{
Items = catalog.Lightcones.OrderByDescending(l => l.Path == info.Path).ThenByDescending(l => l.Rarity).ThenBy(l => l.Name)
.Select(l => new PickItem { Id = l.Id, Title = l.Name, Sub = new string('★', l.Rarity) + " " + l.Path, Accent = Palette.Rarity(l.Rarity), Tag = l, IconPath = Icons.Lightcone(l.Id) }).ToList(),
};
var known = cone == null ? null : catalog.Lightcone(BuildFile.Int(cone, "item_id"));
if (cone != null)
picker.Show(new PickItem { Title = known?.Name ?? "Light cone " + BuildFile.Int(cone, "item_id"), Sub = known == null ? "" : new string('★', known.Rarity) + " " + known.Path, IconPath = Icons.Lightcone(BuildFile.Int(cone, "item_id")) });
picker.Picked += item => { file.EquipLightcone(info.Id, item.Id); Changed(); Redraw(); };
var art = cone == null ? null : Icons.View(Icons.Lightcone(BuildFile.Int(cone, "item_id")), 96);
body.Children.Add(Ui.Row((Ui.Text("Light cone", "Dim", 12.5), Ui.Px(120)), (picker, Ui.Star())));
if (art != null)
{
art.HorizontalAlignment = HorizontalAlignment.Left;
art.Margin = new Thickness(120, 0, 0, 10);
body.Children.Add(art);
}
if (cone != null)
{
if (known != null && known.Path != info.Path)
body.Children.Add(Ui.Row((null, Ui.Px(120)), (Ui.Text("Different path, so its passive won't apply", "Warn", 12), Ui.Star())));
body.Children.Add(Ui.Row((Ui.Text("Level", "Dim", 12.5), Ui.Px(120)),
(Ui.Slider(1, 80, BuildFile.Int(cone, "level", 1), v => { file.SetLightconeLevel(cone, v); Changed(); }, v => "Lv " + v), Ui.Star())));
body.Children.Add(Ui.Row((Ui.Text("Superimposition", "Dim", 12.5), Ui.Px(120)),
(Ui.Segments(Enumerable.Range(1, 5).Select(r => "S" + r), BuildFile.Int(cone, "rank", 1) - 1, v => { cone["rank"] = v + 1; file.Touch(); Changed(); }), Ui.Star())));
var remove = Ui.Button("Take it off", (s, e) => { file.UnequipLightcone(info.Id); Changed(); Redraw(); }, "");
remove.HorizontalAlignment = HorizontalAlignment.Left;
body.Children.Add(remove);
}
return Ui.Section("Light cone", body, "");
}
UIElement RelicSection(AvatarInfo info)
{
var grid = new UniformGrid { Columns = 2 };
for (int slot = 1; slot <= 6; slot++)
grid.Children.Add(RelicCard(info, slot));
var body = new StackPanel();
body.Children.Add(grid);
return Ui.Section("Relics", body, "");
}
UIElement RelicCard(AvatarInfo info, int slot)
{
var relic = file.RelicOf(info.Id, slot);
var card = new StackPanel();
var head = new DockPanel { Margin = new Thickness(0, 0, 0, 10) };
var title = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
if (relic != null)
{
var piece = Icons.View(Icons.RelicPiece(BuildFile.Int(relic, "relic_set_id"), slot), 34);
piece.Margin = new Thickness(0, 0, 10, 0);
title.Children.Add(piece);
}
title.Children.Add(new TextBlock { Text = SlotNames[slot], FontWeight = FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center });
bool planar = slot >= 5;
var sets = catalog.RelicSets.Where(s => s.Planar == planar && s.Pieces.ContainsKey(slot)).OrderBy(s => s.Name).ToList();
if (relic == null)
{
head.Children.Add(title);
card.Children.Add(head);
var add = Ui.Button("Add " + SlotNames[slot].ToLowerInvariant(), (s, e) =>
{
var set = sets.First();
int main = catalog.MainAffixes.TryGetValue(slot, out var mains) ? mains.First().Id : 1;
var created = file.PutRelic(info.Id, slot, set, main);
file.SetSubs(created, DefaultSubs(slot, main));
Changed();
Redraw();
}, "");
add.HorizontalAlignment = HorizontalAlignment.Left;
card.Children.Add(add);
return Wrap(card);
}
var remove = Ui.Button("", (s, e) => { file.RemoveRelic(info.Id, slot); Changed(); Redraw(); }, "", size: 10);
remove.Padding = new Thickness(7, 4, 7, 4);
remove.ToolTip = "Remove this relic";
DockPanel.SetDock(remove, Dock.Right);
head.Children.Add(remove);
head.Children.Add(title);
card.Children.Add(head);
int setId = BuildFile.Int(relic, "relic_set_id");
var setPicker = new Picker("Pick a set")
{
Items = sets.Select(s => new PickItem { Id = s.Id, Title = s.Name, Sub = SetSummary(s), Accent = Ui.Brush(planar ? "Blue" : "Pink"), Tag = s, IconPath = Icons.RelicSet(s.Id) }).ToList(),
};
var currentSet = catalog.RelicSet(setId);
setPicker.Show(new PickItem { Title = currentSet?.Name ?? "Set " + setId, IconPath = Icons.RelicSet(setId) });
setPicker.Picked += item => { file.PutRelic(info.Id, slot, (RelicSetInfo)item.Tag, BuildFile.Int(relic, "main_affix_id")); Changed(); Redraw(); };
card.Children.Add(setPicker);
int level = BuildFile.Int(relic, "level");
int mainId = BuildFile.Int(relic, "main_affix_id");
catalog.MainAffixes.TryGetValue(slot, out List<AffixInfo> mainList);
AffixInfo mainAffix = catalog.Main(slot, mainId);
string MainValue(AffixInfo a, int lv) => a == null ? "" : a.Format(a.Base + a.PerLevel * lv);
var mainRow = new DockPanel { Margin = new Thickness(0, 10, 0, 0) };
if (mainList != null && mainList.Count > 1)
{
var mainPicker = new Picker("Main stat")
{
Items = mainList.Select(a => new PickItem { Id = a.Id, Title = a.Property, Sub = MainValue(a, 15), Accent = Ui.Brush("Warn"), Tag = a, IconPath = Icons.Property(a.Prop) }).ToList(),
};
mainPicker.Show(new PickItem { Title = mainAffix?.Property ?? "Main stat " + mainId, Sub = MainValue(mainAffix, level), IconPath = Icons.Property(mainAffix?.Prop) });
mainPicker.Picked += item =>
{
relic["main_affix_id"] = item.Id;
var subs = BuildFile.Subs(relic).Where(s => catalog.Sub(s.Id)?.Property != ((AffixInfo)item.Tag).Property).ToList();
file.SetSubs(relic, subs);
Changed();
Redraw();
};
mainRow.Children.Add(mainPicker);
}
else
{
var fixedMain = Labelled(Icons.Property(mainAffix?.Prop), (mainAffix?.Property ?? "Main stat") + " " + MainValue(mainAffix, level), "Text");
mainRow.Children.Add(new Border { Background = Ui.Brush("Field"), CornerRadius = new CornerRadius(9), Padding = new Thickness(9, 5, 11, 5), Child = fixedMain });
}
card.Children.Add(mainRow);
card.Children.Add(new Border
{
Margin = new Thickness(0, 10, 0, 6),
Child = Ui.Slider(0, 15, level, v => { relic["level"] = v; file.Touch(); Changed(); }, v => "+" + v),
});
var subsNow = BuildFile.Subs(relic);
int rolls = subsNow.Sum(s => s.Count);
var subHead = new DockPanel { Margin = new Thickness(0, 4, 0, 6) };
var rollText = Ui.Text(rolls + "/9 rolls", rolls > 9 ? "Warn" : "Faint", 11);
DockPanel.SetDock(rollText, Dock.Right);
subHead.Children.Add(rollText);
subHead.Children.Add(Ui.Text("SUBSTATS", "Dim", 10.5));
card.Children.Add(subHead);
for (int i = 0; i < 4; i++)
{
int index = i;
SubStat sub = index < subsNow.Count ? subsNow[index] : null;
AffixInfo affix = sub == null ? null : catalog.Sub(sub.Id);
var taken = subsNow.Where((s, n) => n != index).Select(s => s.Id).ToHashSet();
var picker = new Picker("Empty")
{
Items = catalog.SubAffixes.Where(a => !taken.Contains(a.Id) && a.Property != mainAffix?.Property)
.Select(a => new PickItem { Id = a.Id, Title = a.Property, Sub = a.Format(a.Base), Accent = Ui.Brush("Ok"), Tag = a, IconPath = Icons.Property(a.Prop) }).ToList(),
Padding = new Thickness(9, 5, 8, 5),
MinHeight = 30,
};
if (sub != null)
picker.Show(new PickItem { Title = affix?.Property ?? "Sub " + sub.Id, Sub = affix == null ? "" : affix.Format(affix.Base * sub.Count + affix.Step * sub.Step), IconPath = Icons.Property(affix?.Prop) });
picker.Picked += item =>
{
var list = BuildFile.Subs(relic);
if (index < list.Count) list[index].Id = item.Id;
else list.Add(new SubStat { Id = item.Id, Count = 1, Step = 2 });
file.SetSubs(relic, list);
Changed();
Redraw();
};
UIElement rollsBox = null;
if (sub != null)
{
rollsBox = Ui.Stepper(1, 6, sub.Count, v =>
{
var list = BuildFile.Subs(relic);
list[index].Count = v;
list[index].Step = Math.Min(list[index].Step, v * 2);
file.SetSubs(relic, list);
Changed();
Redraw();
}, 84);
((FrameworkElement)rollsBox).ToolTip = "Rolls";
((FrameworkElement)rollsBox).Margin = new Thickness(6, 0, 0, 0);
}
card.Children.Add(Ui.Row((picker, Ui.Star()), (rollsBox, Ui.Auto)));
}
var perfect = Ui.Button("Perfect rolls", (s, e) =>
{
var list = BuildFile.Subs(relic);
foreach (SubStat s2 in list) s2.Step = s2.Count * 2;
file.SetSubs(relic, list);
Changed();
Redraw();
}, "", size: 11.5);
perfect.HorizontalAlignment = HorizontalAlignment.Left;
perfect.ToolTip = "Every roll at its highest value";
card.Children.Add(perfect);
return Wrap(card);
}
static string SetSummary(RelicSetInfo set)
{
var parts = set.Bonus.OrderBy(b => b.Key).Where(b => b.Value.Count > 0).Select(b => b.Key + "pc " + string.Join(", ", b.Value.Select(StatSheet.Describe)));
string text = string.Join(" · ", parts);
return text.Length > 0 ? text : "#" + set.Id;
}
List<SubStat> DefaultSubs(int slot, int mainId)
{
string main = catalog.Main(slot, mainId)?.Property;
var wanted = new[] { "CRIT Rate", "CRIT DMG", "ATK%", "SPD" };
var picks = catalog.SubAffixes.Where(a => a.Property != main).OrderBy(a => Array.IndexOf(wanted, a.Property) is int n && n >= 0 ? n : 99).Take(4).ToList();
return picks.Select((a, n) => new SubStat { Id = a.Id, Count = n == 0 ? 3 : 2, Step = n == 0 ? 6 : 4 }).ToList();
}
static Border Wrap(UIElement content) => new Border
{
Background = Ui.Brush("Field"), CornerRadius = new CornerRadius(12), Padding = new Thickness(14),
Margin = new Thickness(0, 0, 10, 10), Child = content,
};
// ---- everyone ----
async void MaxEveryone_Click(object sender, RoutedEventArgs e)
{
if (!await ask("Max everyone?", "Every character you have goes to level 80 with every trace maxed. Eidolons stay as they are.", "Max them", false))
return;
foreach (CharRow row in rows)
{
var avatar = file.Avatar(row.Info.Id);
if (avatar != null) file.MaxAvatar(avatar, row.Info, false);
}
foreach (var cone in file.Lightcones.OfType<Dictionary<string, object>>())
file.SetLightconeLevel(cone, 80);
AfterBulk();
}
async void E6S5_Click(object sender, RoutedEventArgs e)
{
if (!await ask("E6 S5 everyone?", "Every character you have goes to their highest eidolon, and every light cone to S5.", "Do it", false))
return;
foreach (CharRow row in rows)
{
var avatar = file.Avatar(row.Info.Id);
if (avatar != null) file.SetRank(avatar, row.Info.MaxRank);
}
foreach (var cone in file.Lightcones.OfType<Dictionary<string, object>>())
cone["rank"] = 5;
file.Touch();
AfterBulk();
}
void AfterBulk()
{
changed();
foreach (CharRow row in rows) row.Refresh(file);
if (current != null) Redraw();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<LangVersion>latest</LangVersion>
<AssemblyName>CapyLauncher</AssemblyName>
<RootNamespace>CapyLauncher</RootNamespace>
<ApplicationIcon>capy.ico</ApplicationIcon>
<DebugType>none</DebugType>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.VisualBasic" />
<Reference Include="System.Web" />
<Reference Include="System.Web.Extensions" />
<Resource Include="capy.ico" />
<Resource Include="capy.png" />
<Resource Include="capy_swim.png" />
</ItemGroup>
</Project>
+274
View File
@@ -0,0 +1,274 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web.Script.Serialization;
using System.Windows.Media;
namespace CapyLauncher
{
public class StatBonus
{
public string Prop;
public double Value;
}
public class Anchor
{
public int Number;
public int Max;
public List<int> Points = new List<int>();
public List<StatBonus> Stats = new List<StatBonus>();
public string Label
{
get
{
switch (Number)
{
case 1: return "Basic ATK";
case 2: return "Skill";
case 3: return "Ultimate";
case 4: return "Talent";
case 5: return "Technique";
case 6: return "Ascension A2";
case 7: return "Ascension A4";
case 8: return "Ascension A6";
case 19: return "Memosprite skill";
case 20: return "Memosprite talent";
default: return Number >= 9 && Number <= 18 ? "Stat bonus " + (Number - 8) : "Extra " + Number;
}
}
}
}
public class AvatarInfo
{
public int Id;
public string Name;
public int Rarity;
public string Element;
public string Path;
public int MaxRank;
public int Sp;
public bool Enhanced;
public List<Anchor> Anchors = new List<Anchor>();
public double[][] Growth = new double[7][];
public string Initials
{
get
{
var words = Name.Split(new[] { ' ', '(', ')', '•', '·' }, StringSplitOptions.RemoveEmptyEntries).Where(w => char.IsLetter(w[0])).ToList();
if (words.Count == 0)
return "?";
if (words.Count == 1 || Name.Contains("("))
return words[0].Length > 1 ? char.ToUpperInvariant(words[0][0]) + words[0].Substring(1, 1) : words[0].ToUpperInvariant();
return string.Concat(words.Take(2).Select(w => char.ToUpperInvariant(w[0])));
}
}
}
public class LightconeInfo
{
public int Id;
public string Name;
public int Rarity;
public string Path;
public double[][] Growth = new double[7][];
public List<StatBonus>[] Passive = new List<StatBonus>[5];
}
public class RelicSetInfo
{
public int Id;
public string Name;
public bool Planar;
public Dictionary<int, int> Pieces = new Dictionary<int, int>();
public Dictionary<int, List<StatBonus>> Bonus = new Dictionary<int, List<StatBonus>>();
}
public class AffixInfo
{
public int Id;
public string Prop;
public string Property;
public bool Percent;
public double Base;
public double PerLevel;
public double Step;
public int Steps;
public string Format(double value) => Percent ? (value * 100).ToString("0.0") + "%" : Property == "SPD" ? value.ToString("0.0") : Math.Floor(value).ToString("0");
}
public class Named
{
public int Id;
public string Name;
public string Extra;
}
public class Catalog
{
public string Version = "";
public List<AvatarInfo> Avatars = new List<AvatarInfo>();
public List<LightconeInfo> Lightcones = new List<LightconeInfo>();
public List<RelicSetInfo> RelicSets = new List<RelicSetInfo>();
public Dictionary<int, List<AffixInfo>> MainAffixes = new Dictionary<int, List<AffixInfo>>();
public List<AffixInfo> SubAffixes = new List<AffixInfo>();
public List<Named> Monsters = new List<Named>();
public List<Named> Stages = new List<Named>();
public List<Named> Buffs = new List<Named>();
public AvatarInfo Avatar(int id) => Avatars.FirstOrDefault(a => a.Id == id);
public LightconeInfo Lightcone(int id) => Lightcones.FirstOrDefault(l => l.Id == id);
public RelicSetInfo RelicSet(int id) => RelicSets.FirstOrDefault(r => r.Id == id);
public AffixInfo Sub(int id) => SubAffixes.FirstOrDefault(s => s.Id == id);
public AffixInfo Main(int slot, int id) =>
MainAffixes.TryGetValue(slot, out List<AffixInfo> list) ? list.FirstOrDefault(a => a.Id == id) : null;
public static Catalog Load(string path)
{
var catalog = new Catalog();
var serializer = new JavaScriptSerializer { MaxJsonLength = int.MaxValue, RecursionLimit = 256 };
var root = (Dictionary<string, object>)serializer.DeserializeObject(File.ReadAllText(path));
catalog.Version = Str(root, "version");
foreach (var a in Items(root, "avatars"))
{
var info = new AvatarInfo
{
Id = Int(a, "id"), Name = Str(a, "name"), Rarity = Int(a, "rarity"), Element = Str(a, "element"),
Path = Str(a, "path"), MaxRank = Int(a, "max_rank"), Sp = Int(a, "sp"), Enhanced = Bool(a, "enhanced"),
};
foreach (var anchor in Items(a, "anchors"))
{
info.Anchors.Add(new Anchor
{
Number = Int(anchor, "anchor"), Max = Int(anchor, "max"),
Points = ((IEnumerable)anchor["points"]).Cast<object>().Select(Convert.ToInt32).ToList(),
Stats = Bonuses(anchor, "stats"),
});
}
info.Growth = Growth(a);
catalog.Avatars.Add(info);
}
foreach (var l in Items(root, "lightcones"))
{
var cone = new LightconeInfo { Id = Int(l, "id"), Name = Str(l, "name"), Rarity = Int(l, "rarity"), Path = Str(l, "path"), Growth = Growth(l) };
if (l.TryGetValue("passive", out object passive) && passive is IEnumerable ranks)
{
int rank = 0;
foreach (object level in ranks)
{
if (rank < 5)
cone.Passive[rank++] = ((IEnumerable)level).Cast<Dictionary<string, object>>().Select(Bonus).ToList();
}
}
catalog.Lightcones.Add(cone);
}
foreach (var s in Items(root, "relic_sets"))
{
var set = new RelicSetInfo { Id = Int(s, "id"), Name = Str(s, "name"), Planar = Bool(s, "planar") };
foreach (var piece in (Dictionary<string, object>)s["pieces"])
set.Pieces[int.Parse(piece.Key)] = Convert.ToInt32(piece.Value);
if (s.TryGetValue("bonus", out object bonus) && bonus is Dictionary<string, object> pieces)
{
foreach (var need in pieces)
set.Bonus[int.Parse(need.Key)] = ((IEnumerable)need.Value).Cast<Dictionary<string, object>>().Select(Bonus).ToList();
}
catalog.RelicSets.Add(set);
}
foreach (var slot in (Dictionary<string, object>)root["main_affixes"])
catalog.MainAffixes[int.Parse(slot.Key)] = ((IEnumerable)slot.Value).Cast<Dictionary<string, object>>().Select(Affix).ToList();
catalog.SubAffixes = Items(root, "sub_affixes").Select(Affix).ToList();
catalog.Monsters = Items(root, "monsters").Select(m => new Named { Id = Int(m, "id"), Name = Str(m, "name"), Extra = Rank(Str(m, "rank")) }).ToList();
catalog.Stages = Items(root, "stages").Select(m => new Named { Id = Int(m, "id"), Name = Str(m, "name"), Extra = Str(m, "type") }).ToList();
catalog.Buffs = Items(root, "buffs").Select(m => new Named { Id = Int(m, "id"), Name = Str(m, "name") }).ToList();
return catalog;
}
static StatBonus Bonus(Dictionary<string, object> d) => new StatBonus { Prop = Str(d, "prop"), Value = Dbl(d, "value") };
static List<StatBonus> Bonuses(Dictionary<string, object> parent, string key) => Items(parent, key).Select(Bonus).ToList();
static double[][] Growth(Dictionary<string, object> parent)
{
var growth = new double[7][];
if (parent.TryGetValue("growth", out object list) && list is IEnumerable steps)
{
int promotion = 0;
foreach (object step in steps)
{
if (promotion < 7 && step is IEnumerable numbers)
growth[promotion] = numbers.Cast<object>().Select(Convert.ToDouble).ToArray();
promotion++;
}
}
return growth;
}
static AffixInfo Affix(Dictionary<string, object> a) => new AffixInfo
{
Id = Int(a, "id"), Prop = Str(a, "prop"), Property = Str(a, "property"), Percent = Bool(a, "percent"), Base = Dbl(a, "base"),
PerLevel = Dbl(a, "per_level"), Step = Dbl(a, "step"), Steps = Int(a, "steps"),
};
static string Rank(string rank)
{
if (rank.StartsWith("BigBoss")) return "Boss";
if (rank.StartsWith("LittleBoss")) return "Mini-boss";
if (rank.StartsWith("Elite")) return "Elite";
return rank.Length == 0 ? "" : "Minion";
}
static IEnumerable<Dictionary<string, object>> Items(Dictionary<string, object> parent, string key) =>
parent.TryGetValue(key, out object list) && list is IEnumerable items ? items.Cast<Dictionary<string, object>>() : Enumerable.Empty<Dictionary<string, object>>();
static int Int(Dictionary<string, object> d, string key) => d.TryGetValue(key, out object v) && v != null ? Convert.ToInt32(v) : 0;
static double Dbl(Dictionary<string, object> d, string key) => d.TryGetValue(key, out object v) && v != null ? Convert.ToDouble(v) : 0;
static string Str(Dictionary<string, object> d, string key) => d.TryGetValue(key, out object v) && v != null ? Convert.ToString(v) : "";
static bool Bool(Dictionary<string, object> d, string key) => d.TryGetValue(key, out object v) && v is bool b && b;
}
public static class Palette
{
static readonly Dictionary<string, string[]> Elements = new Dictionary<string, string[]>
{
{ "Fire", new[] { "#FF9A6B", "#FF5E7A" } },
{ "Ice", new[] { "#9FE7FF", "#5FA8FF" } },
{ "Lightning", new[] { "#E3A6FF", "#A16BFF" } },
{ "Wind", new[] { "#A6F0C6", "#43C59E" } },
{ "Quantum", new[] { "#8E8CFF", "#5A4BD8" } },
{ "Imaginary", new[] { "#FFE38A", "#F2B544" } },
{ "Physical", new[] { "#E6E6EE", "#A3A3B5" } },
};
public static Brush Element(string element)
{
string[] colors = Elements.TryGetValue(element ?? "", out string[] found) ? found : new[] { "#FF9AA2", "#D689FF" };
var brush = new LinearGradientBrush((Color)ColorConverter.ConvertFromString(colors[0]),
(Color)ColorConverter.ConvertFromString(colors[1]), 45);
brush.Freeze();
return brush;
}
public static Brush Solid(string element)
{
string[] colors = Elements.TryGetValue(element ?? "", out string[] found) ? found : new[] { "#FF9AA2" };
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors[0]));
brush.Freeze();
return brush;
}
public static Brush Rarity(int rarity)
{
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(rarity >= 5 ? "#FFD58A" : rarity == 4 ? "#D689FF" : "#7DC8FF"));
brush.Freeze();
return brush;
}
}
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
namespace CapyLauncher
{
public static class Icons
{
const string Source = "https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/";
static readonly string Folder = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CapySR", "icons");
static readonly ConcurrentDictionary<string, ImageSource> Loaded = new ConcurrentDictionary<string, ImageSource>();
static readonly ConcurrentDictionary<string, Task<ImageSource>> Pending = new ConcurrentDictionary<string, Task<ImageSource>>();
static readonly ConcurrentDictionary<string, bool> Missing = new ConcurrentDictionary<string, bool>();
static readonly SemaphoreSlim Gate = new SemaphoreSlim(6);
static readonly Dictionary<string, string> Properties = new Dictionary<string, string>
{
{ "HPDelta", "IconMaxHP" }, { "HPAddedRatio", "IconMaxHP" }, { "AttackDelta", "IconAttack" }, { "AttackAddedRatio", "IconAttack" },
{ "DefenceDelta", "IconDefence" }, { "DefenceAddedRatio", "IconDefence" }, { "SpeedDelta", "IconSpeed" }, { "SpeedAddedRatio", "IconSpeed" },
{ "CriticalChanceBase", "IconCriticalChance" }, { "CriticalDamageBase", "IconCriticalDamage" },
{ "BreakDamageAddedRatioBase", "IconBreakUp" }, { "HealRatioBase", "IconHealRatio" }, { "SPRatioBase", "IconEnergyRecovery" },
{ "StatusProbabilityBase", "IconStatusProbability" }, { "StatusResistanceBase", "IconStatusResistance" },
{ "PhysicalAddedRatio", "IconPhysicalAddedRatio" }, { "FireAddedRatio", "IconFireAddedRatio" }, { "IceAddedRatio", "IconIceAddedRatio" },
{ "ThunderAddedRatio", "IconThunderAddedRatio" }, { "WindAddedRatio", "IconWindAddedRatio" }, { "QuantumAddedRatio", "IconQuantumAddedRatio" },
{ "ImaginaryAddedRatio", "IconImaginaryAddedRatio" }, { "ElationDamageAddedRatioBase", "IconJoy" },
};
static readonly Dictionary<string, string> ElementProps = new Dictionary<string, string>
{
{ "Physical", "PhysicalAddedRatio" }, { "Fire", "FireAddedRatio" }, { "Ice", "IceAddedRatio" }, { "Lightning", "ThunderAddedRatio" },
{ "Wind", "WindAddedRatio" }, { "Quantum", "QuantumAddedRatio" }, { "Imaginary", "ImaginaryAddedRatio" },
};
public static string Character(int id) => "icon/character/" + id + ".png";
public static string Lightcone(int id) => "icon/light_cone/" + id + ".png";
public static string RelicSet(int id) => "icon/relic/" + id + ".png";
public static string RelicPiece(int set, int slot) => "icon/relic/" + set + "_" + (slot <= 4 ? slot - 1 : slot - 5) + ".png";
public static string Element(string element) => "icon/element/" + (element == "Lightning" ? "Thunder" : element) + ".png";
public static string Path(string path) => "icon/path/" + (path == "The Hunt" ? "Hunt" : path) + ".png";
public static string Property(string prop) => prop != null && Properties.TryGetValue(prop, out string icon) ? "icon/property/" + icon + ".png" : null;
public static string ElementDamage(string element) => ElementProps.TryGetValue(element ?? "", out string prop) ? Property(prop) : null;
public static string Skill(int avatarId, Anchor anchor)
{
switch (anchor.Number)
{
case 1: return "icon/skill/" + avatarId + "_basic_atk.png";
case 2: return "icon/skill/" + avatarId + "_skill.png";
case 3: return "icon/skill/" + avatarId + "_ultimate.png";
case 4: return "icon/skill/" + avatarId + "_talent.png";
case 5: return "icon/skill/" + avatarId + "_technique.png";
case 6: case 7: case 8: return "icon/skill/" + avatarId + "_skilltree" + (anchor.Number - 5) + ".png";
default: return anchor.Stats.Count > 0 ? Property(anchor.Stats[0].Prop) : null;
}
}
public static Task<ImageSource> Get(string relative)
{
if (string.IsNullOrEmpty(relative) || Missing.ContainsKey(relative))
return Task.FromResult<ImageSource>(null);
if (Loaded.TryGetValue(relative, out ImageSource ready))
return Task.FromResult(ready);
return Pending.GetOrAdd(relative, key => Task.Run(() => Fetch(key)));
}
static async Task<ImageSource> Fetch(string relative)
{
string file = System.IO.Path.Combine(Folder, relative.Replace('/', '\\'));
string missing = file + ".missing";
try
{
if (File.Exists(missing) && (DateTime.UtcNow - File.GetLastWriteTimeUtc(missing)).TotalDays < 7)
{
Missing[relative] = true;
return null;
}
if (!File.Exists(file))
{
await Gate.WaitAsync();
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(file));
var request = (HttpWebRequest)WebRequest.Create(Source + relative);
request.Timeout = 15000;
using (var response = (HttpWebResponse)await request.GetResponseAsync())
using (var body = response.GetResponseStream())
using (var output = File.Create(file + ".part"))
await body.CopyToAsync(output);
File.Move(file + ".part", file);
}
catch (WebException error) when ((error.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.NotFound)
{
File.WriteAllText(missing, "");
Missing[relative] = true;
return null;
}
finally
{
Gate.Release();
}
}
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.UriSource = new Uri(file);
image.EndInit();
image.Freeze();
Loaded[relative] = image;
return image;
}
catch (Exception)
{
try { File.Delete(file + ".part"); } catch (Exception) { }
return null;
}
finally
{
Pending.TryRemove(relative, out _);
}
}
public static FrameworkElement View(string relative, double size, UIElement fallback = null, double radius = 0)
{
var host = new Grid { Width = size, Height = size };
if (fallback != null)
host.Children.Add(fallback);
var image = new Image { Width = size, Height = size, Stretch = Stretch.Uniform, Opacity = 0 };
RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality);
if (radius > 0)
image.Clip = new RectangleGeometry(new Rect(0, 0, size, size), radius, radius);
host.Children.Add(image);
Fill(image, relative, fallback);
return host;
}
public static async void Fill(Image image, string relative, UIElement fallback = null)
{
ImageSource source = await Get(relative);
if (source == null)
return;
image.Source = source;
image.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(220)));
if (fallback != null)
fallback.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, TimeSpan.FromMilliseconds(220)));
}
}
}
+360
View File
@@ -0,0 +1,360 @@
<Window x:Class="CapyLauncher.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CapyLauncher"
Title="CapySR" Width="1180" Height="780" MinWidth="980" MinHeight="680"
Background="{StaticResource Bg}" Foreground="{StaticResource Text}"
FontFamily="Segoe UI Variable Text, Segoe UI" FontSize="13.5"
WindowStartupLocation="CenterScreen" UseLayoutRounding="True" Icon="capy.ico"
TextOptions.TextFormattingMode="Display">
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeight="52" ResizeBorderThickness="6" GlassFrameThickness="0" CornerRadius="0" UseAeroCaptionButtons="False"/>
</WindowChrome.WindowChrome>
<Window.Triggers>
<EventTrigger RoutedEvent="Window.Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="BlobPink" Storyboard.TargetProperty="(Canvas.Left)" From="-160" To="140" Duration="0:0:23" AutoReverse="True" RepeatBehavior="Forever">
<DoubleAnimation.EasingFunction><SineEase EasingMode="EaseInOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="BlobPink" Storyboard.TargetProperty="(Canvas.Top)" From="-180" To="60" Duration="0:0:17" AutoReverse="True" RepeatBehavior="Forever">
<DoubleAnimation.EasingFunction><SineEase EasingMode="EaseInOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="BlobPurple" Storyboard.TargetProperty="(Canvas.Left)" From="520" To="860" Duration="0:0:29" AutoReverse="True" RepeatBehavior="Forever">
<DoubleAnimation.EasingFunction><SineEase EasingMode="EaseInOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="BlobPurple" Storyboard.TargetProperty="(Canvas.Top)" From="380" To="140" Duration="0:0:21" AutoReverse="True" RepeatBehavior="Forever">
<DoubleAnimation.EasingFunction><SineEase EasingMode="EaseInOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="BlobBlue" Storyboard.TargetProperty="(Canvas.Left)" From="980" To="620" Duration="0:0:26" AutoReverse="True" RepeatBehavior="Forever">
<DoubleAnimation.EasingFunction><SineEase EasingMode="EaseInOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="BlobBlue" Storyboard.TargetProperty="(Canvas.Top)" From="-220" To="20" Duration="0:0:19" AutoReverse="True" RepeatBehavior="Forever">
<DoubleAnimation.EasingFunction><SineEase EasingMode="EaseInOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Window.Triggers>
<Grid>
<Canvas ClipToBounds="True" IsHitTestVisible="False">
<Canvas.CacheMode>
<BitmapCache RenderAtScale="0.5"/>
</Canvas.CacheMode>
<Ellipse x:Name="BlobPink" Width="560" Height="560" Fill="#FF9AA2" Opacity="0.13">
<Ellipse.Effect><BlurEffect Radius="160" KernelType="Gaussian"/></Ellipse.Effect>
</Ellipse>
<Ellipse x:Name="BlobPurple" Width="620" Height="620" Fill="#D689FF" Opacity="0.11">
<Ellipse.Effect><BlurEffect Radius="170" KernelType="Gaussian"/></Ellipse.Effect>
</Ellipse>
<Ellipse x:Name="BlobBlue" Width="520" Height="520" Fill="#7DC8FF" Opacity="0.09">
<Ellipse.Effect><BlurEffect Radius="160" KernelType="Gaussian"/></Ellipse.Effect>
</Ellipse>
</Canvas>
<Grid x:Name="Shell">
<Grid.Effect>
<BlurEffect x:Name="ShellBlur" Radius="0"/>
</Grid.Effect>
<Grid.RowDefinitions>
<RowDefinition Height="52"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="18,0,8,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Image Source="capy.png" Width="26" Height="26" RenderOptions.BitmapScalingMode="NearestNeighbor"/>
<TextBlock Text="CapySR" FontSize="15" FontWeight="Bold" Foreground="{StaticResource Capy}" Margin="10,0,0,1" VerticalAlignment="Center"/>
<Border Background="#1AFFFFFF" CornerRadius="6" Padding="7,1.5" Margin="9,1,0,0" VerticalAlignment="Center">
<TextBlock Text="launcher" FontSize="11" Foreground="{StaticResource Dim}"/>
</Border>
</StackPanel>
<Border HorizontalAlignment="Center" VerticalAlignment="Center" Background="#12FFFFFF" CornerRadius="12" Padding="3"
WindowChrome.IsHitTestVisibleInChrome="True">
<Grid>
<Border x:Name="NavPill" HorizontalAlignment="Left" CornerRadius="9" Background="{StaticResource Raised}" BorderBrush="{StaticResource Line}" BorderThickness="1">
<Border.RenderTransform><TranslateTransform/></Border.RenderTransform>
<Border Height="2" VerticalAlignment="Bottom" Margin="14,0,14,3" CornerRadius="1" Background="{StaticResource Capy}"/>
</Border>
<StackPanel x:Name="NavTabs" Orientation="Horizontal">
<RadioButton x:Name="TabHome" Style="{StaticResource NavTab}" Tag="&#xE80F;" Content="Home" GroupName="nav" IsChecked="True" Checked="Tab_Checked"/>
<RadioButton x:Name="TabBuilds" Style="{StaticResource NavTab}" Tag="&#xE77B;" Content="Builds" GroupName="nav" Checked="Tab_Checked"/>
<RadioButton x:Name="TabBattle" Style="{StaticResource NavTab}" Tag="&#xE7EE;" Content="Battle" GroupName="nav" Checked="Tab_Checked"/>
</StackPanel>
</Grid>
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Style="{StaticResource Chrome}" Content="&#xE921;" Click="Minimize_Click"/>
<Button x:Name="MaxButton" Style="{StaticResource Chrome}" Content="&#xE922;" Margin="2,0" Click="Maximize_Click"/>
<Button Style="{StaticResource ChromeClose}" Content="&#xE8BB;" Click="Close_Click"/>
</StackPanel>
</Grid>
<Grid x:Name="BuildsHost" Grid.Row="1" Margin="18,2,18,18" Visibility="Collapsed">
<local:BuildsPage x:Name="Builds"/>
</Grid>
<Grid x:Name="BattleHost" Grid.Row="1" Margin="18,2,18,18" Visibility="Collapsed">
<local:BattlePage x:Name="BattleView"/>
</Grid>
<Grid x:Name="HomePage" Grid.Row="1" Margin="18,2,18,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="372"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="14"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="14"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border x:Name="ServerCard" Style="{StaticResource Card}">
<StackPanel>
<Grid>
<TextBlock Style="{StaticResource Caption}" Text="SERVER" VerticalAlignment="Center"/>
<Border x:Name="Pill" HorizontalAlignment="Right" CornerRadius="11" Padding="9,3" Background="#1AFFFFFF">
<StackPanel Orientation="Horizontal">
<Grid Width="8" Height="8" VerticalAlignment="Center">
<Ellipse x:Name="PulseRing" Fill="{StaticResource Faint}" RenderTransformOrigin="0.5,0.5" Opacity="0">
<Ellipse.RenderTransform><ScaleTransform/></Ellipse.RenderTransform>
</Ellipse>
<Ellipse x:Name="PillDot" Fill="{StaticResource Faint}"/>
</Grid>
<TextBlock x:Name="PillText" Text="Offline" FontSize="11.5" Margin="7,0,0,0" Foreground="{StaticResource Dim}"/>
</StackPanel>
</Border>
</Grid>
<Grid Margin="0,14,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Width="96" Height="78" CornerRadius="14" Background="{StaticResource Field}" ClipToBounds="True">
<Grid>
<Border x:Name="Water" VerticalAlignment="Bottom" Height="26" Opacity="0.35">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#007DC8FF" Offset="0"/>
<GradientStop Color="#997DC8FF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
<Image x:Name="Capy" Width="78" Height="54" Margin="0,8,0,0" RenderOptions.BitmapScalingMode="NearestNeighbor">
<Image.RenderTransform><TranslateTransform/></Image.RenderTransform>
</Image>
</Grid>
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock x:Name="StateTitle" Text="Sleeping" FontSize="25" FontWeight="Bold" Foreground="{StaticResource Text}"
FontFamily="Segoe UI Variable Display, Segoe UI"/>
<TextBlock x:Name="StateSub" Text="Start the server to wake capy up" Foreground="{StaticResource Dim}"
TextWrapping="Wrap" Margin="0,2,0,0" FontSize="12.5"/>
</StackPanel>
</Grid>
<Grid Margin="0,16,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0.9*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="1.25*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Field}" CornerRadius="10" Padding="11,8" Margin="0,0,4,0">
<StackPanel>
<TextBlock Style="{StaticResource Caption}" Text="UPTIME" FontSize="10"/>
<TextBlock x:Name="UptimeText" Text="—" FontFamily="{StaticResource Mono}" FontSize="13" Margin="0,3,0,0"/>
</StackPanel>
</Border>
<Border Grid.Column="1" Background="{StaticResource Field}" CornerRadius="10" Padding="11,8" Margin="4,0">
<StackPanel>
<TextBlock Style="{StaticResource Caption}" Text="PLAYER" FontSize="10"/>
<TextBlock x:Name="PlayerText" Text="—" FontSize="13" Margin="0,3,0,0" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Background="{StaticResource Field}" CornerRadius="10" Padding="11,8" Margin="4,0,0,0">
<StackPanel>
<TextBlock Style="{StaticResource Caption}" Text="PORTS" FontSize="10"/>
<TextBlock x:Name="PortsText" Text="—" FontFamily="{StaticResource Mono}" FontSize="12.5" Margin="0,3,0,0" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
</Grid>
<Button x:Name="ServerButton" Style="{StaticResource Primary}" Margin="0,16,0,0" Click="Server_Click">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="ServerGlyph" Style="{StaticResource Glyph}" Text="&#xE7E8;" FontSize="15" Margin="0,1,10,0"/>
<TextBlock x:Name="ServerLabel" Text="Start server"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<Border x:Name="GameCard" Grid.Row="2" Style="{StaticResource Card}">
<StackPanel>
<Grid>
<TextBlock Style="{StaticResource Caption}" Text="GAME" VerticalAlignment="Center"/>
<Button HorizontalAlignment="Right" Padding="11,4" FontSize="12" Click="Browse_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8B7;" FontSize="12" Margin="0,1,7,0"/>
<TextBlock Text="Browse"/>
</StackPanel>
</Button>
</Grid>
<Border Background="{StaticResource Field}" CornerRadius="10" Padding="12,10" Margin="0,10,0,0">
<StackPanel>
<TextBlock x:Name="ClientPath" Text="Pick your client folder" TextTrimming="CharacterEllipsis" Foreground="{StaticResource Dim}"/>
<StackPanel Orientation="Horizontal" Margin="0,7,0,0">
<TextBlock x:Name="ExeMark" Style="{StaticResource Glyph}" Text="&#xE711;" FontSize="10" Foreground="{StaticResource Faint}"/>
<TextBlock x:Name="ExeText" Text="StarRail.exe" FontSize="12" Foreground="{StaticResource Faint}" Margin="6,0,16,0"/>
<TextBlock x:Name="PatchMark" Style="{StaticResource Glyph}" Text="&#xE711;" FontSize="10" Foreground="{StaticResource Faint}"/>
<TextBlock x:Name="PatchText" Text="Patch" FontSize="12" Foreground="{StaticResource Faint}" Margin="6,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
<Button x:Name="PlayButton" Style="{StaticResource Primary}" Margin="0,14,0,0" Click="Play_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE768;" FontSize="15" Margin="0,1,10,0"/>
<TextBlock x:Name="PlayLabel" Text="Play"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<Border x:Name="QuickCard" Grid.Row="4" Style="{StaticResource Card}" Padding="16,16,16,14">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Style="{StaticResource Caption}" Text="QUICK ACTIONS" Margin="4,0,0,8"/>
<UniformGrid Columns="3" Rows="2" VerticalAlignment="Top">
<Button Style="{StaticResource Tile}" Tag="&#xE713;" Content="Config" ToolTip="Open config/config.json" Click="Config_Click"/>
<Button Style="{StaticResource Tile}" Tag="&#xE8B7;" Content="Data" ToolTip="Open the data folder" Click="Data_Click"/>
<Button Style="{StaticResource Tile}" Tag="&#xE8A5;" Content="Log file" ToolTip="Open logs/capysr.log" Click="LogFile_Click"/>
<Button x:Name="UnstickButton" Style="{StaticResource Tile}" Tag="&#xE90F;" Content="Unstick" ToolTip="Black screen? This puts you back somewhere safe" Click="Unstick_Click"/>
<Button Style="{StaticResource Tile}" Tag="&#xE774;" Content="srtools" ToolTip="Build your characters on srtools" Click="SrTools_Click"/>
<Button x:Name="FreshButton" Style="{StaticResource Tile}" Tag="&#xE72C;" Content="Fresh start" ToolTip="Move data/player.json to the Recycle Bin" Click="Fresh_Click"/>
</UniformGrid>
</DockPanel>
</Border>
</Grid>
<Border x:Name="ConsoleCard" Grid.Column="2" Style="{StaticResource Card}" Padding="18,16,18,18">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource Caption}" Text="CONSOLE" VerticalAlignment="Center"/>
<TextBlock x:Name="LineCount" Foreground="{StaticResource Faint}" FontSize="11" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<ToggleButton x:Name="ShowDebug" Style="{StaticResource Chip}" Content="Debug" Tag="{StaticResource Faint}" Click="Filter_Click"/>
<ToggleButton x:Name="ShowInfo" Style="{StaticResource Chip}" Content="Info" Tag="{StaticResource Blue}" IsChecked="True" Click="Filter_Click"/>
<ToggleButton x:Name="ShowWarn" Style="{StaticResource Chip}" Content="Warn" Tag="{StaticResource Warn}" IsChecked="True" Click="Filter_Click"/>
<ToggleButton x:Name="ShowError" Style="{StaticResource Chip}" Content="Error" Tag="{StaticResource Fail}" IsChecked="True" Click="Filter_Click" Margin="0,0,12,0"/>
<TextBox x:Name="SearchBox" Width="170" Tag="Search" Padding="8,4" FontSize="12" TextChanged="Search_Changed"/>
<Button Padding="9,5" Margin="8,0,0,0" ToolTip="Copy what's shown" Click="Copy_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8C8;" FontSize="13"/>
</Button>
<Button Padding="9,5" Margin="6,0,0,0" ToolTip="Clear" Click="Clear_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74D;" FontSize="13"/>
</Button>
</StackPanel>
</Grid>
<Grid DockPanel.Dock="Bottom" Margin="2,10,0,0">
<CheckBox x:Name="AutoScroll" Content="Follow new lines" IsChecked="True"/>
<TextBlock x:Name="Hint" HorizontalAlignment="Right" Foreground="{StaticResource Faint}" FontSize="11.5" VerticalAlignment="Center"/>
</Grid>
<Border Background="{StaticResource Field}" CornerRadius="12" Padding="6">
<Grid>
<ListBox x:Name="LogList" Background="Transparent" BorderThickness="0" Foreground="{StaticResource Text}"
ItemContainerStyle="{StaticResource LogItem}" SelectionMode="Extended"
VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel" ScrollViewer.HorizontalScrollBarVisibility="Disabled"
FontFamily="{StaticResource Mono}" FontSize="12">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="92"/>
<ColumnDefinition Width="54"/>
<ColumnDefinition Width="72"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Time}" Foreground="{StaticResource Faint}"/>
<Border Grid.Column="1" Background="{Binding LevelBack}" CornerRadius="4" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="0,1,0,0" Padding="5,0" Visibility="{Binding LevelVisibility}">
<TextBlock Text="{Binding LevelName}" Foreground="{Binding LevelBrush}" FontSize="10.5" FontWeight="SemiBold" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="2" Text="{Binding Tag}" Foreground="{Binding TagBrush}" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" TextWrapping="Wrap">
<Run Text="{Binding Arrow, Mode=OneWay}" Foreground="{Binding ArrowBrush, Mode=OneWay}"/><Run Text="{Binding Message, Mode=OneWay}" Foreground="{Binding MessageBrush, Mode=OneWay}"/>
</TextBlock>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel x:Name="EmptyState" VerticalAlignment="Center" HorizontalAlignment="Center" IsHitTestVisible="False">
<Image x:Name="EmptyCapy" Width="104" Height="72" RenderOptions.BitmapScalingMode="NearestNeighbor" Opacity="0.85"/>
<TextBlock Text="Nothing here yet" HorizontalAlignment="Center" FontSize="15" FontWeight="SemiBold" Margin="0,14,0,0"/>
<TextBlock Text="The server's log shows up here once it starts." HorizontalAlignment="Center" Foreground="{StaticResource Dim}" Margin="0,4,0,0"/>
</StackPanel>
</Grid>
</Border>
</DockPanel>
</Border>
</Grid>
</Grid>
<Border x:Name="SaveBar" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,28" CornerRadius="14"
Background="#F22C2440" BorderBrush="{StaticResource Line}" BorderThickness="1" Padding="18,8,8,8"
Opacity="0" IsHitTestVisible="False">
<Border.RenderTransform><TranslateTransform Y="30"/></Border.RenderTransform>
<Border.Effect>
<DropShadowEffect BlurRadius="30" ShadowDepth="8" Opacity="0.5" Color="#000000"/>
</Border.Effect>
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{StaticResource Warn}" VerticalAlignment="Center"/>
<TextBlock x:Name="SaveText" Text="Unsaved changes" Margin="10,0,18,0" VerticalAlignment="Center"/>
<Button Content="Revert" Padding="14,7" Click="Revert_Click"/>
<Button x:Name="SaveButton" Style="{StaticResource Primary}" Padding="18,8" FontSize="13.5" Margin="8,0,0,0" Click="Save_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74E;" FontSize="13" Margin="0,1,8,0"/>
<TextBlock Text="Save"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<Grid x:Name="Overlay" Background="#9917131F" Visibility="Collapsed" Opacity="0">
<Border x:Name="Dialog" Style="{StaticResource Card}" Background="#F21E1929" HorizontalAlignment="Center" VerticalAlignment="Center"
MaxWidth="460" Padding="26,24" RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform><ScaleTransform/></Border.RenderTransform>
<Border.Effect>
<DropShadowEffect BlurRadius="40" ShadowDepth="10" Opacity="0.5" Color="#000000"/>
</Border.Effect>
<StackPanel>
<TextBlock x:Name="DialogTitle" FontSize="18" FontWeight="Bold" FontFamily="Segoe UI Variable Display, Segoe UI"/>
<TextBlock x:Name="DialogText" TextWrapping="Wrap" Foreground="{StaticResource Dim}" Margin="0,8,0,0" LineHeight="20"/>
<StackPanel x:Name="DialogButtons" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,22,0,0">
<Button x:Name="DialogNo" Content="Cancel" MinWidth="92" Click="DialogNo_Click"/>
<Button x:Name="DialogYes" MinWidth="92" Margin="8,0,0,0" Style="{StaticResource Primary}" Padding="16,8" FontSize="13.5" Click="DialogYes_Click"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
<Border x:Name="ToastCard" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,30" CornerRadius="12"
Background="#F22C2440" BorderBrush="{StaticResource Line}" BorderThickness="1" Padding="16,10"
Opacity="0" IsHitTestVisible="False">
<Border.RenderTransform><TranslateTransform Y="16"/></Border.RenderTransform>
<Border.Effect>
<DropShadowEffect BlurRadius="24" ShadowDepth="6" Opacity="0.45" Color="#000000"/>
</Border.Effect>
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="ToastGlyph" Style="{StaticResource Glyph}" Text="&#xE73E;" FontSize="13" Foreground="{StaticResource Ok}" Margin="0,1,10,0"/>
<TextBlock x:Name="ToastText" FontSize="13"/>
</StackPanel>
</Border>
</Grid>
</Window>
+969
View File
@@ -0,0 +1,969 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.VisualBasic.FileIO;
namespace CapyLauncher
{
public partial class MainWindow : Window
{
enum State { Stopped, Starting, Online, Stopping, Crashed }
const int SwimFrames = 8;
const int MaxLines = 6000;
const string SrToolsUrl = "https://srtools.neonteam.dev/";
static readonly string[] PatchFiles = { "launcher.exe", "hkrpg.dll" };
static readonly Regex Authenticated = new Regex(@"uid (\d+) authenticated", RegexOptions.Compiled);
static readonly Regex BannerRow = new Regex(@"^\|\s*(dispatch|game)\s+(\S+)", RegexOptions.Compiled);
static readonly string[] SleepLines = { "Start the server to wake capy up", "capy is napping in the hot spring", "zzz… press start whenever" };
static readonly string[] OnlineLines = { "Ready when you are", "capy is vibing", "the hot spring is warm", "all systems capy" };
readonly string launcherDir = AppDomain.CurrentDomain.BaseDirectory.TrimEnd('\\');
readonly ServerHost server;
readonly ServerConfig config;
readonly Settings settings = Settings.Load();
readonly Random random = new Random();
readonly ObservableCollection<LogLine> lines = new ObservableCollection<LogLine>();
readonly ConcurrentQueue<string> incoming = new ConcurrentQueue<string>();
readonly List<ImageSource> swim = new List<ImageSource>();
readonly DispatcherTimer drain = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(60) };
readonly DispatcherTimer clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
readonly DispatcherTimer swimmer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
readonly DispatcherTimer toastTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.8) };
Catalog catalog;
BuildFile builds;
FileSystemWatcher watcher;
DateTime quietUntil;
readonly DispatcherTimer reloadTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
ListCollectionView view;
ScrollViewer logScroll;
TaskCompletionSource<bool> ready;
TaskCompletionSource<bool> answer;
State state = State.Stopped;
DateTime startedAt;
string dispatch, gamePort, lastError;
bool closing;
int frame;
double bob;
public MainWindow()
{
InitializeComponent();
server = new ServerHost(launcherDir);
config = ServerConfig.Load(server.Root);
server.Line += text => incoming.Enqueue(text);
server.Exited += (code, asked) => Dispatcher.BeginInvoke(new Action(() => OnExited(code, asked)));
view = (ListCollectionView)CollectionViewSource.GetDefaultView(lines);
view.Filter = Visible;
LogList.ItemsSource = view;
ShowDebug.IsChecked = settings.ShowDebug;
AutoScroll.IsChecked = settings.AutoScroll;
AutoScroll.Checked += (s, e) => { SaveToggles(); FollowEnd(); };
AutoScroll.Unchecked += (s, e) => SaveToggles();
LoadSwim();
drain.Tick += (s, e) => Drain();
clock.Tick += (s, e) => Tick();
swimmer.Tick += (s, e) => Swim();
toastTimer.Tick += (s, e) => HideToast();
drain.Start();
clock.Start();
swimmer.Start();
reloadTimer.Tick += (s, e) => { reloadTimer.Stop(); ExternalChange(); };
Loaded += (s, e) => OnLoaded();
Closing += OnClosing;
StateChanged += (s, e) => OnWindowState();
}
void OnLoaded()
{
logScroll = FindChild<ScrollViewer>(LogList);
ShowClient();
Apply(State.Stopped, instant: true);
PortsText.Text = config.HttpPort + "·" + config.GamePort;
Hint.Text = server.Exe == null ? "capysr.exe not found next to the launcher" : Shorten(server.Exe);
if (server.Exe == null)
ServerButton.IsEnabled = false;
Enter(ServerCard, 0);
Enter(GameCard, 70);
Enter(QuickCard, 140);
Enter(ConsoleCard, 110);
if (server.Strangers().Length > 0)
Toast("A CapySR server is already running outside the launcher", warn: true);
Dispatcher.BeginInvoke(new Action(() => MovePill(TabHome, false)), DispatcherPriority.Loaded);
LoadBuilds();
}
// ---- builds ----
async void LoadBuilds()
{
string catalogPath = Path.Combine(server.Root, "resources", "Catalog.json");
if (!File.Exists(catalogPath))
{
TabBuilds.IsEnabled = TabBattle.IsEnabled = false;
TabBuilds.ToolTip = TabBattle.ToolTip = "resources/Catalog.json is missing";
return;
}
try
{
catalog = await Task.Run(() => Catalog.Load(catalogPath));
builds = new BuildFile(Path.Combine(server.Root, config.SrToolsFile.Replace('/', '\\')));
builds.Load();
}
catch (Exception error)
{
TabBuilds.IsEnabled = TabBattle.IsEnabled = false;
Toast("Couldn't read the builds: " + error.Message, warn: true);
return;
}
Builds.Init(catalog, builds, BuildsChanged, (title, text, yes, danger) => Ask(title, text, yes, danger));
BattleView.Init(catalog, builds, BuildsChanged, config.BattleSource);
Watch();
}
void Watch()
{
string folder = Path.GetDirectoryName(builds.Path);
if (!Directory.Exists(folder))
return;
watcher = new FileSystemWatcher(folder, Path.GetFileName(builds.Path)) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName };
FileSystemEventHandler poke = (s, e) => Dispatcher.BeginInvoke(new Action(() => { reloadTimer.Stop(); reloadTimer.Start(); }));
watcher.Changed += poke;
watcher.Created += poke;
watcher.Renamed += (s, e) => poke(s, null);
watcher.EnableRaisingEvents = true;
}
void ExternalChange()
{
if (builds == null || !File.Exists(builds.Path) || File.GetLastWriteTimeUtc(builds.Path) == builds.LoadedStamp)
return;
if (DateTime.Now < quietUntil)
{
builds.Restamp();
return;
}
if (builds.Dirty)
{
Toast("freesr-data.json changed outside the launcher. Saving will overwrite that", warn: true);
return;
}
try
{
builds.Load();
}
catch (Exception)
{
return;
}
Builds.Reload();
BattleView.Reload();
Toast("Builds reloaded, they changed in game");
}
void BuildsChanged()
{
bool show = builds != null && builds.Dirty;
if (show == SaveBar.IsHitTestVisible)
return;
SaveBar.IsHitTestVisible = show;
Animate(SaveBar, OpacityProperty, show ? 1 : 0, 240);
Animate(SaveBar.RenderTransform, TranslateTransform.YProperty, show ? 0 : 30, 320);
}
async Task<bool> SaveBuilds()
{
if (builds == null)
return false;
try
{
if (server.Running)
{
if (state != State.Online)
{
Toast("Wait for the server to finish starting, then save", warn: true);
return false;
}
string reply = await builds.SendToServer(dispatch ?? config.Dispatch);
if (reply != "OK")
{
Toast("The server refused the build: " + reply, warn: true);
return false;
}
Toast(PlayerText.Text == "In game" ? "Saved. Relog in game to see the changes" : "Saved and sent to the server");
}
else
{
builds.WriteToDisk();
Toast("Saved to freesr-data.json");
}
}
catch (Exception error)
{
Toast("Couldn't save: " + error.Message, warn: true);
return false;
}
quietUntil = DateTime.Now.AddSeconds(2);
BuildsChanged();
return true;
}
async void Save_Click(object sender, RoutedEventArgs e) => await SaveBuilds();
void Revert_Click(object sender, RoutedEventArgs e)
{
if (builds == null)
return;
builds.Load();
Builds.Reload();
BattleView.Reload();
BuildsChanged();
Toast("Changes thrown away");
}
// ---- tabs ----
void Tab_Checked(object sender, RoutedEventArgs e)
{
if (!IsLoaded)
return;
var tab = (RadioButton)sender;
MovePill(tab, true);
FrameworkElement show = tab == TabBuilds ? BuildsHost : tab == TabBattle ? (FrameworkElement)BattleHost : HomePage;
foreach (FrameworkElement page in new FrameworkElement[] { HomePage, BuildsHost, BattleHost })
{
if (page != show)
page.Visibility = Visibility.Collapsed;
}
show.Visibility = Visibility.Visible;
var move = new TranslateTransform(0, 14);
show.RenderTransform = move;
show.Opacity = 0;
Animate(show, OpacityProperty, 1, 260);
Animate(move, TranslateTransform.YProperty, 0, 340);
}
void MovePill(RadioButton tab, bool animate)
{
Point at = tab.TranslatePoint(new Point(0, 0), NavTabs);
var move = (TranslateTransform)NavPill.RenderTransform;
NavPill.Height = tab.ActualHeight;
if (!animate)
{
NavPill.Width = tab.ActualWidth;
move.X = at.X;
return;
}
Animate(NavPill, WidthProperty, tab.ActualWidth, 320);
Animate(move, TranslateTransform.XProperty, at.X, 320);
}
// ---- window chrome ----
[DllImport("dwmapi.dll")]
static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
IntPtr hwnd = new WindowInteropHelper(this).Handle;
int dark = 1, round = 2, border = 0x4A2B35;
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int));
DwmSetWindowAttribute(hwnd, 34, ref border, sizeof(int));
}
void OnWindowState()
{
Shell.Margin = WindowState == WindowState.Maximized ? new Thickness(7) : new Thickness(0);
MaxButton.Content = WindowState == WindowState.Maximized ? "" : "";
}
void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
void Maximize_Click(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
void Close_Click(object sender, RoutedEventArgs e) => Close();
async void OnClosing(object sender, CancelEventArgs e)
{
if (closing)
return;
if (builds != null && builds.Dirty)
{
e.Cancel = true;
bool save = await Ask("Save your builds?", "You have changes in Builds or Battle that aren't saved yet.", "Save", false, "Discard");
if (save && !await SaveBuilds())
return;
builds.Dirty = false;
Close();
return;
}
if (!server.Running)
return;
e.Cancel = true;
closing = true;
OpenDialog("Saving and stopping…", "Giving the server a moment to save your progress before closing.", null);
await server.StopAsync();
Close();
}
// ---- animation helpers ----
static void Animate(DependencyObject target, DependencyProperty property, double to, int ms, int delay = 0, double? from = null)
{
var animation = new DoubleAnimation(to, TimeSpan.FromMilliseconds(ms))
{
BeginTime = TimeSpan.FromMilliseconds(delay),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
};
if (from.HasValue)
animation.From = from.Value;
((IAnimatable)target).BeginAnimation(property, animation);
}
static void Enter(FrameworkElement element, int delay)
{
var move = new TranslateTransform(0, 18);
element.RenderTransform = move;
element.Opacity = 0;
Animate(element, OpacityProperty, 1, 520, delay);
Animate(move, TranslateTransform.YProperty, 0, 620, delay);
}
void Fade(TextBlock block, string text)
{
if (block.Text == text)
return;
var fadeOut = new DoubleAnimation(0, TimeSpan.FromMilliseconds(110));
fadeOut.Completed += (s, e) =>
{
block.Text = text;
Animate(block, OpacityProperty, 1, 220);
};
block.BeginAnimation(OpacityProperty, fadeOut);
}
void Pulse(Brush color, bool on)
{
PillDot.Fill = color;
PulseRing.Fill = color;
var scale = (ScaleTransform)PulseRing.RenderTransform;
if (!on)
{
PulseRing.BeginAnimation(OpacityProperty, null);
scale.BeginAnimation(ScaleTransform.ScaleXProperty, null);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, null);
PulseRing.Opacity = 0;
return;
}
var grow = new DoubleAnimation(1, 2.8, TimeSpan.FromMilliseconds(1500)) { RepeatBehavior = RepeatBehavior.Forever, EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
var fade = new DoubleAnimation(0.6, 0, TimeSpan.FromMilliseconds(1500)) { RepeatBehavior = RepeatBehavior.Forever };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
PulseRing.BeginAnimation(OpacityProperty, fade);
}
// ---- capy ----
void LoadSwim()
{
var strip = new BitmapImage(new Uri("pack://application:,,,/capy_swim.png"));
int width = strip.PixelWidth / SwimFrames;
for (int i = 0; i < SwimFrames; i++)
swim.Add(new CroppedBitmap(strip, new Int32Rect(i * width, 0, width, strip.PixelHeight)));
Capy.Source = swim[0];
EmptyCapy.Source = swim[0];
}
void Swim()
{
bool awake = state == State.Online || state == State.Starting;
swimmer.Interval = TimeSpan.FromMilliseconds(state == State.Starting ? 80 : 120);
frame = (frame + 1) % SwimFrames;
if (awake)
{
Capy.Source = swim[frame];
bob += 0.35;
((TranslateTransform)Capy.RenderTransform).Y = Math.Sin(bob) * 1.5;
}
if (EmptyState.Visibility == Visibility.Visible)
EmptyCapy.Source = swim[frame];
}
// ---- server state ----
void Apply(State next, bool instant = false)
{
state = next;
var sleepy = SleepLines[random.Next(SleepLines.Length)];
switch (next)
{
case State.Stopped:
SetState("Sleeping", sleepy, "Offline", "Faint", false);
break;
case State.Starting:
SetState("Waking up…", "Loading the game tables", "Starting", "Warn", true);
break;
case State.Online:
SetState("Online", OnlineLines[random.Next(OnlineLines.Length)], "Online", "Ok", true);
break;
case State.Stopping:
SetState("Saving…", "Writing your progress and shutting down", "Stopping", "Warn", true);
break;
case State.Crashed:
SetState("Oh no :(", lastError ?? "The server stopped on its own. The console has the details.", "Stopped", "Fail", false);
break;
}
bool busy = next == State.Starting || next == State.Stopping;
bool up = next == State.Online || next == State.Starting;
ServerButton.IsEnabled = !busy && server.Exe != null;
ServerLabel.Text = up ? "Stop server" : next == State.Crashed ? "Start again" : "Start server";
ServerGlyph.Text = up ? "" : "";
ServerButton.Background = (Brush)FindResource(up ? "Danger" : "Capy");
FreshButton.IsEnabled = !up && next != State.Stopping;
PlayLabel.Text = next == State.Online ? "Play" : "Start server & play";
double awake = up ? 1 : 0.45;
if (instant)
{
Capy.Opacity = awake;
Water.Opacity = up ? 0.55 : 0.2;
}
else
{
Animate(Capy, OpacityProperty, awake, 400);
Animate(Water, OpacityProperty, up ? 0.55 : 0.2, 600);
}
if (!up)
{
Capy.Source = swim[0];
((TranslateTransform)Capy.RenderTransform).Y = 0;
UptimeText.Text = "—";
PlayerText.Text = "—";
PlayerText.Foreground = (Brush)FindResource("Text");
}
}
void SetState(string title, string sub, string pill, string color, bool pulse)
{
Fade(StateTitle, title);
Fade(StateSub, sub);
PillText.Text = pill;
PillText.Foreground = (Brush)FindResource(color == "Faint" ? "Dim" : color);
Pulse((Brush)FindResource(color), pulse);
}
void Tick()
{
if (state != State.Online && state != State.Starting)
return;
TimeSpan up = DateTime.Now - startedAt;
UptimeText.Text = up.TotalHours >= 1 ? ((int)up.TotalHours) + ":" + up.ToString(@"mm\:ss") : up.ToString(@"mm\:ss");
}
async Task<bool> StartServer()
{
if (server.Running)
return true;
if (server.Exe == null)
{
Toast("capysr.exe isn't next to the launcher", warn: true);
return false;
}
Process[] strangers = server.Strangers();
if (strangers.Length > 0)
{
bool takeOver = await Ask("Another server is running",
"A CapySR server was started outside the launcher, and two can't share the ports. Close that one and start here?",
"Close it", danger: true);
if (!takeOver)
return false;
foreach (Process stranger in strangers)
{
try { stranger.Kill(); stranger.WaitForExit(3000); } catch (Exception) { }
}
}
lastError = null;
dispatch = null;
gamePort = null;
ready = new TaskCompletionSource<bool>();
startedAt = DateTime.Now;
Apply(State.Starting);
UptimeText.Text = "00:00";
PlayerText.Text = "Waiting";
PlayerText.Foreground = (Brush)FindResource("Dim");
try
{
server.Start();
}
catch (Exception error)
{
lastError = error.Message;
Apply(State.Crashed);
return false;
}
Task finished = await Task.WhenAny(ready.Task, Task.Delay(TimeSpan.FromSeconds(60)));
return finished == ready.Task && ready.Task.Result;
}
async Task StopServer()
{
if (!server.Running)
return;
Apply(State.Stopping);
await server.StopAsync();
}
void OnExited(int code, bool asked)
{
ready?.TrySetResult(false);
Drain();
if (asked || code == 0)
{
Apply(State.Stopped);
if (!closing)
Toast("Server stopped, progress saved");
}
else
{
if (lastError == null)
lastError = "The server exited with code " + code + ".";
Apply(State.Crashed);
}
}
async void Server_Click(object sender, RoutedEventArgs e)
{
if (server.Running)
await StopServer();
else if (await StartServer())
Toast("Server is up, have fun!");
}
// ---- log ----
void Drain()
{
if (incoming.IsEmpty)
return;
LogLine previous = lines.Count > 0 ? lines[lines.Count - 1] : null;
int taken = 0;
while (taken < 800 && incoming.TryDequeue(out string raw))
{
taken++;
Watch(raw);
if (raw.Trim().Length == 0 || raw.TrimStart().StartsWith("+-") || raw.Trim().All(c => c == '-' || c == '+'))
continue;
if (raw.StartsWith("|"))
continue;
previous = LogLine.Parse(raw, previous);
lines.Add(previous);
}
while (lines.Count > MaxLines)
lines.RemoveAt(0);
EmptyState.Visibility = lines.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
LineCount.Text = lines.Count + " lines";
FollowEnd();
}
void Watch(string raw)
{
Match row = BannerRow.Match(raw);
if (row.Success)
{
if (row.Groups[1].Value == "dispatch")
dispatch = row.Groups[2].Value;
else
gamePort = row.Groups[2].Value.Split(':').Last();
PortsText.Text = Port(dispatch, config.HttpPort) + "·" + (gamePort ?? config.GamePort.ToString());
}
if (raw.Contains(" is ready") && state == State.Starting)
{
Apply(State.Online);
ready?.TrySetResult(true);
}
if (raw.Contains(" ERROR "))
lastError = raw.Substring(raw.IndexOf(" ERROR ") + 7).Trim();
Match login = Authenticated.Match(raw);
if (login.Success)
{
PlayerText.Text = "In game";
PlayerText.Foreground = (Brush)FindResource("Ok");
}
else if (raw.Contains(" disconnected (") && state == State.Online)
{
PlayerText.Text = "Offline";
PlayerText.Foreground = (Brush)FindResource("Dim");
}
else if (raw.Contains("accepting handshake") && state == State.Online)
{
PlayerText.Text = "Logging in";
PlayerText.Foreground = (Brush)FindResource("Warn");
}
}
static string Port(string url, int fallback)
{
if (url == null)
return fallback.ToString();
int colon = url.LastIndexOf(':');
return colon > 0 ? url.Substring(colon + 1).TrimEnd('/') : fallback.ToString();
}
bool Visible(object item)
{
var line = (LogLine)item;
bool shown = line.Kind == Level.Debug ? ShowDebug.IsChecked == true
: line.Kind == Level.Info ? ShowInfo.IsChecked == true
: line.Kind == Level.Warn ? ShowWarn.IsChecked == true
: ShowError.IsChecked == true;
if (!shown)
return false;
string search = SearchBox.Text;
return search.Length == 0
|| line.Message.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0
|| line.Tag.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0;
}
void FollowEnd()
{
if (AutoScroll.IsChecked == true)
logScroll?.ScrollToEnd();
}
void SaveToggles()
{
settings.ShowDebug = ShowDebug.IsChecked == true;
settings.AutoScroll = AutoScroll.IsChecked == true;
settings.Save();
}
void Filter_Click(object sender, RoutedEventArgs e)
{
SaveToggles();
view.Refresh();
FollowEnd();
}
void Search_Changed(object sender, TextChangedEventArgs e)
{
view?.Refresh();
FollowEnd();
}
void Copy_Click(object sender, RoutedEventArgs e)
{
var shown = (LogList.SelectedItems.Count > 0 ? LogList.SelectedItems.Cast<LogLine>() : view.Cast<LogLine>()).ToList();
if (shown.Count == 0)
return;
Clipboard.SetText(string.Join(Environment.NewLine, shown.Select(l => l.ToString())));
Toast("Copied " + shown.Count + (shown.Count == 1 ? " line" : " lines"));
}
void Clear_Click(object sender, RoutedEventArgs e)
{
lines.Clear();
EmptyState.Visibility = Visibility.Visible;
LineCount.Text = "";
}
// ---- game ----
string ClientExe => string.IsNullOrEmpty(settings.ClientFolder) ? null : Path.Combine(settings.ClientFolder, "StarRail.exe");
bool Patched()
{
if (string.IsNullOrEmpty(settings.ClientFolder))
return false;
return PatchFiles.All(name => SameFile(Path.Combine(server.Root, "launcher", name), Path.Combine(settings.ClientFolder, name)));
}
static bool SameFile(string a, string b)
{
if (!File.Exists(a) || !File.Exists(b) || new FileInfo(a).Length != new FileInfo(b).Length)
return false;
using (var md5 = MD5.Create())
{
byte[] left, right;
using (var stream = File.OpenRead(a)) left = md5.ComputeHash(stream);
using (var stream = File.OpenRead(b)) right = md5.ComputeHash(stream);
return left.SequenceEqual(right);
}
}
void ShowClient()
{
bool picked = !string.IsNullOrEmpty(settings.ClientFolder);
bool exe = picked && File.Exists(ClientExe);
bool patched = exe && Patched();
ClientPath.Text = picked ? settings.ClientFolder : "Pick your client folder";
ClientPath.ToolTip = picked ? settings.ClientFolder : null;
ClientPath.Foreground = (Brush)FindResource(picked ? "Text" : "Dim");
Mark(ExeMark, ExeText, exe, picked ? (exe ? "StarRail.exe" : "No StarRail.exe here") : "StarRail.exe");
Mark(PatchMark, PatchText, patched, patched ? "Patched" : exe ? "Patches on first play" : "Patch");
PatchMark.Foreground = (Brush)FindResource(patched ? "Ok" : exe ? "Warn" : "Faint");
PatchText.Foreground = PatchMark.Foreground;
PatchMark.Text = patched ? "" : exe ? "" : "";
PlayLabel.Text = state == State.Online ? "Play" : "Start server & play";
}
void Mark(TextBlock mark, TextBlock label, bool ok, string text)
{
mark.Text = ok ? "" : "";
mark.Foreground = (Brush)FindResource(ok ? "Ok" : string.IsNullOrEmpty(settings.ClientFolder) ? "Faint" : "Fail");
label.Foreground = mark.Foreground;
label.Text = text;
}
void Browse_Click(object sender, RoutedEventArgs e)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Pick StarRail.exe in your client folder",
Filter = "Star Rail|StarRail.exe|Any exe|*.exe",
};
if (Directory.Exists(settings.ClientFolder))
dialog.InitialDirectory = settings.ClientFolder;
if (dialog.ShowDialog(this) != true)
return;
settings.ClientFolder = Path.GetDirectoryName(dialog.FileName);
settings.Save();
ShowClient();
}
bool Patch()
{
foreach (string name in PatchFiles)
{
string source = Path.Combine(server.Root, "launcher", name);
string target = Path.Combine(settings.ClientFolder, name);
if (!File.Exists(source))
{
Toast("launcher/" + name + " is missing from CapySR", warn: true);
return false;
}
if (SameFile(source, target))
continue;
try
{
File.Copy(source, target, true);
}
catch (Exception)
{
Toast("Couldn't copy " + name + " into the client folder. Try running the launcher as admin", warn: true);
return false;
}
}
return true;
}
async void Play_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(settings.ClientFolder) || !File.Exists(ClientExe))
{
Toast("Pick your client folder first", warn: true);
Browse_Click(sender, e);
return;
}
PlayButton.IsEnabled = false;
try
{
if (!Patch())
return;
ShowClient();
if (!server.Running)
{
PlayLabel.Text = "Starting the server…";
if (!await StartServer())
{
Toast("The server didn't come up, check the console", warn: true);
return;
}
}
PlayLabel.Text = "Launching…";
try
{
Process.Start(new ProcessStartInfo(Path.Combine(settings.ClientFolder, "launcher.exe"))
{
UseShellExecute = true,
Verb = "runas",
WorkingDirectory = settings.ClientFolder,
});
Toast("Game is launching, see you in the hot spring");
}
catch (Win32Exception error) when (error.NativeErrorCode == 1223)
{
Toast("The game needs admin to start", warn: true);
}
}
finally
{
PlayButton.IsEnabled = true;
ShowClient();
}
}
// ---- quick actions ----
static void Open(string target)
{
try
{
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
}
catch (Exception) { }
}
void Config_Click(object sender, RoutedEventArgs e) => Open(Path.Combine(server.Root, "config", "config.json"));
void Data_Click(object sender, RoutedEventArgs e) => Open(Path.Combine(server.Root, "data"));
void LogFile_Click(object sender, RoutedEventArgs e)
{
string log = Path.Combine(server.Root, "logs", "capysr.log");
Open(File.Exists(log) ? log : Path.Combine(server.Root, "logs"));
}
void SrTools_Click(object sender, RoutedEventArgs e) => Open(SrToolsUrl);
void Unstick_Click(object sender, RoutedEventArgs e)
{
if (!server.Running)
{
Toast("Start the server first", warn: true);
return;
}
Open((dispatch ?? config.Dispatch).TrimEnd('/') + "/unstick");
}
async void Fresh_Click(object sender, RoutedEventArgs e)
{
string save = Path.Combine(server.Root, "data", "player.json");
if (!File.Exists(save))
{
Toast("Already fresh, there's no save yet");
return;
}
if (!await Ask("Start over?", "Your save (data/player.json) goes to the Recycle Bin, so you can still get it back. Your srtools builds stay.", "Start over", danger: true))
return;
try
{
FileSystem.DeleteFile(save, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
Toast("Save moved to the Recycle Bin");
}
catch (Exception error)
{
Toast("Couldn't move it: " + error.Message, warn: true);
}
}
// ---- dialog and toast ----
Task<bool> Ask(string title, string text, string yes, bool danger = false, string no = "Cancel")
{
answer = new TaskCompletionSource<bool>();
DialogYes.Content = yes;
DialogNo.Content = no;
DialogYes.Background = (Brush)FindResource(danger ? "Danger" : "Capy");
OpenDialog(title, text, yes);
return answer.Task;
}
void OpenDialog(string title, string text, string yes)
{
DialogTitle.Text = title;
DialogText.Text = text;
DialogButtons.Visibility = yes == null ? Visibility.Collapsed : Visibility.Visible;
Overlay.Visibility = Visibility.Visible;
var scale = (ScaleTransform)Dialog.RenderTransform;
Animate(Overlay, OpacityProperty, 1, 200);
Animate(scale, ScaleTransform.ScaleXProperty, 1, 260, 0, 0.94);
Animate(scale, ScaleTransform.ScaleYProperty, 1, 260, 0, 0.94);
Animate(ShellBlur, BlurEffect.RadiusProperty, 10, 260);
}
void HideDialog(bool result)
{
var fade = new DoubleAnimation(0, TimeSpan.FromMilliseconds(160));
fade.Completed += (s, e) => Overlay.Visibility = Visibility.Collapsed;
Overlay.BeginAnimation(OpacityProperty, fade);
Animate(ShellBlur, BlurEffect.RadiusProperty, 0, 200);
answer?.TrySetResult(result);
}
void DialogYes_Click(object sender, RoutedEventArgs e) => HideDialog(true);
void DialogNo_Click(object sender, RoutedEventArgs e) => HideDialog(false);
void Toast(string text, bool warn = false)
{
ToastText.Text = text;
ToastGlyph.Text = warn ? "" : "";
ToastGlyph.Foreground = (Brush)FindResource(warn ? "Warn" : "Ok");
Animate(ToastCard, OpacityProperty, 1, 220);
Animate(ToastCard.RenderTransform, TranslateTransform.YProperty, 0, 320);
toastTimer.Stop();
toastTimer.Start();
}
void HideToast()
{
toastTimer.Stop();
Animate(ToastCard, OpacityProperty, 0, 260);
Animate(ToastCard.RenderTransform, TranslateTransform.YProperty, 16, 300);
}
// ---- misc ----
static string Shorten(string path)
{
string[] parts = path.Split('\\');
return parts.Length <= 3 ? path : "…\\" + string.Join("\\", parts.Skip(parts.Length - 3));
}
static T FindChild<T>(DependencyObject parent) where T : DependencyObject
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
DependencyObject child = VisualTreeHelper.GetChild(parent, i);
if (child is T found)
return found;
T deeper = FindChild<T>(child);
if (deeper != null)
return deeper;
}
return null;
}
}
}
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Web.Script.Serialization;
using System.Windows;
using System.Windows.Media;
namespace CapyLauncher
{
public enum Level { Debug, Info, Warn, Error }
public class LogLine
{
static readonly Regex Shape = new Regex(@"^(\d\d:\d\d:\d\d\.\d{3})\s+(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\s+(\S+)\s+(.*)$", RegexOptions.Compiled);
static Brush Frozen(string hex)
{
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
brush.Freeze();
return brush;
}
static readonly Brush TextBrush = Frozen("#ECE3DA");
static readonly Brush DimBrush = Frozen("#9A8FA8");
static readonly Brush FaintBrush = Frozen("#6B6078");
static readonly Brush PinkBrush = Frozen("#FF9AA2");
static readonly Brush BlueBrush = Frozen("#7DC8FF");
static readonly Brush GreenBrush = Frozen("#9FE0A0");
static readonly Brush WarnBrush = Frozen("#FFD58A");
static readonly Brush FailBrush = Frozen("#FF6B7F");
static readonly Brush DebugBack = Frozen("#1A9A8FA8");
static readonly Brush InfoBack = Frozen("#1F7DC8FF");
static readonly Brush WarnBack = Frozen("#26FFD58A");
static readonly Brush FailBack = Frozen("#2EFF6B7F");
public string Time { get; private set; } = "";
public Level Kind { get; private set; } = Level.Info;
public string LevelName => Kind == CapyLauncher.Level.Debug ? "DEBUG" : Kind == CapyLauncher.Level.Warn ? "WARN" : Kind == CapyLauncher.Level.Error ? "ERROR" : "INFO";
public string Tag { get; private set; } = "";
public string Arrow { get; private set; } = "";
public string Message { get; private set; } = "";
public bool Continued { get; private set; }
public Visibility LevelVisibility => Continued ? Visibility.Hidden : Visibility.Visible;
public Brush LevelBrush => Kind == CapyLauncher.Level.Debug ? DimBrush : Kind == CapyLauncher.Level.Warn ? WarnBrush : Kind == CapyLauncher.Level.Error ? FailBrush : BlueBrush;
public Brush LevelBack => Kind == CapyLauncher.Level.Debug ? DebugBack : Kind == CapyLauncher.Level.Warn ? WarnBack : Kind == CapyLauncher.Level.Error ? FailBack : InfoBack;
public Brush TagBrush => Tag == "recv" || Tag == "send" ? FaintBrush : PinkBrush;
public Brush ArrowBrush => Tag == "recv" ? GreenBrush : BlueBrush;
public Brush MessageBrush => Continued ? DimBrush
: Kind == CapyLauncher.Level.Warn ? WarnBrush
: Kind == CapyLauncher.Level.Error ? FailBrush
: Kind == CapyLauncher.Level.Debug ? DimBrush : TextBrush;
public static LogLine Parse(string raw, LogLine previous)
{
Match match = Shape.Match(raw);
if (!match.Success)
{
return new LogLine
{
Kind = previous?.Kind ?? CapyLauncher.Level.Info,
Message = raw.Trim(),
Continued = true,
};
}
var line = new LogLine
{
Time = match.Groups[1].Value,
Kind = LevelOf(match.Groups[2].Value),
Tag = match.Groups[3].Value,
Message = match.Groups[4].Value,
};
if (line.Message.StartsWith("<- ") || line.Message.StartsWith("-> "))
{
line.Arrow = line.Message.StartsWith("<-") ? "← " : "→ ";
line.Message = line.Message.Substring(3);
}
return line;
}
static Level LevelOf(string name)
{
switch (name)
{
case "TRACE":
case "DEBUG": return CapyLauncher.Level.Debug;
case "WARN": return CapyLauncher.Level.Warn;
case "ERROR":
case "FATAL": return CapyLauncher.Level.Error;
default: return CapyLauncher.Level.Info;
}
}
public override string ToString() =>
Continued ? " " + Message : Time + " " + LevelName.PadRight(5) + " " + Tag + " " + Arrow + Message;
}
public class Settings
{
static readonly string Folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CapySR");
static readonly string FilePath = Path.Combine(Folder, "launcher.json");
public string ClientFolder { get; set; } = "";
public bool ShowDebug { get; set; }
public bool AutoScroll { get; set; } = true;
public static Settings Load()
{
try
{
if (File.Exists(FilePath))
return new JavaScriptSerializer().Deserialize<Settings>(File.ReadAllText(FilePath)) ?? new Settings();
}
catch (Exception) { }
return new Settings();
}
public void Save()
{
try
{
Directory.CreateDirectory(Folder);
File.WriteAllText(FilePath, new JavaScriptSerializer().Serialize(this));
}
catch (Exception) { }
}
}
public class ServerConfig
{
public string HttpHost { get; private set; } = "127.0.0.1";
public int HttpPort { get; private set; } = 21000;
public int GamePort { get; private set; } = 23301;
public string BattleSource { get; private set; } = "auto";
public string SrToolsFile { get; private set; } = "data/freesr-data.json";
public string Dispatch => "http://" + HttpHost + ":" + HttpPort;
public static ServerConfig Load(string root)
{
var config = new ServerConfig();
try
{
var doc = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(
File.ReadAllText(Path.Combine(root, "config", "config.json")));
if (doc.TryGetValue("gameplay", out object gameplay) && gameplay is Dictionary<string, object> play && play.TryGetValue("battle_source", out object source))
config.BattleSource = Convert.ToString(source);
if (doc.TryGetValue("paths", out object paths) && paths is Dictionary<string, object> p && p.TryGetValue("srtools_file", out object srtools))
config.SrToolsFile = Convert.ToString(srtools);
if (doc.TryGetValue("server", out object server) && server is Dictionary<string, object> s)
{
if (s.TryGetValue("http", out object http) && http is Dictionary<string, object> h)
{
if (h.TryGetValue("port", out object port)) config.HttpPort = Convert.ToInt32(port);
if (h.TryGetValue("public_host", out object host)) config.HttpHost = Convert.ToString(host);
}
if (s.TryGetValue("game", out object game) && game is Dictionary<string, object> g && g.TryGetValue("port", out object gamePort))
config.GamePort = Convert.ToInt32(gamePort);
}
}
catch (Exception) { }
return config;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CapyLauncher
{
public class ServerHost
{
Process process;
bool stopping;
public event Action<string> Line;
public event Action<int, bool> Exited;
public string Root { get; }
public string Exe { get; }
public bool Running => process != null && !process.HasExited;
public int Id => process?.Id ?? 0;
public ServerHost(string launcherDir)
{
Root = FindRoot(launcherDir) ?? launcherDir;
Exe = new[]
{
Path.Combine(launcherDir, "capysr.exe"),
Path.Combine(Root, "capysr.exe"),
Path.Combine(Root, "build", "bin", "capysr.exe"),
}.FirstOrDefault(File.Exists);
}
static string FindRoot(string dir)
{
for (int depth = 0; depth < 5 && dir != null; depth++)
{
if (File.Exists(Path.Combine(dir, "config", "config.json")))
return dir;
dir = Path.GetDirectoryName(dir);
}
return null;
}
public Process[] Strangers() =>
Process.GetProcessesByName("capysr").Where(p => p.Id != Id).ToArray();
public void Start()
{
var info = new ProcessStartInfo(Exe)
{
WorkingDirectory = Root,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
info.EnvironmentVariables["NO_COLOR"] = "1";
var started = new Process { StartInfo = info, EnableRaisingEvents = true };
started.OutputDataReceived += (s, e) => { if (e.Data != null) Line?.Invoke(e.Data); };
started.ErrorDataReceived += (s, e) => { if (e.Data != null) Line?.Invoke(e.Data); };
started.Exited += (s, e) =>
{
started.WaitForExit();
bool asked = stopping;
stopping = false;
Exited?.Invoke(started.ExitCode, asked);
};
stopping = false;
started.Start();
started.BeginOutputReadLine();
started.BeginErrorReadLine();
process = started;
}
public async Task StopAsync()
{
Process running = process;
if (running == null || running.HasExited)
return;
stopping = true;
try
{
running.StandardInput.WriteLine("stop");
running.StandardInput.Flush();
}
catch (Exception) { }
bool exited = await Task.Run(() => running.WaitForExit(8000));
if (!exited)
{
try { running.Kill(); } catch (Exception) { }
await Task.Run(() => running.WaitForExit(3000));
}
}
}
}
+150
View File
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CapyLauncher
{
public class StatLine
{
public string Label;
public string Prop;
public double Base;
public double Bonus;
public bool Percent;
public string Icon;
public double Total => Base + Bonus;
public string Show(double value) => Percent ? (value * 100).ToString("0.0") + "%" : Label == "SPD" ? value.ToString("0.#") : Math.Floor(value).ToString("#,0");
}
public static class StatSheet
{
static readonly Dictionary<string, string> Names = new Dictionary<string, string>
{
{ "HPDelta", "HP" }, { "AttackDelta", "ATK" }, { "DefenceDelta", "DEF" }, { "SpeedDelta", "SPD" },
{ "HPAddedRatio", "HP" }, { "AttackAddedRatio", "ATK" }, { "DefenceAddedRatio", "DEF" }, { "SpeedAddedRatio", "SPD" },
{ "CriticalChanceBase", "CRIT Rate" }, { "CriticalDamageBase", "CRIT DMG" }, { "StatusProbabilityBase", "Effect Hit Rate" },
{ "StatusResistanceBase", "Effect RES" }, { "BreakDamageAddedRatioBase", "Break Effect" }, { "HealRatioBase", "Outgoing Healing" },
{ "SPRatioBase", "Energy Regen" }, { "PhysicalAddedRatio", "Physical DMG" }, { "FireAddedRatio", "Fire DMG" },
{ "IceAddedRatio", "Ice DMG" }, { "ThunderAddedRatio", "Lightning DMG" }, { "WindAddedRatio", "Wind DMG" },
{ "QuantumAddedRatio", "Quantum DMG" }, { "ImaginaryAddedRatio", "Imaginary DMG" }, { "ElationDamageAddedRatioBase", "Elation DMG" },
};
public static string Describe(StatBonus bonus)
{
string name = Names.TryGetValue(bonus.Prop ?? "", out string found) ? found : bonus.Prop;
bool flat = bonus.Prop == "HPDelta" || bonus.Prop == "AttackDelta" || bonus.Prop == "DefenceDelta" || bonus.Prop == "SpeedDelta";
return name + " +" + (flat ? bonus.Value.ToString("0.#") : (bonus.Value * 100).ToString("0.#") + "%");
}
static readonly Dictionary<string, string> ElementProps = new Dictionary<string, string>
{
{ "Physical", "PhysicalAddedRatio" }, { "Fire", "FireAddedRatio" }, { "Ice", "IceAddedRatio" },
{ "Lightning", "ThunderAddedRatio" }, { "Wind", "WindAddedRatio" }, { "Quantum", "QuantumAddedRatio" },
{ "Imaginary", "ImaginaryAddedRatio" },
};
static double[] Step(double[][] growth, int promotion)
{
for (int p = Math.Min(promotion, growth.Length - 1); p >= 0; p--)
{
if (growth[p] != null)
return growth[p];
}
return null;
}
public static List<StatLine> Build(Catalog catalog, BuildFile file, AvatarInfo info, Dictionary<string, object> avatar)
{
var bonus = new Dictionary<string, double>();
void Add(string prop, double value)
{
if (string.IsNullOrEmpty(prop)) return;
bonus.TryGetValue(prop, out double now);
bonus[prop] = now + value;
}
int level = BuildFile.Int(avatar, "level", 1);
int promotion = BuildFile.Int(avatar, "promotion", BuildFile.PromotionFor(level));
double[] g = Step(info.Growth, promotion) ?? new double[10];
double hp = g[0] + g[1] * (level - 1), atk = g[2] + g[3] * (level - 1), def = g[4] + g[5] * (level - 1);
double spd = g[6], critRate = g[7], critDmg = g[8];
var cone = file.LightconeOf(info.Id);
var coneInfo = cone == null ? null : catalog.Lightcone(BuildFile.Int(cone, "item_id"));
if (coneInfo != null)
{
int coneLevel = BuildFile.Int(cone, "level", 1);
double[] c = Step(coneInfo.Growth, BuildFile.Int(cone, "promotion", BuildFile.PromotionFor(coneLevel)));
if (c != null)
{
hp += c[0] + c[1] * (coneLevel - 1);
atk += c[2] + c[3] * (coneLevel - 1);
def += c[4] + c[5] * (coneLevel - 1);
}
int rank = Math.Max(1, Math.Min(5, BuildFile.Int(cone, "rank", 1)));
if (coneInfo.Path == info.Path && coneInfo.Passive[rank - 1] != null)
{
foreach (StatBonus b in coneInfo.Passive[rank - 1]) Add(b.Prop, b.Value);
}
}
foreach (Anchor anchor in info.Anchors)
{
if (anchor.Stats.Count > 0 && BuildFile.AnchorLevel(avatar, anchor) > 0)
{
foreach (StatBonus b in anchor.Stats) Add(b.Prop, b.Value);
}
}
var sets = new Dictionary<int, int>();
for (int slot = 1; slot <= 6; slot++)
{
var relic = file.RelicOf(info.Id, slot);
if (relic == null) continue;
int relicLevel = BuildFile.Int(relic, "level");
AffixInfo main = catalog.Main(slot, BuildFile.Int(relic, "main_affix_id"));
if (main != null) Add(main.Prop, main.Base + main.PerLevel * relicLevel);
foreach (SubStat sub in BuildFile.Subs(relic))
{
AffixInfo affix = catalog.Sub(sub.Id);
if (affix != null) Add(affix.Prop, affix.Base * sub.Count + affix.Step * sub.Step);
}
int setId = BuildFile.Int(relic, "relic_set_id");
sets.TryGetValue(setId, out int count);
sets[setId] = count + 1;
}
foreach (var pair in sets)
{
RelicSetInfo set = catalog.RelicSet(pair.Key);
if (set == null) continue;
foreach (var need in set.Bonus)
{
if (pair.Value >= need.Key)
{
foreach (StatBonus b in need.Value) Add(b.Prop, b.Value);
}
}
}
double Get(string prop) => bonus.TryGetValue(prop, out double v) ? v : 0;
string element = ElementProps.TryGetValue(info.Element ?? "", out string found) ? found : null;
return new List<StatLine>
{
new StatLine { Label = "HP", Prop = "HP", Icon = Icons.Property("HPDelta"), Base = hp, Bonus = hp * Get("HPAddedRatio") + Get("HPDelta") },
new StatLine { Label = "ATK", Prop = "ATK", Icon = Icons.Property("AttackDelta"), Base = atk, Bonus = atk * Get("AttackAddedRatio") + Get("AttackDelta") },
new StatLine { Label = "DEF", Prop = "DEF", Icon = Icons.Property("DefenceDelta"), Base = def, Bonus = def * Get("DefenceAddedRatio") + Get("DefenceDelta") },
new StatLine { Label = "SPD", Prop = "SPD", Icon = Icons.Property("SpeedDelta"), Base = spd, Bonus = spd * Get("SpeedAddedRatio") + Get("SpeedDelta") },
new StatLine { Label = "CRIT Rate", Prop = "CRIT Rate", Icon = Icons.Property("CriticalChanceBase"), Base = critRate, Bonus = Get("CriticalChanceBase"), Percent = true },
new StatLine { Label = "CRIT DMG", Prop = "CRIT DMG", Icon = Icons.Property("CriticalDamageBase"), Base = critDmg, Bonus = Get("CriticalDamageBase"), Percent = true },
new StatLine { Label = "Break Effect", Prop = "Break Effect", Icon = Icons.Property("BreakDamageAddedRatioBase"), Bonus = Get("BreakDamageAddedRatioBase"), Percent = true },
new StatLine { Label = "Outgoing Healing", Prop = "Outgoing Healing", Icon = Icons.Property("HealRatioBase"), Bonus = Get("HealRatioBase"), Percent = true },
new StatLine { Label = "Energy Regen Rate", Prop = "Energy Regen Rate", Icon = Icons.Property("SPRatioBase"), Base = 1, Bonus = Get("SPRatioBase"), Percent = true },
new StatLine { Label = "Effect Hit Rate", Prop = "Effect Hit Rate", Icon = Icons.Property("StatusProbabilityBase"), Bonus = Get("StatusProbabilityBase"), Percent = true },
new StatLine { Label = "Effect RES", Prop = "Effect RES", Icon = Icons.Property("StatusResistanceBase"), Bonus = Get("StatusResistanceBase"), Percent = true },
new StatLine { Label = info.Element + " DMG Boost", Prop = info.Element + " DMG", Icon = Icons.ElementDamage(info.Element), Bonus = (element == null ? 0 : Get(element)) + Get("AllDamageTypeAddedRatio"), Percent = true },
};
}
}
}
+429
View File
@@ -0,0 +1,429 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
namespace CapyLauncher
{
public class PickItem : System.ComponentModel.INotifyPropertyChanged
{
ImageSource icon;
bool requested;
public int Id { get; set; }
public string Title { get; set; }
public string Sub { get; set; }
public Brush Accent { get; set; }
public object Tag { get; set; }
public string IconPath { get; set; }
public Visibility DotVisibility => icon == null ? Visibility.Visible : Visibility.Collapsed;
public ImageSource Icon
{
get
{
if (!requested && IconPath != null)
{
requested = true;
Load();
}
return icon;
}
}
async void Load()
{
icon = await Icons.Get(IconPath);
if (icon == null) return;
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon)));
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(DotVisibility)));
}
public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
}
public class Picker : Border
{
readonly TextBlock title = new TextBlock { TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center };
readonly Image chosen = new Image { Width = 22, Height = 22, Margin = new Thickness(0, 0, 9, 0), VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed };
readonly TextBlock sub = new TextBlock { FontSize = 11.5, Margin = new Thickness(8, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
readonly Popup popup = new Popup { AllowsTransparency = true, StaysOpen = false, Placement = PlacementMode.Bottom, PopupAnimation = PopupAnimation.Fade };
readonly TextBox search = new TextBox { Tag = "Type to search", Margin = new Thickness(0, 0, 0, 6), FontSize = 12.5 };
readonly ListBox list = new ListBox { Background = Brushes.Transparent, BorderThickness = new Thickness(0), MaxHeight = 320 };
readonly string placeholder;
public IList<PickItem> Items { get; set; } = new List<PickItem>();
public event Action<PickItem> Picked;
public Picker(string placeholder)
{
this.placeholder = placeholder;
Background = Ui.Brush("Field");
BorderBrush = Ui.Brush("Line");
BorderThickness = new Thickness(1);
CornerRadius = new CornerRadius(9);
Padding = new Thickness(11, 7, 10, 7);
Cursor = Cursors.Hand;
MinHeight = 34;
sub.Foreground = Ui.Brush("Faint");
var row = new DockPanel();
var chevron = new TextBlock { Text = "", FontFamily = (FontFamily)Application.Current.FindResource("Icons"), FontSize = 10, Foreground = Ui.Brush("Dim"), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 0, 0) };
DockPanel.SetDock(chevron, Dock.Right);
row.Children.Add(chevron);
var text = new StackPanel { Orientation = Orientation.Horizontal };
RenderOptions.SetBitmapScalingMode(chosen, BitmapScalingMode.HighQuality);
text.Children.Add(chosen);
text.Children.Add(title);
text.Children.Add(sub);
row.Children.Add(text);
Child = row;
Show(null);
list.ItemContainerStyle = (Style)Application.Current.FindResource("PickItem");
VirtualizingPanel.SetIsVirtualizing(list, true);
ScrollViewer.SetHorizontalScrollBarVisibility(list, ScrollBarVisibility.Disabled);
list.ItemTemplate = Ui.PickTemplate();
list.PreviewMouseLeftButtonUp += (s, e) => Choose();
list.KeyDown += (s, e) => { if (e.Key == Key.Enter) Choose(); };
search.TextChanged += (s, e) => Filter();
search.PreviewKeyDown += (s, e) =>
{
if (e.Key == Key.Down && list.Items.Count > 0)
{
list.SelectedIndex = 0;
((UIElement)list.ItemContainerGenerator.ContainerFromIndex(0))?.Focus();
e.Handled = true;
}
else if (e.Key == Key.Enter && list.Items.Count > 0)
{
list.SelectedIndex = 0;
Choose();
}
};
var panel = new DockPanel();
DockPanel.SetDock(search, Dock.Top);
panel.Children.Add(search);
panel.Children.Add(list);
popup.Child = new Border
{
Background = Ui.Brush("Bg"), BorderBrush = Ui.Brush("Line"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(12),
Padding = new Thickness(8), Margin = new Thickness(0, 6, 12, 12), Child = panel,
Effect = new System.Windows.Media.Effects.DropShadowEffect { BlurRadius = 24, ShadowDepth = 6, Opacity = 0.5, Color = Colors.Black },
};
popup.PlacementTarget = this;
popup.Opened += (s, e) => { search.Focus(); search.SelectAll(); };
MouseEnter += (s, e) => BorderBrush = Ui.Brush("Pink");
MouseLeave += (s, e) => { if (!popup.IsOpen) BorderBrush = Ui.Brush("Line"); };
popup.Closed += (s, e) => BorderBrush = Ui.Brush("Line");
MouseLeftButtonUp += (s, e) => Open();
}
public void Show(PickItem item)
{
chosen.Visibility = Visibility.Collapsed;
chosen.Source = null;
if (item?.IconPath != null)
{
chosen.Visibility = Visibility.Visible;
chosen.Opacity = 0;
Icons.Fill(chosen, item.IconPath);
}
title.Text = item?.Title ?? placeholder;
title.Foreground = Ui.Brush(item == null ? "Faint" : "Text");
sub.Text = item?.Sub ?? "";
}
void Open()
{
popup.MinWidth = Math.Max(ActualWidth, 280);
search.Text = "";
Filter();
popup.IsOpen = true;
}
void Filter()
{
string query = search.Text.Trim();
IEnumerable<PickItem> found = Items;
if (query.Length > 0)
{
found = Items.Where(i => i.Title.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0
|| i.Id.ToString().StartsWith(query)
|| (i.Sub ?? "").IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0);
}
list.ItemsSource = found.Take(250).ToList();
}
void Choose()
{
if (!(list.SelectedItem is PickItem item))
return;
popup.IsOpen = false;
Show(item);
Picked?.Invoke(item);
}
}
public static class Ui
{
public static Brush Brush(string key) => (Brush)Application.Current.FindResource(key);
public static DataTemplate PickTemplate()
{
var template = new DataTemplate(typeof(PickItem));
var row = new FrameworkElementFactory(typeof(DockPanel));
var lead = new FrameworkElementFactory(typeof(Grid));
lead.SetValue(FrameworkElement.WidthProperty, 26.0);
lead.SetValue(FrameworkElement.HeightProperty, 26.0);
lead.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 10, 0));
var dot = new FrameworkElementFactory(typeof(Border));
dot.SetValue(Border.WidthProperty, 8.0);
dot.SetValue(Border.HeightProperty, 8.0);
dot.SetValue(Border.CornerRadiusProperty, new CornerRadius(4));
dot.SetBinding(Border.BackgroundProperty, new System.Windows.Data.Binding("Accent"));
dot.SetBinding(UIElement.VisibilityProperty, new System.Windows.Data.Binding("DotVisibility"));
lead.AppendChild(dot);
var picture = new FrameworkElementFactory(typeof(Image));
picture.SetBinding(Image.SourceProperty, new System.Windows.Data.Binding("Icon"));
picture.SetValue(RenderOptions.BitmapScalingModeProperty, BitmapScalingMode.HighQuality);
lead.AppendChild(picture);
row.AppendChild(lead);
var sub = new FrameworkElementFactory(typeof(TextBlock));
sub.SetValue(DockPanel.DockProperty, Dock.Right);
sub.SetBinding(TextBlock.TextProperty, new System.Windows.Data.Binding("Sub"));
sub.SetValue(TextBlock.ForegroundProperty, Brush("Faint"));
sub.SetValue(TextBlock.FontSizeProperty, 11.5);
sub.SetValue(FrameworkElement.MarginProperty, new Thickness(12, 0, 0, 0));
sub.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center);
row.AppendChild(sub);
var title = new FrameworkElementFactory(typeof(TextBlock));
title.SetBinding(TextBlock.TextProperty, new System.Windows.Data.Binding("Title"));
title.SetValue(TextBlock.ForegroundProperty, Brush("Text"));
title.SetValue(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis);
row.AppendChild(title);
template.VisualTree = row;
return template;
}
public static TextBlock Caption(string text, double top = 0) =>
new TextBlock { Text = text, Style = (Style)Application.Current.FindResource("Caption"), Margin = new Thickness(0, top, 0, 8) };
public static TextBlock Text(string text, string color = "Text", double size = 13.5) =>
new TextBlock { Text = text, Foreground = Brush(color), FontSize = size, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
public static Border Section(string caption, UIElement content, string glyph = null)
{
var stack = new StackPanel();
var head = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 12) };
if (glyph != null)
head.Children.Add(new TextBlock { Text = glyph, FontFamily = (FontFamily)Application.Current.FindResource("Icons"), Foreground = Brush("Pink"), FontSize = 13, Margin = new Thickness(0, 0, 9, 0), VerticalAlignment = VerticalAlignment.Center });
head.Children.Add(new TextBlock { Text = caption, FontSize = 14, FontWeight = FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center });
stack.Children.Add(head);
stack.Children.Add(content);
return new Border
{
Background = (Brush)new BrushConverter().ConvertFromString("#0AFFFFFF"), BorderBrush = Brush("SoftLine"), BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14), Padding = new Thickness(18, 16, 18, 18), Margin = new Thickness(0, 0, 0, 14), Child = stack,
};
}
public static Button Button(string text, RoutedEventHandler click, string glyph = null, bool primary = false, double size = 12.5)
{
object content = text;
if (glyph != null)
{
var row = new StackPanel { Orientation = Orientation.Horizontal };
row.Children.Add(new TextBlock { Text = glyph, FontFamily = (FontFamily)Application.Current.FindResource("Icons"), FontSize = size, Margin = new Thickness(0, 1, text.Length > 0 ? 7 : 0, 0), VerticalAlignment = VerticalAlignment.Center });
if (text.Length > 0)
row.Children.Add(new TextBlock { Text = text, VerticalAlignment = VerticalAlignment.Center });
content = row;
}
var button = new Button { Content = content, FontSize = size, Padding = new Thickness(11, 6, 11, 6) };
if (primary)
{
button.Style = (Style)Application.Current.FindResource("Primary");
button.Padding = new Thickness(16, 9, 16, 9);
button.FontSize = 13.5;
}
button.Click += click;
return button;
}
public static FrameworkElement Slider(int min, int max, int value, Action<int> changed, Func<int, string> label = null, double width = double.NaN)
{
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(58) });
var slider = new Slider { Minimum = min, Maximum = max, Value = Math.Max(min, Math.Min(max, value)), VerticalAlignment = VerticalAlignment.Center, Width = width };
var shown = new TextBlock { FontFamily = (FontFamily)Application.Current.FindResource("Mono"), FontSize = 12.5, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center };
shown.Text = label != null ? label(value) : value.ToString();
Grid.SetColumn(shown, 1);
slider.ValueChanged += (s, e) =>
{
int v = (int)Math.Round(e.NewValue);
shown.Text = label != null ? label(v) : v.ToString();
changed(v);
};
grid.Children.Add(slider);
grid.Children.Add(shown);
return grid;
}
public static FrameworkElement Segments(IEnumerable<string> labels, int selected, Action<int> changed)
{
var row = new WrapPanel();
string group = Guid.NewGuid().ToString();
int index = 0;
foreach (string label in labels)
{
int mine = index++;
var option = new RadioButton { Content = label, GroupName = group, Style = (Style)Application.Current.FindResource("Seg"), IsChecked = mine == selected, Margin = new Thickness(0, 0, 5, 5) };
option.Checked += (s, e) => changed(mine);
row.Children.Add(option);
}
return row;
}
public static FrameworkElement Stepper(int min, int max, int value, Action<int> changed, double width = 96)
{
var box = new Border { Background = Brush("Field"), BorderBrush = Brush("Line"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Width = width, Height = 32 };
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
var number = new TextBox
{
Style = null, Text = value.ToString(), Background = Brushes.Transparent, BorderThickness = new Thickness(0),
HorizontalContentAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
FontFamily = (FontFamily)Application.Current.FindResource("Mono"), FontSize = 12.5, Foreground = Brush("Text"),
CaretBrush = Brush("Pink"), SelectionBrush = Brush("Pink"),
};
int current = value;
void Set(int v)
{
v = Math.Max(min, Math.Min(max, v));
number.Text = v.ToString();
if (v == current) return;
current = v;
changed(v);
}
number.LostKeyboardFocus += (s, e) => Set(int.TryParse(number.Text, out int typed) ? typed : current);
number.KeyDown += (s, e) => { if (e.Key == Key.Enter) Set(int.TryParse(number.Text, out int typed) ? typed : current); };
number.PreviewMouseWheel += (s, e) => { Set(current + (e.Delta > 0 ? 1 : -1)); e.Handled = true; };
var minus = Glyph("", () => Set(current - 1));
var plus = Glyph("", () => Set(current + 1));
Grid.SetColumn(number, 1);
Grid.SetColumn(plus, 2);
grid.Children.Add(minus);
grid.Children.Add(number);
grid.Children.Add(plus);
box.Child = grid;
return box;
}
static FrameworkElement Glyph(string glyph, Action click)
{
var text = new TextBlock { Text = glyph, FontFamily = (FontFamily)Application.Current.FindResource("Icons"), FontSize = 10, Foreground = Brush("Dim"), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
var hit = new Border { Background = Brushes.Transparent, Cursor = Cursors.Hand, Child = text };
hit.MouseEnter += (s, e) => text.Foreground = Brush("Pink");
hit.MouseLeave += (s, e) => text.Foreground = Brush("Dim");
hit.MouseLeftButtonUp += (s, e) => click();
return hit;
}
public static CheckBox Toggle(string text, bool on, Action<bool> changed)
{
var toggle = new CheckBox { Content = text, IsChecked = on, Foreground = Brush("Text"), FontSize = 12.5 };
toggle.Checked += (s, e) => changed(true);
toggle.Unchecked += (s, e) => changed(false);
return toggle;
}
public static Grid Row(params (UIElement element, GridLength width)[] cells)
{
var grid = new Grid { Margin = new Thickness(0, 0, 0, 10) };
int column = 0;
foreach (var (element, width) in cells)
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = width });
if (element != null)
{
Grid.SetColumn(element, column);
grid.Children.Add(element);
}
column++;
}
return grid;
}
public static GridLength Px(double px) => new GridLength(px);
public static GridLength Star(double share = 1) => new GridLength(share, GridUnitType.Star);
public static readonly GridLength Auto = GridLength.Auto;
public static FrameworkElement Portrait(AvatarInfo avatar, double size)
{
var frame = new Border { Width = size, Height = size, CornerRadius = new CornerRadius(size * 0.32), Background = Palette.Element(avatar?.Element), ClipToBounds = true };
var inner = new Grid();
inner.Children.Add(Badge(avatar, size));
var image = new Image { Width = size, Height = size, Stretch = Stretch.UniformToFill, Opacity = 0 };
RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality);
image.Clip = new RectangleGeometry(new Rect(0, 0, size, size), size * 0.32, size * 0.32);
inner.Children.Add(image);
frame.Child = inner;
if (avatar != null)
Icons.Fill(image, Icons.Character(avatar.Id), inner.Children[0]);
return frame;
}
public static Border Badge(AvatarInfo avatar, double size)
{
var badge = new Border
{
Width = size, Height = size, CornerRadius = new CornerRadius(size * 0.32),
Background = Palette.Element(avatar?.Element),
Child = new TextBlock
{
Text = avatar?.Initials ?? "?", Foreground = (Brush)new BrushConverter().ConvertFromString("#17131F"), FontWeight = FontWeights.Bold,
FontSize = size * 0.36, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
},
};
return badge;
}
public static Border Chip(string text, Brush accent, string icon = null)
{
var row = new StackPanel { Orientation = Orientation.Horizontal };
if (icon != null)
{
var dotFallback = new Border { Width = 7, Height = 7, CornerRadius = new CornerRadius(3.5), Background = accent };
var view = Icons.View(icon, 16, dotFallback);
view.Margin = new Thickness(-2, 0, 5, 0);
view.VerticalAlignment = VerticalAlignment.Center;
row.Children.Add(view);
}
else
{
row.Children.Add(new Border { Width = 7, Height = 7, CornerRadius = new CornerRadius(3.5), Background = accent, Margin = new Thickness(0, 0, 6, 0), VerticalAlignment = VerticalAlignment.Center });
}
row.Children.Add(new TextBlock { Text = text, FontSize = 11.5, Foreground = Brush("Text") });
return new Border { Background = (Brush)new BrushConverter().ConvertFromString("#14FFFFFF"), CornerRadius = new CornerRadius(10), Padding = new Thickness(9, 3, 10, 3), Margin = new Thickness(0, 0, 6, 0), Child = row };
}
public static void FadeIn(UIElement element, double fromY = 10)
{
var move = new TranslateTransform(0, fromY);
element.RenderTransform = move;
var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
element.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(260)) { EasingFunction = ease });
move.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(fromY, 0, TimeSpan.FromMilliseconds(320)) { EasingFunction = ease });
}
}
}
+7
View File
@@ -0,0 +1,7 @@
@echo off
setlocal
cd /d "%~dp0"
dotnet build -c Release -nologo -v q || exit /b 1
if not exist ..\build\bin mkdir ..\build\bin
copy /y bin\Release\CapyLauncher.exe ..\build\bin\CapyLauncher.exe >nul
echo Built ..\build\bin\CapyLauncher.exe
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

+1
View File
@@ -0,0 +1 @@
{"version":"4.7 beta v3 (4.6.53)","avatars":[{"id":1001,"name":"March 7th (Preservation)","rarity":4,"element":"Ice","path":"Preservation","max_rank":6,"sp":120,"anchors":[{"anchor":1,"max":6,"points":[1001001]},{"anchor":2,"max":10,"points":[1001002]},{"anchor":3,"max":10,"points":[1001003]},{"anchor":4,"max":10,"points":[1001004]},{"anchor":5,"max":1,"points":[1001007]},{"anchor":6,"max":1,"points":[1001101]},{"anchor":7,"max":1,"points":[1001102]},{"anchor":8,"max":1,"points":[1001103]},{"anchor":9,"max":1,"points":[1001201],"stats":[{"prop":"IceAddedRatio","value":0.032}]},{"anchor":10,"max":1,"points":[1001202],"stats":[{"prop":"DefenceAddedRatio","value":0.05}]},{"anchor":11,"max":1,"points":[1001203],"stats":[{"prop":"IceAddedRatio","value":0.032}]},{"anchor":12,"max":1,"points":[1001204],"stats":[{"prop":"StatusResistanceBase","value":0.04}]},{"anchor":13,"max":1,"points":[1001205],"stats":[{"prop":"IceAddedRatio","value":0.048}]},{"anchor":14,"max":1,"points":[1001206],"stats":[{"prop":"StatusResistanceBase","value":0.06}]},{"anchor":15,"max":1,"points":[1001207],"stats":[{"prop":"DefenceAddedRatio","value":0.075}]},{"anchor":16,"max":1,"points":[1001208],"stats":[{"prop":"IceAddedRatio","value":0.048}]},{"anchor":17,"max":1,"points":[1001209],"stats":[{"prop":"IceAddedRatio","value":0.064}]},{"anchor":18,"max":1,"points":[1001210],"stats":[{"prop":"DefenceAddedRatio","value":0.1}]}],"enhanced":false,"growth":[[144.0,7.2,69.6,3.48,78.0,3.9,101.0,0.05,0.5,150.0],[201.6,7.2,97.44,3.48,109.2,3.9,101.0,0.05,0.5,150.0],[259.2,7.2,125.28,3.48,140.4,3.9,101.0,0.05,0.5,150.0],[316.8,7.2,153.12,3.48,171.6,3.9,101.0,0.05,0.5,150.0],[374.4,7.2,180.96,3.48,202.8,3.9,101.0,0.05,0.5,150.0],[432.0,7.2,208.8,3.48,234.0,3.9,101.0,0.05,0.5,150.0],[489.6,7.2,236.64,3.48,265.2,3.9,101.0,0.05,0.5,150.0]]},{"id":1002,"name":"Dan Heng","rarity":4,"element":"Wind","path":"The Hunt","max_rank":6,"sp":100,"anchors":[{"anchor":1,"max":6,"points":[1002001]},{"anchor":2,"max":10,"points":[1002002]},{"anchor":3,"max":10,"points":[1002003]},{"anchor":4,"max":10,"points":[1002004]},{"anchor":5,"max":1,"points":[1002007]},{"anchor":6,"max":1,"points":[1002101]},{"anchor":7,"max":1,"points":[1002102]},{"anchor":8,"max":1,"points":[1002103]},{"anchor":9,"max":1,"points":[1002201],"stats":[{"prop":"WindAddedRatio","value":0.032}]},{"anchor":10,"max":1,"points":[1002202],"stats":[{"prop":"AttackAddedRatio","value":0.04}]},{"anchor":11,"max":1,"points":[1002203],"stats":[{"prop":"WindAddedRatio","value":0.032}]},{"anchor":12,"max":1,"points":[1002204],"stats":[{"prop":"DefenceAddedRatio","value":0.05}]},{"anchor":13,"max":1,"points":[1002205],"stats":[{"prop":"WindAddedRatio","value":0.048}]},{"anchor":14,"max":1,"points":[1002206],"stats":[{"prop":"AttackAddedRatio","value":0.06}]},{"anchor":15,"max":1,"points":[1002207],"stats":[{"prop":"WindAddedRatio","value":0.048}]},{"anchor":16,"max":1,"points":[1002208],"stats":[{"prop":"DefenceAddedRatio","value":0.075}]},{"anchor":17,"max":1,"points":[1002209],"stats":[{"prop":"AttackAddedRatio","value":0.08}]},{"anchor":18,"max":1,"points":[1002210],"stats":[{"prop":"WindAddedRatio","value":0.064}]}],"enhanced":false,"growth":[[120.0,6.0,74.4,3.72,54.0,2.7,110.0,0.05,0.5,75.0],[168.0,6.0,104.16,3.72,75.6,2.7,110.0,0.05,0.5,75.0],[216.0,6.0,133.92,3.72,97.2,2.7,110.0,0.05,0.5,75.0],[264.0,6.0,163.68,3.72,118.8,2.7,110.0,0.05,0.5,75.0],[312.0,6.0,193.44,3.72,140.4,2.7,110.0,0.05,0.5,75.0],[360.0,6.0,223.2,3.72,162.0,2.7,110.0,0.05,0.5,75.0],[408.0,6.0,252.96,3.72,183.6,2.7,110.0,0.05,0.5,75.0]]},{"id":1003,"name":"Himeko","rarity":5,"element":"Fire","path":"Erudition","max_rank":6,"sp":120,"anchors":[{"anchor":1,"max":6,"points":[1003001]},{"anchor":2,"max":10,"points":[1003002]},{"anchor":3,"max":10,"points":[1003003]},{"anchor":4,"max":10,"points":[1003004]},{"anchor":5,"max":1,"points":[1003007]},{"anchor":6,"max":1,"points":[1003101]},{"anchor":7,"max":1,"points":[1003102]},{"anchor":8,"max":1,"points":[1003103]},{"anchor":9,"max":1,"points":[1003201],"stats":[{"prop":"FireAddedRatio","value":0.032}]},{"anchor":10,"max":1,"points":[1003202],"stats":[{"prop":"AttackAddedRatio","value":0.04}]},{"anchor":11,"max":1,"points":[1003203],"stats":[{"prop":"FireAddedRatio","value":0.032}]},{"anchor":12,"max":1,"points":[1003204],"stats":[{"prop":"StatusResistanceBase","value":0.04}]},{"anchor":13,"max":1,"points":[1003205],"stats":[{"prop":"FireAddedRatio","value":0.048}]},{"anchor":14,"max":1,"points":[1003206],"stats":[{"prop":"AttackAddedRatio","value":0.06}]},{"anchor":15,"max":1,"points":[1003207],"stats":[{"prop":"FireAddedRatio","value":0.048}]},{"anchor":16,"max":1,"points":[1003208],"stats":[{"prop":"StatusResistanceBase","value":0.06}]},{"anchor":17,"max":1,"points":[1003209],"stats":[{"prop":"AttackAddedRatio","value":0.08}]},{"anchor":18,"max":1,"points":[1003210],"stats":[{"prop":"FireAddedRatio","value":0.064}]}],"enhanced":false,"growth":[[142.56,7.128,102.96,5.148,59.4,2.97,96.0,0.Line truncated
+7
View File
@@ -199,6 +199,13 @@ bool writeFile(const std::string& path, std::string_view data) {
return writeTo(target);
}
std::string keepCopy(const std::string& path, std::string_view suffix) {
std::string copy = path + std::string(suffix);
std::error_code ec;
std::filesystem::copy_file(path, copy, std::filesystem::copy_options::overwrite_existing, ec);
return ec ? std::string() : copy;
}
std::string executableDir() {
std::error_code ec;
#ifdef _WIN32
+1
View File
@@ -28,6 +28,7 @@ std::string randomHex(size_t bytes);
std::string readFile(const std::string& path, bool* ok = nullptr);
bool writeFile(const std::string& path, std::string_view data);
bool fileExists(const std::string& path);
std::string keepCopy(const std::string& path, std::string_view suffix);
std::string executableDir();
std::string findRootDir(const std::string& marker);
+7 -3
View File
@@ -107,12 +107,16 @@ void Player::load() {
}
void Player::save() {
uint64_t now = util::nowMs();
if (now - lastSaveMs_ < kSaveIntervalMs) return;
saveNow();
unsaved_ = true;
savePending();
}
void Player::savePending() {
if (unsaved_ && util::nowMs() - lastSaveMs_ >= kSaveIntervalMs) saveNow();
}
void Player::saveNow() {
unsaved_ = false;
lastSaveMs_ = util::nowMs();
files::writeLater(core::Config::get().paths.playerFile, playerStateJson(*this));
}
+2
View File
@@ -222,6 +222,7 @@ public:
void load();
void save();
void saveNow();
void savePending();
private:
uint32_t uid_;
@@ -257,5 +258,6 @@ private:
bool globalBuffs_ = true;
uint32_t battleIdCursor_ = 0;
uint64_t lastSaveMs_ = 0;
bool unsaved_ = false;
};
}
+11 -1
View File
@@ -42,6 +42,11 @@ std::vector<uint32_t> u32Array(const json& j, const char* key) {
}
return out;
}
void keepBroken(const std::string& path) {
std::string copy = util::keepCopy(path, ".broken");
if (!copy.empty()) logging::warn("player", "kept the unreadable file as {}", copy);
}
}
bool loadPlayerState(Player& player) {
@@ -59,9 +64,14 @@ bool loadPlayerState(Player& player) {
j = json::parse(text, nullptr, true, true);
} catch (const std::exception& e) {
logging::error("player", "{} is not valid json: {}", path, e.what());
keepBroken(path);
return false;
}
if (!j.is_object()) {
logging::error("player", "{} is not a json object", path);
keepBroken(path);
return false;
}
if (!j.is_object()) return false;
if (auto it = j.find("name"); it != j.end() && it->is_string()) {
player.setName(it->get<std::string>());
+2
View File
@@ -291,6 +291,8 @@ bool SrTools::loadFromDisk() {
auto parsed = parse(text, error);
if (!parsed) {
logging::error("srtools", "{}: {}", path, error);
std::string copy = util::keepCopy(path, ".broken");
if (!copy.empty()) logging::warn("srtools", "kept the unreadable file as {}", copy);
return false;
}
logging::info("srtools", "loaded {} avatars, {} lightcones, {} relics from {}",
+39 -1
View File
@@ -1,9 +1,15 @@
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <iostream>
#include <string>
#include <thread>
#ifdef _WIN32
#include <windows.h>
#endif
#include "core/config.h"
#include "core/crash.h"
#include "core/files.h"
@@ -21,8 +27,34 @@
namespace {
std::atomic<bool> g_running{true};
std::atomic<bool> g_stopped{false};
void onSignal(int) { g_running = false; }
void watchStdin() {
std::thread([] {
std::string line;
while (std::getline(std::cin, line)) {
if (line == "stop" || line == "quit") {
g_running = false;
return;
}
}
}).detach();
}
#ifdef _WIN32
BOOL WINAPI onConsoleClose(DWORD event) {
if (event != CTRL_CLOSE_EVENT && event != CTRL_LOGOFF_EVENT && event != CTRL_SHUTDOWN_EVENT) {
return FALSE;
}
g_running = false;
for (int waited = 0; !g_stopped && waited < 4500; waited += 50) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
return TRUE;
}
#endif
}
int main(int argc, char** argv) {
@@ -44,7 +76,8 @@ int main(int argc, char** argv) {
auto& config = core::Config::get();
config.load(configPath);
logging::init(logging::parseLevel(config.log.level), config.log.color, config.log.file);
bool color = config.log.color && std::getenv("NO_COLOR") == nullptr;
logging::init(logging::parseLevel(config.log.level), color, config.log.file);
crash::install("logs");
logging::info("capysr", "CapySR starting ({})", config.serverName);
@@ -83,6 +116,10 @@ int main(int argc, char** argv) {
std::signal(SIGINT, onSignal);
std::signal(SIGTERM, onSignal);
#ifdef _WIN32
SetConsoleCtrlHandler(onConsoleClose, TRUE);
#endif
watchStdin();
while (g_running) std::this_thread::sleep_for(std::chrono::milliseconds(200));
logging::info("capysr", "shutting down");
@@ -90,5 +127,6 @@ int main(int argc, char** argv) {
web.stop();
files::flush();
logging::shutdown();
g_stopped = true;
return 0;
}
+1
View File
@@ -150,6 +150,7 @@ void Session::flushOutbound() {
void Session::update(uint32_t nowMs) {
std::lock_guard lock(mutex_);
if (kcp_) ikcp_update(kcp_, nowMs);
if (player_) player_->savePending();
if (emptyReplyCount_ != 0 && util::nowMs() >= lastEmptyReplyMs_ + kEmptyReplyQuietMs) {
flushEmptyReplies();
}