AI: Loadout page — ZeroSpace/ZeroSpace.Web/Pages/Loadout.razor

This commit is contained in:
2026-10-03 03:23:21 +00:00
parent 6f13fd351c
commit e9a8302a01
+383
View File
@@ -0,0 +1,383 @@
@page "/loadout"
<PageTitle>Loadout</PageTitle>
<div class="section-header d-flex align-items-center mb-4">
<h1 class="mb-0">Loadout</h1>
<div class="ms-4 d-flex align-items-center gap-2">
<span class="hero-label">Hero:</span>
@foreach (var hero in Heroes)
{
<button type="button"
class="btn @(hero == _hero ? "btn-primary" : "btn-outline-secondary") btn-sm"
@onclick="() => SelectHero(hero)">
@hero
</button>
}
</div>
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
</div>
<div class="loadout-layout">
<section class="loadout-pool" aria-label="Available items">
<label class="details-toggle">
<input type="checkbox" @bind="_showDetails" />
Show details
</label>
<h2 class="pool-heading">Faction Powers</h2>
<div class="pool-list">
@foreach (var item in FactionPowers)
{
<article class="zs-glass-card pool-card @(_showDetails ? "" : "icon-only") @(_factionSlots.Contains(item) ? "equipped" : "")"
draggable="true"
title="@item.Name"
@ondragstart="() => _drag = item"
@ondragend="() => _drag = null">
@if (item.ImageUrl is not null)
{
<img class="pool-icon" src="@item.ImageUrl" alt="@item.Name" loading="lazy" draggable="false" />
}
else
{
<div class="pool-icon pool-icon-placeholder" aria-hidden="true">✦</div>
}
@if (_showDetails)
{
<div class="pool-body">
<div class="pool-name">@item.Name</div>
@if (item.LimitedToMerc is not null)
{
<span class="merc-badge">@item.LimitedToMerc</span>
}
<ul class="pool-levels">
@foreach (var level in item.Levels)
{
<li>@level</li>
}
</ul>
</div>
}
</article>
}
</div>
<h2 class="pool-heading">General Items</h2>
<div class="pool-list">
@foreach (var item in GeneralItemsList)
{
<article class="zs-glass-card pool-card @(_showDetails ? "" : "icon-only") @(_generalSlots.Contains(item) ? "equipped" : "")"
draggable="true"
title="@item.Name"
@ondragstart="() => _drag = item"
@ondragend="() => _drag = null">
@if (item.ImageUrl is not null)
{
<img class="pool-icon" src="@item.ImageUrl" alt="@item.Name" loading="lazy" draggable="false" />
}
else
{
<div class="pool-icon pool-icon-placeholder" aria-hidden="true">✦</div>
}
@if (_showDetails)
{
<div class="pool-body">
<div class="pool-name">@item.Name</div>
@if (item.LimitedToMerc is not null)
{
<span class="merc-badge">@item.LimitedToMerc</span>
}
<ul class="pool-levels">
@foreach (var level in item.Levels)
{
<li>@level</li>
}
</ul>
</div>
}
</article>
}
</div>
<h2 class="pool-heading">Master Items</h2>
<div class="pool-list">
@foreach (var item in MasterItems)
{
<article class="zs-glass-card pool-card @(_showDetails ? "" : "icon-only") @(_masterySlots.Contains(item) ? "equipped" : "")"
draggable="true"
title="@item.Name"
@ondragstart="() => _drag = item"
@ondragend="() => _drag = null">
@if (item.ImageUrl is not null)
{
<img class="pool-icon" src="@item.ImageUrl" alt="@item.Name" loading="lazy" draggable="false" />
}
else
{
<div class="pool-icon pool-icon-placeholder" aria-hidden="true">✦</div>
}
@if (_showDetails)
{
<div class="pool-body">
<div class="pool-name">@item.Name</div>
<ul class="pool-levels">
@foreach (var level in item.Levels)
{
<li>@level</li>
}
</ul>
</div>
}
</article>
}
</div>
</section>
<section class="loadout-board" aria-label="Current loadout">
@if (_mercError is not null)
{
<div class="merc-error" role="alert">@_mercError</div>
}
<h2 class="pool-heading">Faction Powers <span class="slot-count">pick up to 4</span></h2>
<div class="slot-grid">
@for (var i = 0; i < _factionSlots.Length; i++)
{
var index = i;
<div class="slot @(IsConflict(_factionSlots[index]) ? "slot-conflict" : "") @(_factionSlots[index] is null ? "slot-empty" : "")"
@ondragover:preventDefault
@ondrop="() => DropFaction(index)">
@if (_factionSlots[index] is { } slotted)
{
<button type="button" class="slot-remove" @onclick="() => RemoveFaction(index)" aria-label="Remove @slotted.Name">×</button>
@if (slotted.ImageUrl is not null)
{
<img class="slot-icon" src="@slotted.ImageUrl" alt="" draggable="false" />
}
else
{
<div class="slot-icon slot-icon-placeholder" aria-hidden="true">✦</div>
}
<span class="slot-name">@slotted.Name</span>
}
else
{
<span class="slot-hint">Drop here</span>
}
</div>
}
</div>
<h2 class="pool-heading">General Items <span class="slot-count">pick up to 3</span></h2>
<div class="slot-grid">
@for (var i = 0; i < _generalSlots.Length; i++)
{
var index = i;
<div class="slot @(IsConflict(_generalSlots[index]) ? "slot-conflict" : "") @(_generalSlots[index] is null ? "slot-empty" : "")"
@ondragover:preventDefault
@ondrop="() => DropGeneral(index)">
@if (_generalSlots[index] is { } slotted)
{
<button type="button" class="slot-remove" @onclick="() => RemoveGeneral(index)" aria-label="Remove @slotted.Name">×</button>
@if (slotted.ImageUrl is not null)
{
<img class="slot-icon" src="@slotted.ImageUrl" alt="" draggable="false" />
}
else
{
<div class="slot-icon slot-icon-placeholder" aria-hidden="true">✦</div>
}
<span class="slot-name">@slotted.Name</span>
}
else
{
<span class="slot-hint">Drop here</span>
}
</div>
}
</div>
<h2 class="pool-heading">Master Items <span class="slot-count">pick up to 2</span></h2>
<div class="slot-grid">
@for (var i = 0; i < _masterySlots.Length; i++)
{
var index = i;
<div class="slot @(IsConflict(_masterySlots[index]) ? "slot-conflict" : "") @(_masterySlots[index] is null ? "slot-empty" : "")"
@ondragover:preventDefault
@ondrop="() => DropMastery(index)">
@if (_masterySlots[index] is { } slotted)
{
<button type="button" class="slot-remove" @onclick="() => RemoveMastery(index)" aria-label="Remove @slotted.Name">×</button>
@if (slotted.ImageUrl is not null)
{
<img class="slot-icon" src="@slotted.ImageUrl" alt="" draggable="false" />
}
else
{
<div class="slot-icon slot-icon-placeholder" aria-hidden="true">✦</div>
}
<span class="slot-name">@slotted.Name</span>
}
else
{
<span class="slot-hint">Drop here</span>
}
</div>
}
</div>
</section>
</div>
@code {
private sealed record PoolItem(
string Kind,
string Id,
string Name,
IReadOnlyList<string> Levels,
string? LimitedTo,
string? LimitedToMerc,
string? ImageUrl);
private static readonly string[] Heroes = ["Vynthra", "Prefect Aster"];
private string _hero = Heroes[0];
private bool _showDetails = true;
private PoolItem? _drag;
private List<PoolItem> _allFactionPowers = [];
private List<PoolItem> _allGeneralItems = [];
private List<PoolItem> _allMasterItems = [];
private PoolItem?[] _factionSlots = new PoolItem?[4];
private PoolItem?[] _generalSlots = new PoolItem?[3];
private PoolItem?[] _masterySlots = new PoolItem?[2];
private readonly HashSet<PoolItem> _mercConflicts = [];
private string? _mercError;
private IEnumerable<PoolItem> FactionPowers =>
_allFactionPowers.Where(item => item.LimitedTo is null || item.LimitedTo == _hero);
private IEnumerable<PoolItem> GeneralItemsList => _allGeneralItems;
private IEnumerable<PoolItem> MasterItems => _allMasterItems;
protected override void OnInitialized()
{
_allFactionPowers = FactionPowerItems.All
.Select(item => new PoolItem(
"faction",
item.Id,
item.Name,
item.Levels,
item.LimitedTo,
item.LimitedToMerc,
item.ImageFileName is null ? null : $"images/faction-power-items/{Uri.EscapeDataString(item.ImageFileName)}"))
.ToList();
_allGeneralItems = GeneralItems.All
.Select(item => new PoolItem(
"general",
item.Id,
item.Name,
item.Levels,
LimitedTo: null,
item.LimitedToMerc,
item.ImageFileName is null ? null : $"images/general-items/{Uri.EscapeDataString(item.ImageFileName)}"))
.ToList();
_allMasterItems = PlanetaryMasteryItems.All
.Select(item => new PoolItem(
"mastery",
item.Id,
item.Name,
item.Levels,
LimitedTo: null,
LimitedToMerc: null,
item.ImageFileName is null ? null : $"images/planetary-mastery-items/{Uri.EscapeDataString(item.ImageFileName)}"))
.ToList();
}
private void SelectHero(string hero)
{
if (_hero == hero)
{
return;
}
_hero = hero;
// Faction powers are hero-locked: unequip any that belong to another hero.
for (var i = 0; i < _factionSlots.Length; i++)
{
var slotted = _factionSlots[i];
if (slotted?.LimitedTo is not null && slotted.LimitedTo != _hero)
{
_factionSlots[i] = null;
}
}
ValidateMercs();
}
private void DropFaction(int index) => DropInto(_factionSlots, "faction", index);
private void DropGeneral(int index) => DropInto(_generalSlots, "general", index);
private void DropMastery(int index) => DropInto(_masterySlots, "mastery", index);
private void DropInto(PoolItem?[] slots, string kind, int index)
{
var dragged = _drag;
_drag = null;
if (dragged is null || dragged.Kind != kind)
{
return;
}
if (slots.Contains(dragged) || slots[index] is not null)
{
return;
}
slots[index] = dragged;
ValidateMercs();
}
private void RemoveFaction(int index) => RemoveFrom(_factionSlots, index);
private void RemoveGeneral(int index) => RemoveFrom(_generalSlots, index);
private void RemoveMastery(int index) => RemoveFrom(_masterySlots, index);
private void RemoveFrom(PoolItem?[] slots, int index)
{
slots[index] = null;
ValidateMercs();
}
private bool IsConflict(PoolItem? item) => item is not null && _mercConflicts.Contains(item);
private IEnumerable<PoolItem> SlottedItems() =>
_factionSlots.Concat(_generalSlots).Concat(_masterySlots).Where(static item => item is not null)!;
private void ValidateMercs()
{
var mercs = SlottedItems()
.Where(static item => item.LimitedToMerc is not null)
.Select(static item => item.LimitedToMerc!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
_mercConflicts.Clear();
if (mercs.Count > 1)
{
foreach (var item in SlottedItems().Where(static item => item.LimitedToMerc is not null))
{
_mercConflicts.Add(item);
}
_mercError = "Two items cannot be from different mercs.";
}
else
{
_mercError = null;
}
}
}