feat(gui): launcher
This commit is contained in:
1 parent
f21415486c
commit
679b041b09
33 files changed
+4934
-17
No files matched your search
@@ -11,3 +11,7 @@ src/net/cmd_ids.cpp
|
||||
*.user
|
||||
tools/
|
||||
tests/
|
||||
data/*.bak
|
||||
data/*.tmp
|
||||
data/*.broken
|
||||
.tools/
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
+704
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace CapyLauncher
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="" 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="" 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="" 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="" 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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="" Content="Home" GroupName="nav" IsChecked="True" Checked="Tab_Checked"/>
|
||||
<RadioButton x:Name="TabBuilds" Style="{StaticResource NavTab}" Tag="" Content="Builds" GroupName="nav" Checked="Tab_Checked"/>
|
||||
<RadioButton x:Name="TabBattle" Style="{StaticResource NavTab}" Tag="" Content="Battle" GroupName="nav" Checked="Tab_Checked"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource Chrome}" Content="" Click="Minimize_Click"/>
|
||||
<Button x:Name="MaxButton" Style="{StaticResource Chrome}" Content="" Margin="2,0" Click="Maximize_Click"/>
|
||||
<Button Style="{StaticResource ChromeClose}" Content="" 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="" 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="" 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="" 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="" 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="" 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="" Content="Config" ToolTip="Open config/config.json" Click="Config_Click"/>
|
||||
<Button Style="{StaticResource Tile}" Tag="" Content="Data" ToolTip="Open the data folder" Click="Data_Click"/>
|
||||
<Button Style="{StaticResource Tile}" Tag="" Content="Log file" ToolTip="Open logs/capysr.log" Click="LogFile_Click"/>
|
||||
<Button x:Name="UnstickButton" Style="{StaticResource Tile}" Tag="" Content="Unstick" ToolTip="Black screen? This puts you back somewhere safe" Click="Unstick_Click"/>
|
||||
<Button Style="{StaticResource Tile}" Tag="" Content="srtools" ToolTip="Build your characters on srtools" Click="SrTools_Click"/>
|
||||
<Button x:Name="FreshButton" Style="{StaticResource Tile}" Tag="" 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="" FontSize="13"/>
|
||||
</Button>
|
||||
<Button Padding="9,5" Margin="6,0,0,0" ToolTip="Clear" Click="Clear_Click">
|
||||
<TextBlock Style="{StaticResource Glyph}" Text="" 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="" 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="" FontSize="13" Foreground="{StaticResource Ok}" Margin="0,1,10,0"/>
|
||||
<TextBlock x:Name="ToastText" FontSize="13"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 885 B |
Binary file not shown.
|
After Width: | Height: | Size: 677 B |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>());
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user