Add level-aware builder controls

This commit is contained in:
JB
2026-07-25 20:52:08 -04:00
parent 7f9a75021d
commit 0425fede8e
13 changed files with 654 additions and 26 deletions
+46 -4
View File
@@ -1,6 +1,6 @@
export type Roll = { statId: string; value: number };
export type GearSelection = { itemId: string; level: number; 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 GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: [Roll, Roll]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
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 = {
@@ -15,8 +15,8 @@ export type LoadoutDraft = {
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: '', level: 50, 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 blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({
version: 1,
@@ -36,3 +36,45 @@ export const clamp = (value: unknown, min: unknown, max: unknown) => {
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;
export type LevelScaledRange = {
rawMin: number;
rawMax: number;
min: number;
max: number;
provisional: boolean;
};
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level);
export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level);
export function levelScaledRange(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
referenceLevel: unknown,
source: 'gear' | 'weapon'
): LevelScaledRange {
const target = itemLevel(targetLevel);
const reference = itemLevel(referenceLevel);
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
const ratio = multiplier(target) / multiplier(reference);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40
};
}
export function remapRangeValue(value: unknown, from: LevelScaledRange, to: LevelScaledRange) {
const span = from.max - from.min;
const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
return Math.round(to.min + (to.max - to.min) * position);
}
+7 -1
View File
@@ -132,7 +132,13 @@
{@render children()}
<footer class="site-footer">
<div><a class="brand footer-brand" href="/"><span class="brand-mark"><i></i><b>SHD</b></span><span>SHD<span class="accent">.build</span></span></a><p>Independent loadout intelligence for the agent community.</p></div>
<div>
<a class="brand footer-brand" href="/" aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span>
<span class="brand-name">SHD<span>.build</span><small>LOADOUT NETWORK</small></span>
</a>
<p>Independent loadout intelligence for the agent community.</p>
</div>
<div class="footer-links">
<a class="footer-discord" href="https://discord.gg/dhpd33VwQK" target="_blank" rel="noreferrer">
<img src="/discord-symbol.svg" alt="" aria-hidden="true" />
@@ -0,0 +1,52 @@
<script lang="ts">
let {
label,
name,
items,
value = $bindable(''),
columns = Math.min(items.length, 4),
searchable = false,
required = false,
onchange
}: {
label: string;
name: string;
items: Array<{ id: string; name: string; iconUrl?: string; category?: string }>;
value?: string;
columns?: number;
searchable?: boolean;
required?: boolean;
onchange?: (id: string) => void;
} = $props();
let query = $state('');
const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
));
function choose(id: string) {
value = id;
onchange?.(id);
}
</script>
<fieldset class="builder-radio-grid" style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend>
{#if searchable}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
{/if}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} onchange={() => choose(item.id)}/>
<span>
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>{item.name.slice(0,2).toUpperCase()}</i>{/if}
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
</fieldset>
@@ -0,0 +1,57 @@
<script lang="ts">
import { clamp } from '$lib/builder/loadout';
let {
label,
min,
max,
value = $bindable(),
provisional = false,
step = 1,
onchange
}: {
label: string;
min: number;
max: number;
value: number;
provisional?: boolean;
step?: number;
onchange?: (value: number) => void;
} = $props();
function update(raw: unknown) {
const next = Number(clamp(raw, min, max));
if (onchange) onchange(next);
else value = next;
}
</script>
<label class="range-value-control">
<span class="range-value-head">
<b>{label}</b>
<small>{min}–{max}{provisional ? ' · ESTIMATED' : ''}</small>
</span>
<span class="range-value-inputs">
<input
class="range-value-slider"
type="range"
{min}
{max}
{step}
value={value}
aria-label={`${label} slider`}
oninput={(event) => update(event.currentTarget.value)}
/>
<input
class="range-value-number"
type="number"
{min}
{max}
{step}
value={value}
aria-label={`${label} value`}
onchange={(event) => update(event.currentTarget.value)}
onblur={(event) => update(event.currentTarget.value)}
/>
</span>
</label>