diff --git a/src/Web/Shared/ComponentBuilders/SkillListFieldBuilder.cs b/src/Web/Shared/ComponentBuilders/SkillListFieldBuilder.cs new file mode 100644 index 000000000..d16d7363b --- /dev/null +++ b/src/Web/Shared/ComponentBuilders/SkillListFieldBuilder.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.ComponentBuilders; + +using System.Reflection; +using Microsoft.AspNetCore.Components.Rendering; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Web.Shared.Components.Form; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// A for of fields. +/// It shows a skill list including a master skill tree editor. +/// +public class SkillListFieldBuilder : BaseComponentBuilder, IComponentBuilder +{ + /// + public int BuildComponent(object model, PropertyInfo propertyInfo, RenderTreeBuilder builder, int currentIndex, IChangeNotificationService notificationService) + => this.BuildField>(model, typeof(SkillListField), propertyInfo, builder, currentIndex, notificationService); + + /// + public bool CanBuildComponent(PropertyInfo propertyInfo) + => propertyInfo.PropertyType == typeof(ICollection); +} diff --git a/src/Web/Shared/Components/Form/AutoFields.cs b/src/Web/Shared/Components/Form/AutoFields.cs index 56c1497ed..97a5daab1 100644 --- a/src/Web/Shared/Components/Form/AutoFields.cs +++ b/src/Web/Shared/Components/Form/AutoFields.cs @@ -49,6 +49,7 @@ static AutoFields() Builders.Add(new ItemStorageFieldBuilder()); Builders.Add(new LookupFieldBuilder()); Builders.Add(new EmbeddedFormFieldBuilder()); + Builders.Add(new SkillListFieldBuilder()); Builders.Add(new ObjectCollectionFieldBuilder()); Builders.Add(new IntCollectionFieldBuilder()); Builders.Add(new ByteArrayFieldBuilder()); diff --git a/src/Web/Shared/Components/Form/SkillListField.razor b/src/Web/Shared/Components/Form/SkillListField.razor new file mode 100644 index 000000000..953a956a7 --- /dev/null +++ b/src/Web/Shared/Components/Form/SkillListField.razor @@ -0,0 +1,99 @@ +@* + Licensed under the MIT License. See LICENSE file in the project root for full license information. + *@ + +@using MUnique.OpenMU.DataModel.Configuration +@using MUnique.OpenMU.DataModel.Entities +@using MUnique.OpenMU.Persistence + +@inherits InputBase> + +
+ + @* ── Regular Skills ─────────────────────────────────────────────────── *@ +
+
+ +
+
+ + + @foreach (var entry in this.RegularSkills) + { + + + + + } + + + + + + +
@(entry.Skill?.GetName() ?? "Unknown Skill") + +
+ +
+
+
+ + @* ── Master Skill Tree ──────────────────────────────────────────────── *@ + @if (this.MasterSkillRoots.Count > 0) + { +
+
+ Master Skill Tree + +
+
+
+ @foreach (var root in this.MasterSkillRoots) + { +
+
@root.Name
+ @foreach (var rank in this.GetRanksForRoot(root)) + { +
+ @foreach (var skill in this.GetSkillsForRootAndRank(root, rank)) + { + var level = this.GetMasterSkillLevel(skill); + var maxLevel = skill.MasterDefinition!.MaximumLevel; + var requiredNames = this.GetRequiredSkillNames(skill); +
+
@skill.GetName()
+ @if (!string.IsNullOrEmpty(requiredNames)) + { +
+ ↑ @requiredNames +
+ } + +
+ } +
+ } +
+ } +
+
+
+ } +
diff --git a/src/Web/Shared/Components/Form/SkillListField.razor.cs b/src/Web/Shared/Components/Form/SkillListField.razor.cs new file mode 100644 index 000000000..b14a8a920 --- /dev/null +++ b/src/Web/Shared/Components/Form/SkillListField.razor.cs @@ -0,0 +1,279 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Form; + +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Blazored.Modal; +using Blazored.Modal.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence; + +/// +/// A component that displays and allows editing a character's skill list, including the master skill tree. +/// +public partial class SkillListField : InputBase> +{ + private Character? _character; + private IList? _masterSkillRoots; + private IList? _availableMasterSkills; + + /// + /// Gets or sets the game configuration data source used to retrieve available skills. + /// + [Inject] + public IDataSource GameConfigurationSource { get; set; } = null!; + + /// + /// Gets or sets the modal service for opening creation dialogs. + /// + [Inject] + public IModalService ModalService { get; set; } = null!; + + /// + /// Gets or sets the persistence context. + /// + [CascadingParameter] + public IContext PersistenceContext { get; set; } = null!; + + /// + /// Gets or sets the label. + /// + [Parameter] + public string? Label { get; set; } + + private IEnumerable RegularSkills => + (this.Value ?? Enumerable.Empty()) + .Where(e => e.Skill?.MasterDefinition == null) + .OrderBy(e => e.Skill?.GetName()); + + private IList MasterSkillRoots + { + get + { + if (this._masterSkillRoots is null) + { + this.LoadMasterSkillData(); + } + + return this._masterSkillRoots ?? new List(); + } + } + + private IList AvailableMasterSkills + { + get + { + if (this._availableMasterSkills is null) + { + this.LoadMasterSkillData(); + } + + return this._availableMasterSkills ?? new List(); + } + } + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + // Extract the Character object from the ValueExpression (e.g. () => character.LearnedSkills) + if (this.ValueExpression?.Body is MemberExpression { Expression: ConstantExpression { Value: { } owner } } + && owner is Character character + && character != this._character) + { + this._character = character; + + // Reset cached data when the character changes + this._masterSkillRoots = null; + this._availableMasterSkills = null; + } + } + + /// + protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out ICollection result, [NotNullWhen(false)] out string? validationErrorMessage) + { + throw new NotImplementedException(); + } + + private void LoadMasterSkillData() + { + var allSkills = this.GameConfigurationSource.GetAll(); + var masterSkills = allSkills.Where(s => s.MasterDefinition != null); + + if (this._character?.CharacterClass is { } characterClass) + { + masterSkills = masterSkills.Where(s => s.QualifiedCharacters.Contains(characterClass)); + } + + this._availableMasterSkills = masterSkills + .OrderBy(s => s.MasterDefinition!.Rank) + .ThenBy(s => s.Number) + .ToList(); + + this._masterSkillRoots = this._availableMasterSkills + .Select(s => s.MasterDefinition!.Root) + .Where(r => r is not null) + .Distinct() + .Cast() + .ToList(); + } + + private IEnumerable GetRanksForRoot(MasterSkillRoot root) + { + return this.AvailableMasterSkills + .Where(s => s.MasterDefinition?.Root == root) + .Select(s => s.MasterDefinition!.Rank) + .Distinct() + .OrderBy(r => r); + } + + private IEnumerable GetSkillsForRootAndRank(MasterSkillRoot root, byte rank) + { + return this.AvailableMasterSkills + .Where(s => s.MasterDefinition?.Root == root && s.MasterDefinition?.Rank == rank) + .OrderBy(s => s.Number); + } + + private int GetMasterSkillLevel(Skill skill) + { + return (this.Value ?? Enumerable.Empty()) + .FirstOrDefault(e => e.Skill?.Number == skill.Number)?.Level ?? 0; + } + + private string GetRequiredSkillNames(Skill skill) + { + var required = skill.MasterDefinition?.RequiredMasterSkills; + if (required is null || !required.Any()) + { + return string.Empty; + } + + return string.Join(", ", required.Select(s => s.GetName())); + } + + private async Task OnMasterSkillLevelChangedAsync(Skill skill, ChangeEventArgs args) + { + if (!int.TryParse(args.Value?.ToString(), out var level)) + { + return; + } + + level = Math.Max(0, Math.Min(level, skill.MasterDefinition!.MaximumLevel)); + + var collection = this.Value; + if (collection is null) + { + return; + } + + var existingEntry = collection.FirstOrDefault(e => e.Skill?.Number == skill.Number); + + if (level == 0 && existingEntry is not null) + { + collection.Remove(existingEntry); + if (this.PersistenceContext.IsSupporting(typeof(SkillEntry))) + { + await this.PersistenceContext.DeleteAsync(existingEntry).ConfigureAwait(false); + } + } + else if (level > 0) + { + if (existingEntry is not null) + { + existingEntry.Level = level; + } + else + { + var newEntry = this.PersistenceContext.IsSupporting(typeof(SkillEntry)) + ? this.PersistenceContext.CreateNew() + : new SkillEntry(); + newEntry.Skill = skill; + newEntry.Level = level; + collection.Add(newEntry); + } + } + + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } + + private async Task OnResetMasterSkillsClickAsync() + { + var collection = this.Value; + if (collection is null) + { + return; + } + + var masterEntries = collection + .Where(e => e.Skill?.MasterDefinition is not null) + .ToList(); + + foreach (var entry in masterEntries) + { + collection.Remove(entry); + if (this.PersistenceContext.IsSupporting(typeof(SkillEntry))) + { + await this.PersistenceContext.DeleteAsync(entry).ConfigureAwait(false); + } + } + + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } + + private async Task OnAddRegularSkillClickAsync() + { + var newEntry = this.PersistenceContext.IsSupporting(typeof(SkillEntry)) + ? this.PersistenceContext.CreateNew() + : new SkillEntry(); + + var parameters = new ModalParameters(); + parameters.Add(nameof(ModalCreateNew.Item), newEntry); + parameters.Add(nameof(ModalCreateNew.PersistenceContext), this.PersistenceContext); + if (this._character is not null) + { + parameters.Add(nameof(ModalCreateNew.Owner), this._character); + } + + var options = new ModalOptions { DisableBackgroundCancel = true }; + var modal = this.ModalService.Show>("Add Skill", parameters, options); + var result = await modal.Result.ConfigureAwait(false); + + if (result.Cancelled) + { + if (this.PersistenceContext.IsSupporting(typeof(SkillEntry))) + { + await this.PersistenceContext.DeleteAsync(newEntry).ConfigureAwait(false); + } + } + else + { + this.Value ??= new List(); + this.Value.Add(newEntry); + if (this.PersistenceContext.IsSupporting(typeof(SkillEntry))) + { + await this.PersistenceContext.SaveChangesAsync().ConfigureAwait(false); + } + + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } + } + + private async Task OnRemoveSkillClickAsync(SkillEntry entry) + { + this.Value?.Remove(entry); + if (this.PersistenceContext.IsSupporting(typeof(SkillEntry))) + { + await this.PersistenceContext.DeleteAsync(entry).ConfigureAwait(false); + await this.PersistenceContext.SaveChangesAsync().ConfigureAwait(false); + } + + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } +} diff --git a/src/Web/Shared/Components/Form/SkillListField.razor.css b/src/Web/Shared/Components/Form/SkillListField.razor.css new file mode 100644 index 000000000..56926f44c --- /dev/null +++ b/src/Web/Shared/Components/Form/SkillListField.razor.css @@ -0,0 +1,88 @@ +.master-skill-tree { + display: flex; + flex-direction: row; + gap: 1rem; + overflow-x: auto; +} + +.master-skill-root { + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.master-skill-root-header { + font-weight: bold; + text-align: center; + padding: 4px 8px; + background-color: #333; + color: #f0c040; + border-radius: 4px; + margin-bottom: 4px; +} + +.master-skill-rank { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.4rem; + padding: 4px 0; + border-bottom: 1px dashed #555; +} + +.master-skill-entry { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 6px; + border: 1px solid #666; + border-radius: 4px; + min-width: 80px; + max-width: 120px; + background-color: #2a2a2a; + color: #ccc; +} + +.master-skill-entry.master-skill-active { + border-color: #f0c040; + background-color: #3a3000; + color: #f0c040; +} + +.master-skill-name { + font-size: 0.75rem; + text-align: center; + word-break: break-word; + overflow: hidden; + max-height: 2.4em; + line-height: 1.2em; + margin-bottom: 2px; +} + +.master-skill-requires { + font-size: 0.65rem; + color: #aaa; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + margin-bottom: 2px; +} + +.master-skill-active .master-skill-requires { + color: #c8a820; +} + +.master-skill-level-input { + width: 56px; + text-align: center; + background-color: #1a1a1a; + color: #fff; + border: 1px solid #888; + border-radius: 3px; + font-size: 0.8rem; + padding: 1px 2px; +}