Build catalog-backed loadout workbench

This commit is contained in:
JB
2026-07-25 16:56:27 -04:00
parent 095b697b08
commit e5f0414a8e
6 changed files with 462 additions and 22 deletions
+38
View File
@@ -0,0 +1,38 @@
export type Roll = { statId: string; value: number };
export type GearSelection = { itemId: string; brandId: string; talentId: string; health: number; armor: number; rolls: Roll[]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string };
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
export type LoadoutDraft = {
version: 1;
buildName: string;
gear: Record<string, GearSelection>;
weapons: [WeaponSelection, WeaponSelection];
chips: [ChipSelection, ChipSelection, ChipSelection];
protocol: ProtocolSelection;
activeTalents: string[];
};
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
export const blankGear = (): GearSelection => ({ itemId: '', brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '' });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({
version: 1,
buildName: 'Untitled loadout',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
weapons: [blankWeapon(), blankWeapon()],
chips: [blankChip(), blankChip(), blankChip()],
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
activeTalents: []
});
export const clamp = (value: unknown, min: unknown, max: unknown) => {
const low = Number(min), high = Number(max), numeric = Number(value);
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
return Math.min(Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric));
};
export const scaled = (min: unknown, max: unknown, progress: number, steps: number) =>
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
let {
items,
value = $bindable(''),
label,
placeholder = 'Search available items…',
disabledIds = [],
onchange
}: {
items: Array<{ id: string; name: string; iconUrl?: string; category?: string; pending?: boolean }>;
value?: string;
label: string;
placeholder?: string;
disabledIds?: string[];
onchange?: (id: string) => void;
} = $props();
let open = $state(false);
let query = $state('');
const selected = $derived(items.find((item) => item.id === value));
const results = $derived(items.filter((item) => item.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())).slice(0, 40));
const choose = (id: string) => { value = id; onchange?.(id); open = false; query = ''; };
</script>
<div class="builder-picker">
<span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
{#if selected?.iconUrl}<img src={selected.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
</button>
{#if open}
<div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<div>
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
{#each results as item}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
</button>
{:else}<p>NO MATCHING ITEMS</p>{/each}
</div>
</div>
{/if}
</div>