diff --git a/.gitignore b/.gitignore index ea443cf..b92ded4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ src/net/cmd_ids.cpp *.user tools/ tests/ +data/*.bak +data/*.tmp +data/*.broken +.tools/ diff --git a/README.md b/README.md index d5b0bd9..4a9fbd2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CapySR - WIP -A server reimplementation written in C++ for a certain anime game with star and rails. Targets the `4.6.5x beta` (usually the latest). This server is in WIP, but you can still use it. +A server reimplementation written in C++ for a certain anime game with star and rails. Targets the `4.7 beta v3` (`4.6.53`, usually the latest beta). This server is in WIP, but a lot is implemented (mostly combat stuff). ![Screenshot](aha.png) @@ -18,10 +18,12 @@ A server reimplementation written in C++ for a certain anime game with star and | Challenge modes | 🟢 | MoC / PF / AS playable | | Anomaly Arbitration | 🔴 | | | Battle flow | 🟡 | Core combat loop works, Battle College missing | +| Techniques | 🟢 | Overworld effects, enemy debuffs, technique points | | Shop | 🟡 | Buying works, roll shop missing | | Friend system | 🟡 | List and display work, applying/deleting missing | | Quest system | 🟡 | Quest list shows, rewards can't be claimed | -| Characters and gear | 🟡 | Built from srtools, in-game leveling/promotion/eidolons/upgrades missing | +| Characters and gear | 🟡 | Built in the launcher or srtools, in-game leveling/promotion/eidolons/upgrades missing | +| Launcher | 🟢 | Start/stop the server, play, console, builds and battle editor | | Mail | 🔴 | Mail list only, no attachments or deleting | | Items | 🔴 | Using, selling and composing items missing | | Currency Wars | 🔴 | Not planned for implementation soon | @@ -31,35 +33,49 @@ A server reimplementation written in C++ for a certain anime game with star and Don't want to build it yourself? You can grab the prebuilt from [Releases](https://github.com/capyb2222/CapySR/releases), and skip to [Running it](#running-it). -## Requirements +## Requirements (C++ tools are heavy, and is complicated to set up so I recommend you to use prebuilt) 1. Visual Studio 2026 Community (or the smaller Build Tools): https://visualstudio.microsoft.com/downloads/ In the installer, tick "Desktop development with C++". That brings the compiler, the Windows SDK, CMake and Ninja; keep "C++ CMake tools for Windows" ticked, which it is by default. -2. Python 3: https://www.python.org/downloads/windows/ - Tick "Add python.exe to PATH" during install. -3. protoc: https://github.com/protocolbuffers/protobuf/releases - Download protoc--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 ! @@ -67,6 +83,7 @@ The server also supports ! ## 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. \ No newline at end of file diff --git a/build.ps1 b/build.ps1 index b569e40..228a206 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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 } diff --git a/gui/.gitignore b/gui/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/gui/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/gui/App.xaml b/gui/App.xaml new file mode 100644 index 0000000..d0c3c3d --- /dev/null +++ b/gui/App.xaml @@ -0,0 +1,704 @@ + + + #17131F + #2C2440 + #3A2F55 + #352B4A + #FF9AA2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Segoe Fluent Icons, Segoe MDL2 Assets + Cascadia Mono, Consolas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/App.xaml.cs b/gui/App.xaml.cs new file mode 100644 index 0000000..d2a748b --- /dev/null +++ b/gui/App.xaml.cs @@ -0,0 +1,8 @@ +using System.Windows; + +namespace CapyLauncher +{ + public partial class App : Application + { + } +} diff --git a/gui/BattlePage.xaml b/gui/BattlePage.xaml new file mode 100644 index 0000000..887da04 --- /dev/null +++ b/gui/BattlePage.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/BattlePage.xaml.cs b/gui/BattlePage.xaml.cs new file mode 100644 index 0000000..a0e2be1 --- /dev/null +++ b/gui/BattlePage.xaml.cs @@ -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 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 Battle => file.Battle; + + List> Waves() + { + var waves = BuildFile.List(Battle, "monsters"); + for (int i = 0; i < waves.Count; i++) + { + if (!(waves[i] is List)) + waves[i] = waves[i] is System.Collections.IEnumerable items ? items.Cast().ToList() : new List(); + } + return waves.Cast>().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>().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 { ["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())), ""); + 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>().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 { ["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>().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 { ["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, ""); + } + } +} diff --git a/gui/BuildFile.cs b/gui/BuildFile.cs new file mode 100644 index 0000000..0f7264a --- /dev/null +++ b/gui/BuildFile.cs @@ -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 root = new Dictionary(); + + 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 ?? new Dictionary() + : new Dictionary(); + if (root.TryGetValue("data", out object wrapped) && wrapped is Dictionary 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 Dict(Dictionary parent, string key) + { + if (parent.TryGetValue(key, out object value) && value is Dictionary dict) + return dict; + var fresh = new Dictionary(); + parent[key] = fresh; + return fresh; + } + + public static List List(Dictionary parent, string key) + { + if (parent.TryGetValue(key, out object value) && value is List list) + return list; + var fresh = value is IEnumerable items && !(value is string) ? items.Cast().ToList() : new List(); + parent[key] = fresh; + return fresh; + } + + public static int Int(Dictionary 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 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 items) => + items.OfType>().Select(i => Int(i, "internal_uid")).DefaultIfEmpty(0).Max() + 1; + + // ---- avatars ---- + + public Dictionary Avatars => Dict(root, "avatars"); + + public Dictionary Avatar(int id) => + Avatars.TryGetValue(id.ToString(), out object value) ? value as Dictionary : null; + + public Dictionary AddAvatar(AvatarInfo info) + { + var avatar = new Dictionary + { + ["avatar_id"] = info.Id, + ["data"] = new Dictionary + { + ["rank"] = 0, + ["skills"] = new Dictionary(), + ["skills_by_anchor_type"] = new Dictionary(), + }, + ["level"] = 80, + ["promotion"] = 6, + ["sp_max"] = info.Sp, + ["sp_value"] = info.Sp / 2, + ["techniques"] = new List { 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>()) + { + if (Int(item, "equip_avatar") == id) + item["equip_avatar"] = 0; + } + Dirty = true; + } + + public static int Rank(Dictionary avatar) => Int(Dict(avatar, "data"), "rank"); + + public void SetRank(Dictionary avatar, int rank) + { + Dict(avatar, "data")["rank"] = rank; + Dirty = true; + } + + public void SetLevel(Dictionary avatar, int level) + { + avatar["level"] = level; + avatar["promotion"] = PromotionFor(level); + Dirty = true; + } + + public static int AnchorLevel(Dictionary avatar, Anchor anchor) => + Int(Dict(Dict(avatar, "data"), "skills_by_anchor_type"), anchor.Number.ToString()); + + public void SetAnchor(Dictionary 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 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 Lightcones => List(root, "lightcones"); + + public Dictionary LightconeOf(int avatarId) => + Lightcones.OfType>().FirstOrDefault(l => Int(l, "equip_avatar") == avatarId); + + public Dictionary EquipLightcone(int avatarId, int itemId) + { + var cone = LightconeOf(avatarId); + if (cone == null) + { + cone = new Dictionary + { + ["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 cone, int level) + { + cone["level"] = level; + cone["promotion"] = PromotionFor(level); + Dirty = true; + } + + // ---- relics ---- + + public List Relics => List(root, "relics"); + + public static int Slot(Dictionary relic) => Int(relic, "relic_id") % 10; + + public Dictionary RelicOf(int avatarId, int slot) => + Relics.OfType>().FirstOrDefault(r => Int(r, "equip_avatar") == avatarId && Slot(r) == slot); + + public Dictionary PutRelic(int avatarId, int slot, RelicSetInfo set, int mainAffix) + { + var relic = RelicOf(avatarId, slot); + if (relic == null) + { + relic = new Dictionary + { + ["equip_avatar"] = avatarId, + ["internal_uid"] = NextUid(Relics), + ["level"] = 15, + ["sub_affixes"] = new List(), + }; + 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 Subs(Dictionary relic) => + List(relic, "sub_affixes").OfType>() + .Select(s => new SubStat { Id = Int(s, "sub_affix_id"), Count = Int(s, "count", 1), Step = Int(s, "step") }).ToList(); + + public void SetSubs(Dictionary relic, IEnumerable subs) + { + relic["sub_affixes"] = subs.Where(s => s.Id != 0).Select(s => (object)new Dictionary + { + ["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 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 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().ToList(); + if (items.Count == 0) { text.Append("[]"); break; } + bool flat = items.All(x => !(x is Dictionary) && (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 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 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; + } + } + } +} diff --git a/gui/BuildsPage.xaml b/gui/BuildsPage.xaml new file mode 100644 index 0000000..843aa1c --- /dev/null +++ b/gui/BuildsPage.xaml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/BuildsPage.xaml.cs b/gui/BuildsPage.xaml.cs new file mode 100644 index 0000000..2b30627 --- /dev/null +++ b/gui/BuildsPage.xaml.cs @@ -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> ask; + readonly List rows = new List(); + string element; + AvatarInfo current; + + public BuildsPage() + { + InitializeComponent(); + } + + public void Init(Catalog catalog, BuildFile file, Action changed, Func> 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 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 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 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 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 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>()) + 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>()) + cone["rank"] = 5; + file.Touch(); + AfterBulk(); + } + + void AfterBulk() + { + changed(); + foreach (CharRow row in rows) row.Refresh(file); + if (current != null) Redraw(); + } + } +} diff --git a/gui/CapyLauncher.csproj b/gui/CapyLauncher.csproj new file mode 100644 index 0000000..afd6cd0 --- /dev/null +++ b/gui/CapyLauncher.csproj @@ -0,0 +1,26 @@ + + + + WinExe + net48 + true + true + latest + CapyLauncher + CapyLauncher + capy.ico + none + false + false + + + + + + + + + + + + diff --git a/gui/Catalog.cs b/gui/Catalog.cs new file mode 100644 index 0000000..eaa358a --- /dev/null +++ b/gui/Catalog.cs @@ -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 Points = new List(); + public List Stats = new List(); + + 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 Anchors = new List(); + 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[] Passive = new List[5]; + } + + public class RelicSetInfo + { + public int Id; + public string Name; + public bool Planar; + public Dictionary Pieces = new Dictionary(); + public Dictionary> Bonus = new Dictionary>(); + } + + 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 Avatars = new List(); + public List Lightcones = new List(); + public List RelicSets = new List(); + public Dictionary> MainAffixes = new Dictionary>(); + public List SubAffixes = new List(); + public List Monsters = new List(); + public List Stages = new List(); + public List Buffs = new List(); + + 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 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)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().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>().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)s["pieces"]) + set.Pieces[int.Parse(piece.Key)] = Convert.ToInt32(piece.Value); + if (s.TryGetValue("bonus", out object bonus) && bonus is Dictionary pieces) + { + foreach (var need in pieces) + set.Bonus[int.Parse(need.Key)] = ((IEnumerable)need.Value).Cast>().Select(Bonus).ToList(); + } + catalog.RelicSets.Add(set); + } + foreach (var slot in (Dictionary)root["main_affixes"]) + catalog.MainAffixes[int.Parse(slot.Key)] = ((IEnumerable)slot.Value).Cast>().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 d) => new StatBonus { Prop = Str(d, "prop"), Value = Dbl(d, "value") }; + + static List Bonuses(Dictionary parent, string key) => Items(parent, key).Select(Bonus).ToList(); + + static double[][] Growth(Dictionary 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().Select(Convert.ToDouble).ToArray(); + promotion++; + } + } + return growth; + } + + static AffixInfo Affix(Dictionary 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> Items(Dictionary parent, string key) => + parent.TryGetValue(key, out object list) && list is IEnumerable items ? items.Cast>() : Enumerable.Empty>(); + + static int Int(Dictionary d, string key) => d.TryGetValue(key, out object v) && v != null ? Convert.ToInt32(v) : 0; + static double Dbl(Dictionary d, string key) => d.TryGetValue(key, out object v) && v != null ? Convert.ToDouble(v) : 0; + static string Str(Dictionary d, string key) => d.TryGetValue(key, out object v) && v != null ? Convert.ToString(v) : ""; + static bool Bool(Dictionary d, string key) => d.TryGetValue(key, out object v) && v is bool b && b; + } + + public static class Palette + { + static readonly Dictionary Elements = new Dictionary + { + { "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; + } + } +} diff --git a/gui/Icons.cs b/gui/Icons.cs new file mode 100644 index 0000000..925ee1e --- /dev/null +++ b/gui/Icons.cs @@ -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 Loaded = new ConcurrentDictionary(); + static readonly ConcurrentDictionary> Pending = new ConcurrentDictionary>(); + static readonly ConcurrentDictionary Missing = new ConcurrentDictionary(); + static readonly SemaphoreSlim Gate = new SemaphoreSlim(6); + + static readonly Dictionary Properties = new Dictionary + { + { "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 ElementProps = new Dictionary + { + { "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 Get(string relative) + { + if (string.IsNullOrEmpty(relative) || Missing.ContainsKey(relative)) + return Task.FromResult(null); + if (Loaded.TryGetValue(relative, out ImageSource ready)) + return Task.FromResult(ready); + return Pending.GetOrAdd(relative, key => Task.Run(() => Fetch(key))); + } + + static async Task 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))); + } + } +} diff --git a/gui/MainWindow.xaml b/gui/MainWindow.xaml new file mode 100644 index 0000000..d03261c --- /dev/null +++ b/gui/MainWindow.xaml @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +