Allow users to load and save builds from Focus View (Closes #1018) (#1021)

This commit is contained in:
2025-06-27 13:36:28 +02:00
parent 06288ec2a2
commit 1b7654f70f
22 changed files with 998 additions and 222 deletions
@@ -99,4 +99,16 @@ public sealed class MainPlayerController(
var titleInfo = await this.mainPlayerService.GetTitleInfo(cancellationToken);
return titleInfo is not null ? Results.Ok(titleInfo) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
[GenerateGet("build-context")]
[EndpointName("GetMainPlayerBuildContext")]
[EndpointSummary("Get the current build context")]
[EndpointDescription("Get the current build context. Returns a json serialized MainPlayerBuildContext object")]
[ProducesResponseType<BuildEntry>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> GetMainPlayerBuildContext(CancellationToken cancellationToken)
{
var context = await this.mainPlayerService.GetMainPlayerBuildContext(cancellationToken);
return context is not null ? Results.Ok(context) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
<InteropExports>true</InteropExports>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<Version>0.9.9.73</Version>
<Version>0.9.9.75</Version>
<PublishAot>true</PublishAot>
<SelfContained>true</SelfContained>
@@ -35,6 +35,7 @@ namespace Daybreak.API.Serialization;
[JsonSerializable(typeof(InstanceInfo))]
[JsonSerializable(typeof(TitleInfo))]
[JsonSerializable(typeof(LoginInfo))]
[JsonSerializable(typeof(MainPlayerBuildContext))]
public partial class ApiJsonSerializerContext : JsonSerializerContext
{
}
@@ -12,6 +12,7 @@ using System.Collections.Concurrent;
using System.Core.Extensions;
using System.Extensions;
using System.Extensions.Core;
using System.Logging;
using System.Runtime.CompilerServices;
using ZLinq;
using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType;
@@ -428,6 +429,52 @@ public sealed class MainPlayerService : IDisposable
}, cancellationToken);
}
public Task<MainPlayerBuildContext?> GetMainPlayerBuildContext(CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
return this.gameThreadService.QueueOnGameThread(() =>
{
unsafe
{
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
{
scopedLogger.LogError("Not loaded");
return default;
}
var gameContext = this.gameContextService.GetGameContext();
if (gameContext.IsNull ||
gameContext.Pointer->AccountContext is null ||
gameContext.Pointer->WorldContext is null)
{
this.logger.LogError("Game context is not initialized");
return default;
}
var playerAgentId = this.agentContextService.GetPlayerAgentId();
if (playerAgentId is 0x0)
{
scopedLogger.LogError("Failed to get player agent id");
return default;
}
var agentProfession = gameContext.Pointer->WorldContext->Professions.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerAgentId);
if (agentProfession.AgentId != playerAgentId)
{
scopedLogger.LogError("Failed to find agent profession for player agent id {agentId}", playerAgentId);
return default;
}
return new MainPlayerBuildContext(
PrimaryProfessionId: (uint)agentProfession.CurrentPrimary,
UnlockedProfessions: agentProfession.UnlockedProfessionsFlags,
UnlockedAccountSkills: gameContext.Pointer->AccountContext->UnlockedAccountSkills.AsValueEnumerable().ToArray(),
UnlockedCharacterSkills: gameContext.Pointer->WorldContext->UnlockedCharacterSkills.AsValueEnumerable().ToArray());
}
}, cancellationToken);
}
public CallbackRegistration RegisterMainStateConsumer(TimeSpan frequency, Action<ReadOnlySpan<byte>> onUpdate)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(frequency, TimeSpan.Zero);
+1 -1
View File
@@ -7,6 +7,6 @@ public enum HeroBehavior
{
Fight,
Guard,
AvoidCombat,
Avoid,
Undefined
}
@@ -0,0 +1,4 @@
namespace Daybreak.Shared.Models.Api;
public sealed record MainPlayerBuildContext(uint PrimaryProfessionId, uint UnlockedProfessions, uint[] UnlockedCharacterSkills, uint[] UnlockedAccountSkills)
{
}
@@ -0,0 +1,10 @@
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Models.Guildwars;
namespace Daybreak.Shared.Models.Builds;
public sealed class PartyMemberEntry
{
public required SingleBuildEntry Build { get; init; }
public required Hero? Hero { get; init; }
public required HeroBehavior Behavior { get; init; }
}
@@ -0,0 +1,9 @@
namespace Daybreak.Shared.Models.FocusView;
public sealed class BuildComponentContext
{
public required bool IsInOutpost { get; init; }
public required uint PrimaryProfessionId { get; init; }
public required uint UnlockedProfessions { get; init; }
public required uint[] CharacterUnlockedSkills { get; init; }
public required uint[] AccountUnlockedSkills { get; init; }
}
@@ -111,7 +111,7 @@ public abstract class BuildEntryBase : INotifyPropertyChanged, IBuildEntry
set
{
this.Metadata ??= [];
this.Metadata[nameof(PartyCompositionMetadataEntry)] = value is null
this.Metadata[nameof(this.PartyComposition)] = value is null
? string.Empty
: JsonConvert.SerializeObject(value, Formatting.None);
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.PartyComposition)));
@@ -25,6 +25,7 @@ public sealed class ScopedApiContext(
private const string GetMainPlayerInfoPath = "/api/v1/rest/main-player/info";
private const string GetMainPlayerInstanceInfoPath = "/api/v1/rest/main-player/instance-info";
private const string GetMainPlayerBuildPath = "/api/v1/rest/main-player/build";
private const string GetMainPlayerBuildContextPath = "/api/v1/rest/main-player/build-context";
private const string PostMainPlayerBuildPath = $"/api/v1/rest/main-player/build?code={CodePlaceholder}";
private const string PartyLoadoutPath = "/api/v1/rest/party/loadout";
private const string GetTitleInfoPath = "/api/v1/rest/main-player/title";
@@ -89,6 +90,8 @@ public sealed class ScopedApiContext(
public Task<ProcessIdResponse?> GetProcessId(CancellationToken cancellationToken) => this.GetPayload<ProcessIdResponse>(GetHealthPath, cancellationToken);
public Task<MainPlayerBuildContext?> GetMainPlayerBuildContext(CancellationToken cancellationToken) => this.GetPayload<MainPlayerBuildContext>(GetMainPlayerBuildContextPath, cancellationToken);
public async Task<bool> PostPartyLoadout(PartyLoadout partyLoadout, CancellationToken cancellationToken) => await this.PostPayload(PartyLoadoutPath, partyLoadout, cancellationToken);
private async Task<bool> PostPayload<T>(string path, T payload, CancellationToken cancellationToken)
@@ -12,6 +12,8 @@ using System.Extensions;
using System.Extensions.Core;
using System.IO;
using System.Linq;
using System.Logging;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using AttributeEntry = Daybreak.Shared.Models.Builds.AttributeEntry;
@@ -71,6 +73,18 @@ public sealed class BuildTemplateManager(
return entry;
}
public SingleBuildEntry CreateSingleBuild(BuildEntry buildEntry)
{
var singleBuildEntry = this.CreateSingleBuild();
return this.PopulateSingleBuild(singleBuildEntry, buildEntry);
}
public SingleBuildEntry CreateSingleBuild(string name, BuildEntry buildEntry)
{
var singleBuildEntry = this.CreateSingleBuild(name);
return this.PopulateSingleBuild(singleBuildEntry, buildEntry);
}
public TeamBuildEntry CreateTeamBuild()
{
var name = Guid.NewGuid().ToString();
@@ -139,23 +153,103 @@ public sealed class BuildTemplateManager(
return teamBuildEntry;
}
public PartyLoadout ConvertToPartyLoadout(TeamBuildEntry teamBuildEntry)
{
if (teamBuildEntry.PartyComposition is not List<PartyCompositionMetadataEntry> partyComposition ||
partyComposition.Count != teamBuildEntry.Builds.Count)
{
throw new InvalidOperationException("Invalid team build entry. Team build entry contains invalid party composition metadata");
}
if (partyComposition.Any(c => c.Type is PartyCompositionMemberType.Hero && (c.HeroId is null || c.Behavior is null)))
{
throw new InvalidOperationException("Invalid team build entry. Hero entries without specified heroes and behaviors are not currently supported");
}
var loadoutEntryComposition = teamBuildEntry.Builds.Select((build, index) =>
{
var compositionEntry = partyComposition.First(c => c.Index == index);
return (build, compositionEntry, index);
});
return new PartyLoadout(
Entries: [.. loadoutEntryComposition.Select(entry => new PartyLoadoutEntry(
HeroId: entry.compositionEntry.Type is PartyCompositionMemberType.MainPlayer ? 0 : entry.compositionEntry.HeroId ?? throw new InvalidOperationException(),
HeroBehavior: entry.compositionEntry.Behavior ?? HeroBehavior.Guard,
Build: this.ConvertToBuildEntry(entry.build)))]);
}
public BuildEntry ConvertToBuildEntry(SingleBuildEntry singleBuildEntry)
{
return new BuildEntry(
Primary: singleBuildEntry.Primary.Id,
Secondary: singleBuildEntry.Secondary.Id,
Attributes: [.. singleBuildEntry.Attributes.Select(a => new Shared.Models.Api.AttributeEntry((uint)(a.Attribute?.Id ?? -1), (uint)a.Points, (uint)a.Points))],
Skills: [.. singleBuildEntry.Skills.Select(s => (uint)s.Id)]);
}
public bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, TeamBuildEntry teamBuildEntry)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (teamBuildEntry.PartyComposition is not List<PartyCompositionMetadataEntry> partyComposition ||
partyComposition.Count != teamBuildEntry.Builds.Count)
{
scopedLogger.LogDebug("Invalid team build entry {buildName}. Team build entry contains invalid party composition metadata", teamBuildEntry.Name ?? string.Empty);
return false;
}
if (partyComposition.Any(c => c.Type is PartyCompositionMemberType.Hero && (c.HeroId is null || c.Behavior is null)))
{
scopedLogger.LogDebug("Invalid team build entry {buildName}. Hero entries without specified heroes and behaviors are not currently supported", teamBuildEntry.Name ?? string.Empty);
return false;
}
var loadoutEntryComposition = teamBuildEntry.Builds.Select((build, index) =>
{
var compositionEntry = partyComposition.First(c => c.Index == index);
return (build, compositionEntry, index);
});
var lockedSkill = loadoutEntryComposition
.Select(entry => entry.build.Skills.FirstOrDefault(s => s != Skill.NoSkill && !IsSkillUnlocked(s.Id, mainPlayerBuildContext.UnlockedAccountSkills)))
.OfType<Skill>()
.FirstOrDefault();
if (lockedSkill is not null)
{
scopedLogger.LogDebug("Invalid team build entry {buildName}. Skill {skillName} is not unlocked by the current account", teamBuildEntry.Name ?? string.Empty, lockedSkill.Name);
}
return loadoutEntryComposition.Any(entry => entry.compositionEntry.Type is PartyCompositionMemberType.MainPlayer && this.CanApply(mainPlayerBuildContext, entry.build));
}
public bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, SingleBuildEntry singleBuildEntry)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (singleBuildEntry.Primary.Id != mainPlayerBuildContext.PrimaryProfessionId)
{
scopedLogger.LogDebug("Invalid build entry {buildName}. Build primary {buildPrimaryId} does not match player primary {playerPrimaryId}", singleBuildEntry.Name ?? string.Empty, singleBuildEntry.Primary.Id, mainPlayerBuildContext.PrimaryProfessionId);
return false;
}
if (singleBuildEntry.Secondary != Profession.None &&
!IsProfessionUnlocked(singleBuildEntry.Secondary.Id, mainPlayerBuildContext.UnlockedProfessions))
{
scopedLogger.LogDebug("Invalid build entry {buildName}. Build secondary {buildSecondaryId} is not unlocked by the player", singleBuildEntry.Name ?? string.Empty, singleBuildEntry.Secondary.Id);
return false;
}
if (singleBuildEntry.Skills.FirstOrDefault(s => s != Skill.NoSkill && !IsSkillUnlocked(s.Id, mainPlayerBuildContext.UnlockedCharacterSkills)) is Skill lockedSkill)
{
scopedLogger.LogDebug("Invalid build entry {buildName}. Skill {skillName} is not unlocked by the current character", singleBuildEntry.Name ?? string.Empty, lockedSkill.Name);
return false;
}
return true;
}
public bool CanTemplateApply(BuildTemplateValidationRequest request)
{
var scopedLogger = this.logger.CreateScopedLogger();
bool IsProfessionUnlocked(int professionId) => (request.UnlockedCharacterProfessions & (1 << professionId)) != 0;
bool IsSkillUnlocked(int skillId, uint[] unlockedSkills)
{
var realIndex = skillId / 32;
if (realIndex >= unlockedSkills.Length)
{
return false;
}
var shift = skillId % 32;
var flag = 1U << shift;
return (unlockedSkills[realIndex] & flag) != 0;
}
if (request.BuildPrimary != request.CurrentPrimary)
{
scopedLogger.LogError("Primary profession does not match current primary profession");
@@ -163,7 +257,7 @@ public sealed class BuildTemplateManager(
}
if (request.BuildSecondary is not 0 &&
!IsProfessionUnlocked((int)request.BuildSecondary))
!IsProfessionUnlocked((int)request.BuildSecondary, request.UnlockedCharacterProfessions))
{
scopedLogger.LogError("Secondary profession is not unlocked");
return false;
@@ -389,69 +483,83 @@ public sealed class BuildTemplateManager(
private TeamBuildEntry PopulateTeamBuild(TeamBuildEntry teamBuildEntry, PartyLoadout partyLoadout)
{
var scopedLogger = this.logger.CreateScopedLogger();
teamBuildEntry.Builds.Clear();
var partyCompositionMetadata = new List<PartyCompositionMetadataEntry>();
var index = 0;
foreach (var entry in partyLoadout.Entries)
foreach (var entry in partyLoadout.Entries.OrderBy(e => e.HeroId))
{
var build = entry.Build;
var buildEntry = this.CreateSingleBuild();
if (!Profession.TryParse(build.Primary, out var primary))
{
scopedLogger.LogError("Failed to parse primary profession with id {professionId}", build.Primary);
throw new InvalidOperationException($"Failed to parse primary profession with id {build.Primary}");
}
this.PopulateSingleBuild(buildEntry, build);
if (!Profession.TryParse(build.Secondary, out var secondary))
{
scopedLogger.LogError("Failed to parse secondary profession with id {professionId}", build.Secondary);
throw new InvalidOperationException($"Failed to parse secondary profession with id {build.Secondary}");
}
buildEntry.Primary = primary;
buildEntry.Secondary = secondary;
buildEntry.Attributes = [.. build.Attributes.Select(a =>
{
if (!Daybreak.Shared.Models.Guildwars.Attribute.TryParse((int)a.Id, out var attr))
{
scopedLogger.LogError("Failed to parse attribute with id {attributeId}", a.Id);
throw new InvalidOperationException($"Failed to parse attribute with id {a.Id}");
}
return new Daybreak.Shared.Models.Builds.AttributeEntry
{
Attribute = attr,
Points = (int)a.BasePoints
};
})];
buildEntry.Skills = [.. build.Skills.Select(s =>
{
if (!Skill.TryParse((int)s, out var skill))
{
scopedLogger.LogError("Failed to parse skill with id {skillId}", s);
throw new InvalidOperationException($"Failed to parse skill with id {s}");
}
return skill;
})];
teamBuildEntry.Builds.Add(buildEntry);
partyCompositionMetadata.Add(new PartyCompositionMetadataEntry
var partyCompositionEntry = new PartyCompositionMetadataEntry
{
Type = entry.HeroId is 0 ? PartyCompositionMemberType.MainPlayer : PartyCompositionMemberType.Hero,
Index = index,
Index = entry.HeroId is 0 ? 0 : teamBuildEntry.Builds.Count,
Behavior = entry.HeroId is 0 ? default : (HeroBehavior)entry.HeroBehavior,
HeroId = entry.HeroId is 0 ? default : entry.HeroId
});
};
index++;
if (partyCompositionEntry.Type is PartyCompositionMemberType.MainPlayer)
{
teamBuildEntry.Builds.Insert(0, buildEntry);
partyCompositionMetadata.Insert(0, partyCompositionEntry);
}
else
{
teamBuildEntry.Builds.Add(buildEntry);
partyCompositionMetadata.Add(partyCompositionEntry);
}
}
teamBuildEntry.PartyComposition = partyCompositionMetadata;
return teamBuildEntry;
}
private SingleBuildEntry PopulateSingleBuild(SingleBuildEntry singleBuildEntry, BuildEntry buildEntry)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!Profession.TryParse(buildEntry.Primary, out var primary))
{
scopedLogger.LogError("Failed to parse primary profession with id {professionId}", buildEntry.Primary);
throw new InvalidOperationException($"Failed to parse primary profession with id {buildEntry.Primary}");
}
if (!Profession.TryParse(buildEntry.Secondary, out var secondary))
{
scopedLogger.LogError("Failed to parse secondary profession with id {professionId}", buildEntry.Secondary);
throw new InvalidOperationException($"Failed to parse secondary profession with id {buildEntry.Secondary}");
}
singleBuildEntry.Primary = primary;
singleBuildEntry.Secondary = secondary;
singleBuildEntry.Attributes = [.. buildEntry.Attributes.Select(a =>
{
if (!Daybreak.Shared.Models.Guildwars.Attribute.TryParse((int)a.Id, out var attr))
{
scopedLogger.LogError("Failed to parse attribute with id {attributeId}", a.Id);
throw new InvalidOperationException($"Failed to parse attribute with id {a.Id}");
}
return new Daybreak.Shared.Models.Builds.AttributeEntry
{
Attribute = attr,
Points = (int)a.BasePoints
};
})];
singleBuildEntry.Skills = [.. buildEntry.Skills.Select(s =>
{
if (!Skill.TryParse((int)s, out var skill))
{
scopedLogger.LogError("Failed to parse skill with id {skillId}", s);
throw new InvalidOperationException($"Failed to parse skill with id {s}");
}
return skill;
})];
return singleBuildEntry;
}
private async Task<Result<IBuildEntry, Exception>> LoadBuildFromFile(string path, string buildName)
{
if (File.Exists(path) is false)
@@ -767,4 +875,19 @@ public sealed class BuildTemplateManager(
return (int)value;
}
private static bool IsProfessionUnlocked(int professionId, uint unlockedProfessions) => (unlockedProfessions & (1 << professionId)) != 0;
private static bool IsSkillUnlocked(int skillId, uint[] unlockedSkills)
{
var realIndex = skillId / 32;
if (realIndex >= unlockedSkills.Length)
{
return false;
}
var shift = skillId % 32;
var flag = 1U << shift;
return (unlockedSkills[realIndex] & flag) != 0;
}
}
@@ -16,10 +16,16 @@ public interface IBuildTemplateManager
bool CanTemplateApply(BuildTemplateValidationRequest request);
SingleBuildEntry CreateSingleBuild();
SingleBuildEntry CreateSingleBuild(string name);
SingleBuildEntry CreateSingleBuild(BuildEntry buildEntry);
SingleBuildEntry CreateSingleBuild(string name, BuildEntry buildEntry);
TeamBuildEntry CreateTeamBuild();
TeamBuildEntry CreateTeamBuild(string name);
TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout);
TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout, string name);
PartyLoadout ConvertToPartyLoadout(TeamBuildEntry teamBuildEntry);
BuildEntry ConvertToBuildEntry(SingleBuildEntry singleBuildEntry);
bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, TeamBuildEntry teamBuildEntry);
bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, SingleBuildEntry singleBuildEntry);
void ClearBuilds();
void SaveBuild(IBuildEntry buildEntry);
void RemoveBuild(IBuildEntry buildEntry);
+18 -35
View File
@@ -18,6 +18,7 @@ public partial class DropDownButton : Control
private const string PartMainButton = "PART_MainButton";
private const string PartArrowButton = "PART_ArrowButton";
private const string PartDropDown = "PART_DropDown";
private const string DropDownContextMenu = "DropDownContextMenu";
[GenerateDependencyProperty]
private DataTemplate itemTemplate = default!;
@@ -72,28 +73,12 @@ public partial class DropDownButton : Control
this.arrowButton.Clicked -= this.ArrowButton_Clicked;
}
if (this.ContextMenu is not null)
{
this.ContextMenu.Opened -= this.ContextMenu_Opened;
}
this.mainButton = this.GetTemplateChild(PartMainButton) as HighlightButton;
this.arrowButton = this.GetTemplateChild(PartArrowButton) as HighlightButton;
if (this.mainButton is not null)
{
this.mainButton.Clicked += this.MainButton_Clicked;
}
if (this.arrowButton is not null)
{
this.arrowButton.Clicked += this.ArrowButton_Clicked;
}
if (this.ContextMenu is not null)
{
this.ContextMenu.Opened += this.ContextMenu_Opened;
}
this.ContextMenu ??= (ContextMenu)this.TryFindResource(DropDownContextMenu);
this.mainButton = (HighlightButton)this.GetTemplateChild(PartMainButton);
this.arrowButton = (HighlightButton)this.GetTemplateChild(PartArrowButton);
this.AttachItemClickedHandler(this.ContextMenu);
this.mainButton.Clicked += this.MainButton_Clicked;
this.arrowButton.Clicked += this.ArrowButton_Clicked;
}
private void MainButton_Clicked(object? sender, object e)
@@ -110,19 +95,6 @@ public partial class DropDownButton : Control
}
}
// When the contextmenu opens, locate the DropDownButtonContextMenu inside it and hook its CLR event.
private void ContextMenu_Opened(object? sender, RoutedEventArgs e)
{
if (sender is not ContextMenu menu)
return;
if (menu.Template.FindName(PartDropDown, menu) is DropDownButtonContextMenu dropDown)
{
dropDown.ItemClicked -= this.DropDownButtonContextMenu_ItemClicked; // avoid duplicates
dropDown.ItemClicked += this.DropDownButtonContextMenu_ItemClicked;
}
}
private void DropDownButtonContextMenu_ItemClicked(object? _, object e)
{
this.SelectedItem = e;
@@ -133,4 +105,15 @@ public partial class DropDownButton : Control
this.SelectionChanged?.Invoke(this, e);
}
private void AttachItemClickedHandler(ContextMenu menu)
{
// Ensure the visual tree is built
menu.ApplyTemplate();
if (menu.Template.FindName(PartDropDown, menu) is DropDownButtonContextMenu dropDown)
{
dropDown.ItemClicked -= this.DropDownButtonContextMenu_ItemClicked;
dropDown.ItemClicked += this.DropDownButtonContextMenu_ItemClicked;
}
}
}
@@ -0,0 +1,172 @@
<UserControl x:Class="Daybreak.Controls.FocusViewComponents.BuildComponent"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:Daybreak.Controls.FocusViewComponents"
xmlns:buttons="clr-namespace:Daybreak.Controls.Buttons"
xmlns:glyphs="clr-namespace:Daybreak.Controls.Glyphs"
xmlns:builds="clr-namespace:Daybreak.Shared.Models.Builds;assembly=Daybreak.Shared"
xmlns:controls="clr-namespace:Daybreak.Controls"
xmlns:converters="clr-namespace:Daybreak.Shared.Converters;assembly=Daybreak.Shared"
Background="{StaticResource Daybreak.Brushes.Background}"
BorderBrush="{StaticResource MahApps.Brushes.ThemeForeground}"
BorderThickness="1"
IsEnabled="{Binding IsInOutpost, Mode=OneWay}"
DataContextChanged="UserControl_DataContextChanged"
Loaded="UserControl_Loaded"
Unloaded="UserControl_Unloaded"
mc:Ignorable="d"
x:Name="_this"
Height="264"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<converters:BooleanToVisibilityConverter x:Key="InverseBooleanToVisibilityConverter" TriggerValue="True" />
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBlock Text="Builds"
FontSize="16"
VerticalAlignment="Center"
Margin="10, 0, 40, 0"/>
<StackPanel Orientation="Horizontal"
Grid.Column="1"
HorizontalAlignment="Right">
<buttons:HighlightButton Title="Get Player Build"
IsEnabled="{Binding IsInOutpost, Mode=OneWay}"
HighlightBrush="{StaticResource MahApps.Brushes.Accent}"
BorderBrush="{StaticResource MahApps.Brushes.ThemeForeground}"
DisabledBrush="{StaticResource MahApps.Brushes.ThemeBackground}"
BorderThickness="1, 0, 1, 0"
Height="30"
Width="150"
FontSize="16"
VerticalContentAlignment="Center"
HorizontalContentAlignment="Center"
Clicked="GetPlayerBuildButton_Clicked"
ToolTip="Load current player build into Daybreak"/>
<buttons:HighlightButton Title="Get Team Build"
IsEnabled="{Binding IsInOutpost, Mode=OneWay}"
HighlightBrush="{StaticResource MahApps.Brushes.Accent}"
BorderBrush="{StaticResource MahApps.Brushes.ThemeForeground}"
DisabledBrush="{StaticResource MahApps.Brushes.ThemeBackground}"
BorderThickness="1, 0, 1, 0"
Height="30"
Width="150"
FontSize="16"
VerticalContentAlignment="Center"
HorizontalContentAlignment="Center"
Clicked="GetTeamBuildButton_Clicked"
ToolTip="Load current team build and composition into Daybreak"/>
</StackPanel>
</Grid>
<Rectangle Fill="{StaticResource MahApps.Brushes.ThemeForeground}"
Height="1"
VerticalAlignment="Bottom"/>
<ItemsControl Grid.Row="1"
ItemsSource="{Binding ElementName=_this, Path=Builds, Mode=OneWay}"
ScrollViewer.VerticalScrollBarVisibility="Visible"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.IsContainerVirtualizable="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.CanContentScroll="False">
<ItemsControl.Template>
<ControlTemplate>
<Border Padding="{TemplateBinding Control.Padding}"
Background="{TemplateBinding Panel.Background}"
BorderBrush="{TemplateBinding Border.BorderBrush}"
BorderThickness="{TemplateBinding Border.BorderThickness}"
SnapsToDevicePixels="True">
<ScrollViewer Padding="{TemplateBinding Control.Padding}" Focusable="False">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
</ScrollViewer>
</Border>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Resources>
<DataTemplate DataType="{x:Type builds:SingleBuildEntry}">
<Grid>
<Rectangle Fill="White"
Height="1"
VerticalAlignment="Bottom" />
<WrapPanel>
<glyphs:PersonGlyph Height="20"
Width="35"/>
<TextBlock Text="{Binding Name}"
FontSize="16"/>
</WrapPanel>
<buttons:BackButton Height="20"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"
IsEnabled="{Binding IsEnabled, Mode=OneWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
RenderTransformOrigin="0.5 0.5"
HorizontalAlignment="Right"
ToolTip="Load this player build into Guild Wars"
Clicked="LoadSingleBuildButton_Clicked">
<buttons:BackButton.RenderTransform>
<RotateTransform Angle="90" />
</buttons:BackButton.RenderTransform>
</buttons:BackButton>
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type builds:TeamBuildEntry}">
<Grid>
<Rectangle Fill="White"
Height="1"
VerticalAlignment="Bottom" />
<WrapPanel>
<glyphs:TeamGlyph Height="20"
Width="35"/>
<TextBlock Text="{Binding Name}"
FontSize="16"/>
</WrapPanel>
<buttons:BackButton Height="20"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"
IsEnabled="{Binding IsEnabled, Mode=OneWay, RelativeSource={RelativeSource AncestorType=UserControl}}"
RenderTransformOrigin="0.5 0.5"
HorizontalAlignment="Right"
ToolTip="Load this team build into Guild Wars"
Clicked="LoadTeamBuildButton_Clicked">
<buttons:BackButton.RenderTransform>
<RotateTransform Angle="90" />
</buttons:BackButton.RenderTransform>
</buttons:BackButton>
</Grid>
</DataTemplate>
</ItemsControl.Resources>
</ItemsControl>
<Grid Visibility="{Binding ElementName=_this, Path=Loading, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"
Grid.RowSpan="2"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
Background="{StaticResource Daybreak.Brushes.Background}">
<controls:CircularLoadingWidget VerticalAlignment="Center"
HorizontalAlignment="Center"
Height="100"
Width="100"/>
</Grid>
<Grid Visibility="{Binding ElementName=_this, Path=ValidData, Mode=OneWay, Converter={StaticResource InverseBooleanToVisibilityConverter}}"
Grid.RowSpan="2"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
Background="{StaticResource Daybreak.Brushes.Background}">
<controls:CircularLoadingWidget VerticalAlignment="Center"
HorizontalAlignment="Center"
Height="100"
Width="100"/>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,264 @@
using Daybreak.Controls.Buttons;
using Daybreak.Shared;
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Models.Builds;
using Daybreak.Shared.Models.FocusView;
using Daybreak.Shared.Services.Api;
using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.Navigation;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Views;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Core.Extensions;
using System.Extensions;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Extensions;
namespace Daybreak.Controls.FocusViewComponents;
/// <summary>
/// Interaction logic for BuildComponent.xaml
/// </summary>
public partial class BuildComponent : UserControl
{
private const string PveMetaBuildsUrl = "https://gwpvx.fandom.com/wiki/Category:Great_working_general_builds";
private readonly INotificationService notificationService;
private readonly IBuildTemplateManager buildTemplateManager;
private readonly IAttachedApiAccessor attachedApiAccessor;
private readonly IViewManager viewManager;
private bool buildsInitialized = false;
private List<IBuildEntry>? cachedBuilds;
private MainPlayerBuildContext? cachedMainPlayerBuildContext;
[GenerateDependencyProperty]
private bool loading;
[GenerateDependencyProperty]
private bool validData;
public event EventHandler<string>? NavigateToClicked;
public ObservableCollection<IBuildEntry> Builds { get; } = [];
public BuildComponent()
: this(Global.GlobalServiceProvider.GetRequiredService<INotificationService>(),
Global.GlobalServiceProvider.GetRequiredService<IViewManager>(),
Global.GlobalServiceProvider.GetRequiredService<IAttachedApiAccessor>(),
Global.GlobalServiceProvider.GetRequiredService<IBuildTemplateManager>())
{
}
public BuildComponent(
INotificationService notificationService,
IViewManager viewManager,
IAttachedApiAccessor attachedApiAccessor,
IBuildTemplateManager buildTemplateManager)
{
this.notificationService = notificationService.ThrowIfNull();
this.viewManager = viewManager.ThrowIfNull();
this.attachedApiAccessor = attachedApiAccessor.ThrowIfNull();
this.buildTemplateManager = buildTemplateManager.ThrowIfNull();
this.InitializeComponent();
}
private async void UserControl_Loaded(object _, RoutedEventArgs __)
{
this.cachedBuilds = await this.buildTemplateManager.GetBuilds().ToListAsync();
this.buildsInitialized = true;
}
private void UserControl_Unloaded(object _, RoutedEventArgs __)
{
this.Builds.Clear();
this.cachedMainPlayerBuildContext = default;
this.buildsInitialized = false;
}
private void UserControl_DataContextChanged(object _, DependencyPropertyChangedEventArgs e)
{
if (e.NewValue is not BuildComponentContext context)
{
return;
}
if (!this.BuildContextChanged(context))
{
return;
}
this.Loading = true;
this.cachedMainPlayerBuildContext = new MainPlayerBuildContext(context.PrimaryProfessionId, context.UnlockedProfessions, context.CharacterUnlockedSkills, context.AccountUnlockedSkills);
this.LoadBuilds();
this.Loading = false;
}
private async void GetPlayerBuildButton_Clicked(object _, EventArgs __)
{
if (this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext)
{
throw new InvalidOperationException("Attached API context is not available. Focus view should not be accessible without an attached context");
}
var buildEntry = await apiContext.GetMainPlayerBuild(CancellationToken.None);
if (buildEntry is null)
{
this.notificationService.NotifyError(
"Failed to load player build",
"Could not retrieve the player's build from the API. Please check logs for more details");
return;
}
try
{
var singleBuildEntry = this.buildTemplateManager.CreateSingleBuild(buildEntry);
this.viewManager.ShowView<SingleBuildTemplateView>(singleBuildEntry);
}
catch
{
throw;
}
}
private async void GetTeamBuildButton_Clicked(object _, EventArgs __)
{
if (this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext)
{
throw new InvalidOperationException("Attached API context is not available. Focus view should not be accessible without an attached context");
}
var partyLoadout = await apiContext.GetPartyLoadout(CancellationToken.None);
if (partyLoadout is null)
{
this.notificationService.NotifyError(
"Failed to load team build",
"Could not retrieve the team build from the API. Please check logs for more details");
return;
}
try
{
var teamBuildEntry = this.buildTemplateManager.CreateTeamBuild(partyLoadout);
this.viewManager.ShowView<TeamBuildTemplateView>(teamBuildEntry);
}
catch
{
throw;
}
}
private void PveMetaBuildsButton_Clicked(object _, EventArgs __)
{
this.NavigateToClicked?.Invoke(this, PveMetaBuildsUrl);
}
private async void LoadSingleBuildButton_Clicked(object sender, EventArgs e)
{
if (sender is not BackButton backButton ||
backButton.DataContext is not SingleBuildEntry build ||
this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext)
{
return;
}
this.Loading = true;
var code = this.buildTemplateManager.EncodeTemplate(build);
await apiContext.PostMainPlayerBuild(code, CancellationToken.None).ConfigureAwait(true);
this.Loading = false;
}
private async void LoadTeamBuildButton_Clicked(object sender, EventArgs e)
{
if (sender is not BackButton backButton ||
backButton.DataContext is not TeamBuildEntry build ||
this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext)
{
return;
}
this.Loading = true;
var loadout = this.buildTemplateManager.ConvertToPartyLoadout(build);
await apiContext.PostPartyLoadout(loadout, CancellationToken.None).ConfigureAwait(true);
this.Loading = false;
}
private void LoadBuilds()
{
if (this.cachedMainPlayerBuildContext is null)
{
this.Builds.Clear();
return;
}
if (this.cachedBuilds is null)
{
this.Builds.Clear();
return;
}
var validBuilds = this.cachedBuilds
.Where(build =>
{
if (build is SingleBuildEntry singleBuild)
{
return this.buildTemplateManager.CanApply(this.cachedMainPlayerBuildContext, singleBuild);
}
else if (build is TeamBuildEntry teamBuild)
{
return this.buildTemplateManager.CanApply(this.cachedMainPlayerBuildContext, teamBuild);
}
else
{
return false;
}
})
.OrderBy(b => b.Name);
this.UpdateBuildCollection(validBuilds);
}
private void UpdateBuildCollection(IEnumerable<IBuildEntry> validBuilds)
{
var buildsToAdd = validBuilds.Where(b1 => !this.Builds.Any(b2 => b1.Name == b2.Name)).ToList();
var buildsToRemove = this.Builds.Where(b1 => !validBuilds.Any(b2 => b1.Name == b2.Name)).ToList();
foreach(var build in buildsToRemove)
{
this.Builds.Remove(build);
}
foreach (var build in buildsToAdd)
{
this.Builds.Add(build);
}
}
private bool BuildContextChanged(BuildComponentContext context)
{
if (!this.buildsInitialized)
{
return false;
}
if (this.cachedMainPlayerBuildContext is null)
{
return true;
}
else if (this.cachedMainPlayerBuildContext.PrimaryProfessionId != context.PrimaryProfessionId ||
this.cachedMainPlayerBuildContext.UnlockedProfessions != context.UnlockedProfessions ||
!this.cachedMainPlayerBuildContext.UnlockedCharacterSkills.SequenceEqual(context.CharacterUnlockedSkills) ||
!this.cachedMainPlayerBuildContext.UnlockedAccountSkills.SequenceEqual(context.AccountUnlockedSkills))
{
return true;
}
return false;
}
}
@@ -0,0 +1,68 @@
<UserControl x:Class="Daybreak.Controls.Templates.PartyMemberTemplate"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:Daybreak.Controls.Templates"
xmlns:builds="clr-namespace:Daybreak.Shared.Models.Builds;assembly=Daybreak.Shared"
xmlns:converters="clr-namespace:Daybreak.Shared.Converters;assembly=Daybreak.Shared"
xmlns:buttons="clr-namespace:Daybreak.Controls.Buttons"
xmlns:controls="clr-namespace:Daybreak.Controls"
x:Name="_this"
DataContextChanged="UserControl_DataContextChanged"
Height="30"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=builds:PartyMemberEntry}"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources>
<converters:EqualityToVisibilityConverter x:Key="EqualityToVisibilityConverter" />
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
</UserControl.Resources>
<Grid>
<Rectangle Fill="{StaticResource MahApps.Brushes.Accent}" >
<Rectangle.Visibility>
<MultiBinding Converter="{StaticResource EqualityToVisibilityConverter}">
<Binding />
<Binding Path="SelectedItem" RelativeSource="{RelativeSource AncestorType={x:Type ListView}}" />
</MultiBinding>
</Rectangle.Visibility>
</Rectangle>
<buttons:HighlightButton HighlightBrush="{StaticResource MahApps.Brushes.Accent}"
Clicked="HighlightButton_Clicked"
Width="300">
<buttons:HighlightButton.ButtonContent>
<Grid>
<WrapPanel Cursor="Hand"
VerticalAlignment="Center">
<TextBlock Text="{Binding Build.Primary.Alias}"
FontSize="16"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"/>
<TextBlock Text="/"
FontSize="16"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"/>
<TextBlock Text="{Binding Build.Secondary.Alias}"
FontSize="16"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"/>
<TextBlock Text="{Binding Hero.Name}"
Margin="5, 0, 0, 0"
FontSize="16"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"
Visibility="{Binding ElementName=_this, Path=ShowName, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"/>
</WrapPanel>
</Grid>
</buttons:HighlightButton.ButtonContent>
</buttons:HighlightButton>
<controls:DropDownButton HorizontalAlignment="Right"
Items="{Binding ElementName=_this, Path=HeroBehaviors, Mode=OneWay}"
SelectedItem="{Binding ElementName=_this, Path=SelectedBehavior, Mode=OneWay}"
Visibility="{Binding ElementName=_this, Path=ShowBehavior, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"
SelectionChanged="DropDownButton_SelectionChanged"
FontSize="16"
VerticalContentAlignment="Center"
DropDownBackground="{StaticResource MahApps.Brushes.ThemeBackground}"
DisableBrush="Transparent"
IsEnabled="True"
ClickEnabled="False"
Width="80"/>
</Grid>
</UserControl>
@@ -0,0 +1,71 @@
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Models.Builds;
using Daybreak.Shared.Models.Guildwars;
using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Extensions;
namespace Daybreak.Controls.Templates;
/// <summary>
/// Interaction logic for PartyMemberTemplate.xaml
/// </summary>
public partial class PartyMemberTemplate : UserControl
{
public event EventHandler<HeroBehavior>? BehaviorChanged;
public event EventHandler<IBuildEntry>? BuildSelected;
[GenerateDependencyProperty]
private bool showBehavior = false;
[GenerateDependencyProperty]
private bool showName = false;
[GenerateDependencyProperty]
private HeroBehavior selectedBehavior;
public ObservableCollection<HeroBehavior> HeroBehaviors { get; } =
[
HeroBehavior.Fight,
HeroBehavior.Guard,
HeroBehavior.Avoid
];
public PartyMemberTemplate()
{
this.InitializeComponent();
}
private void UserControl_DataContextChanged(object _, DependencyPropertyChangedEventArgs e)
{
if (e.NewValue is not PartyMemberEntry entry)
{
return;
}
this.ShowBehavior = entry.Hero is not null && entry.Hero != Hero.None;
this.SelectedBehavior = entry.Behavior;
this.ShowName = entry.Hero is not null && entry.Hero != Hero.None;
}
private void DropDownButton_SelectionChanged(object _, object newValue)
{
if (newValue is not HeroBehavior behavior)
{
return;
}
this.BehaviorChanged?.Invoke(this, behavior);
}
private void HighlightButton_Clicked(object sender, EventArgs e)
{
if (this.DataContext is not PartyMemberEntry entry)
{
return;
}
this.BuildSelected?.Invoke(this, entry.Build);
}
}
+24 -29
View File
@@ -9,36 +9,31 @@
<conv:BooleanToVisibilityConverter x:Key="InverseBooleanToVisibilityConverter"
TriggerValue="True"/>
<conv:NullToUnsetValueConverter x:Key="NullToUnset" />
<ContextMenu x:Key="DropDownButtonContextMenu"
x:Shared="False"
Placement="Bottom">
<ContextMenu.Template>
<ControlTemplate>
<Border BorderBrush="{Binding PlacementTarget.BorderBrush, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
BorderThickness="{Binding PlacementTarget.BorderThickness, RelativeSource={RelativeSource AncestorType=ContextMenu}}">
<Border.Background>
<PriorityBinding>
<!-- override brush (only if not null) -->
<Binding Path="PlacementTarget.DropDownBackground" RelativeSource="{RelativeSource AncestorType=ContextMenu}" Converter="{StaticResource NullToUnset}" />
<!-- fall back to the button background -->
<Binding Path="PlacementTarget.Background" RelativeSource="{RelativeSource AncestorType=ContextMenu}" />
</PriorityBinding>
</Border.Background>
<local:DropDownButtonContextMenu x:Name="PART_DropDown"
Items="{Binding PlacementTarget.Items, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
ItemTemplate="{Binding PlacementTarget.ItemTemplate, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Width="{Binding PlacementTarget.ActualWidth, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
</Border>
</ControlTemplate>
</ContextMenu.Template>
</ContextMenu>
<Style TargetType="{x:Type local:DropDownButton}">
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu Placement="Bottom">
<ContextMenu.Template>
<ControlTemplate>
<Border BorderBrush="{Binding PlacementTarget.BorderBrush, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
BorderThickness="{Binding PlacementTarget.BorderThickness, RelativeSource={RelativeSource AncestorType=ContextMenu}}">
<Border.Background>
<PriorityBinding>
<!-- override brush (only if not null) -->
<Binding Path="PlacementTarget.DropDownBackground" RelativeSource="{RelativeSource AncestorType=ContextMenu}" Converter="{StaticResource NullToUnset}" />
<!-- fall back to the button background -->
<Binding Path="PlacementTarget.Background" RelativeSource="{RelativeSource AncestorType=ContextMenu}" />
</PriorityBinding>
</Border.Background>
<local:DropDownButtonContextMenu
x:Name="PART_DropDown"
Items="{Binding PlacementTarget.Items, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
ItemTemplate="{Binding PlacementTarget.ItemTemplate, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Width="{Binding PlacementTarget.ActualWidth, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
</Border>
</ControlTemplate>
</ContextMenu.Template>
</ContextMenu>
</Setter.Value>
</Setter>
<Setter Property="ContextMenu" Value="{StaticResource DropDownButtonContextMenu}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:DropDownButton}">
+7 -1
View File
@@ -76,10 +76,16 @@
<Grid Grid.Column="1"
Margin="5, 0, 5, 0">
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="{Binding ElementName=Browser, Path=BrowserSupported, Mode=OneWay, Converter={StaticResource BrowserComponentVisibilityConverter}}" />
</Grid.RowDefinitions>
<components:BuildComponent Margin="0, 0, 0, 10"
DataContext="{Binding ElementName=_this, Path=BuildComponentContext, Mode=OneWay}"
ValidData="{Binding ElementName=_this, Path=MainPlayerDataValid, Mode=OneWay}"
Visibility="{Binding ElementName=_this, Path=BuildComponentContext.IsInOutpost, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"
NavigateToClicked="Component_NavigateToClicked"/>
<Grid x:Name="BrowserHolder"
Grid.Row="0">
Grid.Row="1">
<controls:ChromiumBrowserWrapper
x:Name="Browser"
ControlsEnabled="True"
+21 -2
View File
@@ -79,6 +79,9 @@ public partial class FocusView : UserControl
[GenerateDependencyProperty]
private VanquishComponentContext vanquishComponentContext = default!;
[GenerateDependencyProperty]
private BuildComponentContext buildComponentContext = default!;
private bool browserMaximized = false;
private CancellationTokenSource? cancellationTokenSource;
@@ -189,13 +192,14 @@ public partial class FocusView : UserControl
var mainPlayerInfoTask = context.ApiContext.GetMainPlayerInfo(cancellationToken);
var mainPlayerStateTask = context.ApiContext.GetMainPlayerState(cancellationToken);
var mainPlayerBuildContextTask = context.ApiContext.GetMainPlayerBuildContext(cancellationToken);
var characterSelectTask = context.ApiContext.GetCharacters(cancellationToken);
var titleInfoTask = context.ApiContext.GetTitleInfo(cancellationToken);
var questLogTask = context.ApiContext.GetMainPlayerQuestLog(cancellationToken);
await Task.WhenAll(
mainPlayerInfoTask,
mainPlayerStateTask,
mainPlayerBuildContextTask,
characterSelectTask,
titleInfoTask,
questLogTask,
@@ -203,6 +207,7 @@ public partial class FocusView : UserControl
var mainPlayerInfo = await mainPlayerInfoTask;
var mainPlayerState = await mainPlayerStateTask;
var mainPlayerBuildContext = await mainPlayerBuildContextTask;
var characters = await characterSelectTask;
var titleInfo = await titleInfoTask;
var questLog = await questLogTask;
@@ -210,7 +215,8 @@ public partial class FocusView : UserControl
if (mainPlayerInfo is null ||
mainPlayerState is null ||
characters is null ||
questLog is null)
questLog is null ||
mainPlayerBuildContext is null)
{
this.MainPlayerDataValid = false;
continue;
@@ -222,6 +228,7 @@ public partial class FocusView : UserControl
this.SetQuestLogComponentContext(questLog);
this.SetPlayerResourcesComponentContext(mainPlayerState);
this.SetVanquishComponentContext(instanceInfo);
this.SetBuildComponentContext(instanceInfo, mainPlayerBuildContext);
this.MainPlayerDataValid = !this.PauseDataFetching;
this.Browser.Visibility = Visibility.Visible;
@@ -510,4 +517,16 @@ public partial class FocusView : UserControl
TotalKurzick = state.TotalKurzick,
};
}
private void SetBuildComponentContext(InstanceInfo instanceInfo, MainPlayerBuildContext mainPlayerBuildContext)
{
this.BuildComponentContext = new BuildComponentContext
{
IsInOutpost = instanceInfo.Type is Shared.Models.Api.InstanceType.Outpost,
PrimaryProfessionId = mainPlayerBuildContext.PrimaryProfessionId,
AccountUnlockedSkills = mainPlayerBuildContext.UnlockedAccountSkills,
CharacterUnlockedSkills = mainPlayerBuildContext.UnlockedCharacterSkills,
UnlockedProfessions = mainPlayerBuildContext.UnlockedProfessions,
};
}
}
+18 -91
View File
@@ -60,14 +60,16 @@
<ColumnDefinition Width="auto"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="auto"/>
<RowDefinition Height="auto"/>
<RowDefinition Height="auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="1" Text="Code: " Foreground="{StaticResource MahApps.Brushes.ThemeForeground}" Background="Transparent" FontSize="16" VerticalAlignment="Center" />
<TextBox Grid.Row="1" Grid.Column="1" Foreground="{StaticResource MahApps.Brushes.ThemeForeground}" Background="Transparent" FontSize="16"
Text="{Binding ElementName=_this, Path=CurrentBuildCode, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Template code for the current build"/>
ToolTip="Template code for the current build"
IsReadOnly="True"/>
<buttons:CopyButton Grid.Row="1"
Grid.Column="2"
Height="30"
@@ -105,101 +107,26 @@
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"
FontSize="20"
VerticalAlignment="Center"/>
<buttons:AddButton Height="30"
Width="30"
HorizontalAlignment="Right"
Margin="2"
ToolTip="Add new build"
Clicked="AddButton_Clicked"/>
<ListView Grid.Row="1"
ItemsSource="{Binding ElementName=_this, Path=CurrentBuild.Builds, Mode=OneWay}"
SelectedItem="{Binding ElementName=_this, Path=SelectedBuild, Mode=TwoWay}"
ScrollViewer.VerticalScrollBarVisibility="Auto"
Background="Transparent">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource MahApps.Brushes.ThemeForeground}" />
<Setter Property="BorderThickness" Value="0, 1, 0, 0" />
<!-- Optional: Set the selection background to be transparent or a specific color -->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListView.ItemContainerStyle>
<ListView.Template>
<ControlTemplate>
<Border
Padding="{TemplateBinding Control.Padding}"
Background="{TemplateBinding Panel.Background}"
BorderBrush="{TemplateBinding Border.BorderBrush}"
BorderThickness="{TemplateBinding Border.BorderThickness}"
SnapsToDevicePixels="True">
<ScrollViewer Padding="{TemplateBinding Control.Padding}" Focusable="False">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
</ScrollViewer>
</Border>
</ControlTemplate>
</ListView.Template>
<ListView.ItemsPanel>
<ItemsControl Grid.Row="1"
ItemsSource="{Binding ElementName=_this, Path=PartyMembers, Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<ListView.ItemTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid>
<Rectangle Fill="{StaticResource MahApps.Brushes.Accent}" >
<Rectangle.Visibility>
<MultiBinding Converter="{StaticResource EqualityToVisibilityConverter}">
<Binding />
<Binding Path="SelectedItem" RelativeSource="{RelativeSource AncestorType={x:Type ListView}}" />
</MultiBinding>
</Rectangle.Visibility>
</Rectangle>
<buttons:HighlightButton HighlightBrush="{StaticResource MahApps.Brushes.Accent}">
<buttons:HighlightButton.ButtonContent>
<Grid>
<WrapPanel Cursor="Hand"
VerticalAlignment="Center">
<TextBlock Text="{Binding Primary.Alias}"
FontSize="16"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"/>
<TextBlock Text="/"
FontSize="16"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"/>
<TextBlock Text="{Binding Secondary.Alias}"
FontSize="16"
Foreground="{StaticResource MahApps.Brushes.ThemeForeground}"/>
</WrapPanel>
</Grid>
</buttons:HighlightButton.ButtonContent>
</buttons:HighlightButton>
<buttons:BinButton Height="30"
Width="30"
HorizontalAlignment="Right"
VerticalAlignment="Center"
ToolTip="Delete build"
Clicked="BinButton_Clicked"/>
</Grid>
<templates:PartyMemberTemplate BehaviorChanged="PartyMemberTemplate_BehaviorChanged"
BuildSelected="PartyMemberTemplate_BuildSelected"/>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
<templates:BuildTemplate x:Name="BuildTemplate"
Grid.Column="1"
Grid.Row="2"
DataContext="{Binding ElementName=_this, Path=SelectedBuild, Mode=OneWay}"
BuildChanged="BuildTemplate_BuildChanged">
</templates:BuildTemplate>
Grid.Column="1"
Grid.Row="3"
DataContext="{Binding ElementName=_this, Path=SelectedBuild, Mode=OneWay}"
BuildChanged="BuildTemplate_BuildChanged" />
</Grid>
</Grid>
</UserControl>
@@ -1,4 +1,6 @@
using Daybreak.Controls.Buttons;
using Daybreak.Controls.Templates;
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Models.Builds;
using Daybreak.Shared.Models.Guildwars;
using Daybreak.Shared.Services.BuildTemplates;
@@ -7,6 +9,7 @@ using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Toolbox;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Core.Extensions;
using System.Extensions;
using System.Linq;
@@ -40,11 +43,17 @@ public partial class TeamBuildTemplateView : UserControl
[GenerateDependencyProperty]
private TeamBuildEntry currentBuild = default!;
[GenerateDependencyProperty]
private List<PartyMemberEntry> partyMembers = default!;
[GenerateDependencyProperty]
private PartyMemberEntry selectedPartyMember = default!;
[GenerateDependencyProperty]
private string currentBuildCode = string.Empty;
[GenerateDependencyProperty]
private string currentBuildSource = string.Empty;
[GenerateDependencyProperty]
private string currentBuildSubCode = string.Empty;
[GenerateDependencyProperty]
private bool isPartyLocked = false;
public TeamBuildTemplateView(
IToolboxService toolboxService,
@@ -69,6 +78,13 @@ public partial class TeamBuildTemplateView : UserControl
this.CurrentBuildCode = this.buildTemplateManager.EncodeTemplate(this.CurrentBuild);
this.CurrentBuildSource = buildEntry.SourceUrl;
this.SelectedBuild = this.CurrentBuild.Builds.FirstOrDefault();
this.PartyMembers = this.CurrentBuild.PartyComposition?.Select(p => new PartyMemberEntry
{
Behavior = p.Behavior ?? HeroBehavior.Undefined,
Build = this.CurrentBuild.Builds[p.Index],
Hero = Hero.TryParse((int)(p.HeroId ?? -1), out _) ? Hero.Parse((int)p.HeroId!) : default,
}).ToList()!;
this.IsPartyLocked = this.PartyMembers is not null;
this.preventDecode = false;
this.CurrentBuild.PropertyChanged += this.CurrentBuild_PropertyChanged;
}
@@ -330,5 +346,45 @@ public partial class TeamBuildTemplateView : UserControl
"Copied team build code",
$"Copied {this.CurrentBuildCode} code to clipboard");
}
private void PartyMemberTemplate_BehaviorChanged(object sender, HeroBehavior e)
{
if (sender is not PartyMemberTemplate template ||
template.DataContext is not PartyMemberEntry entry)
{
return;
}
var currentComposition = this.CurrentBuild.PartyComposition ?? [];
var oldComposition = currentComposition.Select((entry, index) => (entry, index)).FirstOrDefault(p => p.entry.HeroId == entry.Hero?.Id);
if (oldComposition.entry.HeroId is null ||
oldComposition.entry.HeroId != entry.Hero?.Id)
{
return;
}
var newComposition = new PartyCompositionMetadataEntry
{
Index = oldComposition.entry.Index,
Type = oldComposition.entry.Type,
HeroId = oldComposition.entry.HeroId,
Behavior = e
};
currentComposition[oldComposition.index] = newComposition;
this.CurrentBuild.PartyComposition = currentComposition;
}
private void PartyMemberListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
this.SelectedBuild = this.SelectedPartyMember?.Build;
}
private void PartyMemberTemplate_BuildSelected(object _, IBuildEntry e)
{
if (e is SingleBuildEntry build)
{
this.SelectedBuild = build;
}
}
}