998 lines
40 KiB
HTML
998 lines
40 KiB
HTML
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<title>OnlyScavs – Loadout Planner</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
:root {
|
||
--bg: #121212;
|
||
--panel: #1a1a1a;
|
||
--text: #eee;
|
||
--muted: #bbb;
|
||
--border: #333;
|
||
--accent: #9ccfff;
|
||
--amber: #ffd580;
|
||
}
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
margin: 0;
|
||
padding-top: 52px;
|
||
background-image: url('/assets/onlyscavs.png');
|
||
background-attachment: fixed;
|
||
background-size: cover;
|
||
background-position: center 65%;
|
||
}
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(14,14,14,0.88);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
|
||
h1 { margin-bottom: 4px; }
|
||
a { color: var(--accent); }
|
||
.site-nav {
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 0 24px; height: 52px;
|
||
background: rgba(14,14,14,0.92);
|
||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||
}
|
||
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
|
||
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
|
||
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
|
||
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||
|
||
/* Tab bar */
|
||
.tab-bar {
|
||
display: flex;
|
||
gap: 2px;
|
||
margin-bottom: 20px;
|
||
border-bottom: 2px solid var(--border);
|
||
flex-wrap: wrap;
|
||
}
|
||
.tab-bar a {
|
||
text-decoration: none;
|
||
color: var(--muted);
|
||
padding: 8px 16px;
|
||
font-size: 0.95rem;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -2px;
|
||
}
|
||
.tab-bar a.active {
|
||
color: var(--accent);
|
||
border-bottom-color: var(--accent);
|
||
}
|
||
.tab-bar a:hover { color: var(--text); }
|
||
|
||
/* Filter bar */
|
||
.filter-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.filter-bar label { color: var(--muted); font-size: 0.9rem; }
|
||
.filter-bar select, .filter-bar input[type=number] {
|
||
background: #222;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 4px 8px;
|
||
font-size: 0.9rem;
|
||
}
|
||
.filter-bar button {
|
||
background: #2a2a2a;
|
||
color: var(--text);
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
padding: 4px 12px;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
.filter-bar button:hover { border-color: var(--accent); }
|
||
|
||
/* Slot filter checkboxes */
|
||
.slot-check {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
background: #222;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 3px 9px;
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
color: var(--muted);
|
||
}
|
||
.slot-check.active {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
.slot-check input { cursor: pointer; }
|
||
|
||
/* Gear table */
|
||
.gear-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.88rem;
|
||
}
|
||
.gear-table th {
|
||
text-align: left;
|
||
padding: 6px 10px;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
border-bottom: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
}
|
||
.gear-table td {
|
||
padding: 7px 10px;
|
||
border-bottom: 1px solid #1e1e1e;
|
||
vertical-align: middle;
|
||
}
|
||
.gear-table tr:hover td { background: #1c1c1c; }
|
||
.gear-table img {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 4px;
|
||
background: #222;
|
||
object-fit: contain;
|
||
}
|
||
.w { font-weight: bold; color: var(--amber); white-space: nowrap; }
|
||
.muted { color: var(--muted); }
|
||
.name-cell strong { display: block; }
|
||
.name-cell small { color: var(--muted); font-size: 0.8rem; }
|
||
|
||
/* Armor class badges */
|
||
.cls {
|
||
display: inline-block;
|
||
padding: 2px 7px;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
font-weight: bold;
|
||
background: #2a2a2a;
|
||
border: 1px solid #444;
|
||
}
|
||
.cls-1, .cls-2 { border-color: #444; color: #aaa; }
|
||
.cls-3 { border-color: #5a7a3a; color: #8fc87f; }
|
||
.cls-4 { border-color: #3a6a8a; color: #7fc4e8; }
|
||
.cls-5 { border-color: #7a4a8a; color: #c090e0; }
|
||
.cls-6 { border-color: #8a4a3a; color: #e09070; }
|
||
|
||
/* Empty state */
|
||
.empty {
|
||
color: var(--muted);
|
||
padding: 28px 14px;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
/* Build builder */
|
||
.builder-total {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 16px 24px;
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.builder-total .big { font-size: 2rem; font-weight: bold; color: var(--amber); }
|
||
.builder-total .label { color: var(--muted); font-size: 0.9rem; }
|
||
.builder-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.slot-card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
}
|
||
.slot-card h3 {
|
||
margin: 0 0 8px;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
}
|
||
.slot-card select {
|
||
width: 100%;
|
||
background: #222;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 5px 8px;
|
||
font-size: 0.85rem;
|
||
}
|
||
.slot-weight { margin-top: 6px; font-size: 0.85rem; color: var(--amber); min-height: 1.2em; }
|
||
.save-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||
.save-row input[type=text] {
|
||
background: #222;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 5px 10px;
|
||
font-size: 0.9rem;
|
||
width: 200px;
|
||
}
|
||
.save-row button {
|
||
background: #2a2a2a;
|
||
color: var(--text);
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
padding: 5px 14px;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
.save-row button:hover { border-color: var(--accent); }
|
||
.save-status { color: var(--muted); font-size: 0.85rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<nav class="site-nav">
|
||
<a class="nav-brand" href="/">OnlyScavs</a>
|
||
<div class="nav-links">
|
||
<a href="/keys">Keys</a>
|
||
<a href="/collector">Collector</a>
|
||
<a href="/quests">Quests</a>
|
||
<a href="/loadout" class="active">Loadout</a>
|
||
<a href="/meds">Injectors</a>
|
||
<a href="/barters">Barters</a>
|
||
</div>
|
||
</nav>
|
||
<h1>Loadout Planner</h1>
|
||
<p style="color:var(--muted);margin:0 0 16px;font-size:0.9rem;">
|
||
Find the lightest gear for each slot. Filter by requirements.
|
||
</p>
|
||
|
||
<div class="tab-bar">
|
||
{% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Soft Rigs'),('armored_rigs','Armored Rigs'),('plates','Plates'),('builder','Build Builder')] %}
|
||
<a href="/loadout?tab={{ t_id }}" class="{% if tab == t_id %}active{% endif %}">{{ t_label }}</a>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
{# =============================== GUNS TAB =============================== #}
|
||
{% if tab == "guns" %}
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="guns">
|
||
<span style="color:var(--muted);font-size:0.85rem;">Must have slot:</span>
|
||
{% for label, nameid in slot_filters %}
|
||
<label class="slot-check {% if nameid in requires %}active{% endif %}">
|
||
<input type="checkbox" name="requires" value="{{ nameid }}"
|
||
{% if nameid in requires %}checked{% endif %}>
|
||
{{ label }}
|
||
</label>
|
||
{% endfor %}
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
{% if requires %}<a href="/loadout?tab=guns" style="font-size:0.85rem;color:var(--muted)">clear</a>{% endif %}
|
||
</form>
|
||
|
||
{% if requires %}
|
||
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:12px;">
|
||
"Lightest build" = gun base weight + lightest compatible mod per required slot.
|
||
Guns without all required slots are hidden.
|
||
</p>
|
||
{% endif %}
|
||
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>Name</th>
|
||
<th>Caliber</th>
|
||
<th>Ergo</th>
|
||
<th title="Vertical recoil">Recoil</th>
|
||
<th>Base weight</th>
|
||
<th>{% if requires %}Lightest build{% else %}Slots{% endif %}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for gun in guns %}
|
||
<tr class="gun-row" data-gun-id="{{ gun.id }}" onclick="toggleGunRow(this)" style="cursor:pointer">
|
||
<td>
|
||
{% if gun.grid_image_url %}
|
||
<img src="{{ gun.grid_image_url }}" loading="lazy" alt="">
|
||
{% endif %}
|
||
</td>
|
||
<td class="name-cell">
|
||
<strong>{{ gun.short_name or gun.name }}</strong>
|
||
{% if gun.short_name and gun.short_name != gun.name %}
|
||
<small>{{ gun.name }}</small>
|
||
{% endif %}
|
||
</td>
|
||
<td class="muted">{{ gun.caliber or '—' }}</td>
|
||
<td class="muted">{{ gun.ergonomics or '—' }}</td>
|
||
<td class="muted">{{ gun.recoil_vertical or '—' }}</td>
|
||
<td class="w">
|
||
{% if gun.weight_kg is not none %}{{ "%.3f"|format(gun.weight_kg) }} kg{% else %}—{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if requires %}
|
||
<span class="w">{% if gun.lightest_build_weight is not none %}{{ "%.3f"|format(gun.lightest_build_weight) }} kg{% else %}—{% endif %}</span>
|
||
{% else %}
|
||
<span class="muted" style="font-size:0.8rem">▶ expand</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
<tr class="gun-expand-row" id="expand-{{ gun.id }}" style="display:none">
|
||
<td colspan="7" style="padding:0">
|
||
<div class="gun-expand-inner" style="padding:10px 14px;background:#151515;border-bottom:1px solid var(--border)">
|
||
<div class="expand-loading" style="color:var(--muted);font-size:0.85rem">Loading slots…</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="7" class="empty">No guns found matching those requirements.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
|
||
<style>
|
||
.gun-row:hover td { background: #1c1c1c; }
|
||
.gun-row.expanded td { background: #181818; }
|
||
.slot-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
|
||
.slot-pill {
|
||
display: flex; flex-direction: column;
|
||
background: #1e1e1e; border: 1px solid var(--border);
|
||
border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; min-width: 110px;
|
||
}
|
||
.slot-pill.key { border-color: #5a7a3a; background: #141e10; }
|
||
.slot-pill.optional { opacity: 0.55; }
|
||
.slot-pill .sp-name { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.slot-pill .sp-mod { font-size: 0.82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
|
||
.slot-pill .sp-w { color: var(--amber); font-size: 0.82rem; font-weight: bold; margin-top: 2px; }
|
||
.expand-footer { display: flex; align-items: center; gap: 14px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #222; }
|
||
.expand-total { color: var(--amber); font-weight: bold; font-size: 0.9rem; }
|
||
.expand-link { font-size: 0.82rem; }
|
||
</style>
|
||
|
||
<script>
|
||
const _gunSlotCache = {};
|
||
|
||
function toggleGunRow(tr) {
|
||
const gunId = tr.dataset.gunId;
|
||
const expandRow = document.getElementById('expand-' + gunId);
|
||
const isOpen = expandRow.style.display !== 'none';
|
||
|
||
if (isOpen) {
|
||
expandRow.style.display = 'none';
|
||
tr.classList.remove('expanded');
|
||
return;
|
||
}
|
||
|
||
tr.classList.add('expanded');
|
||
expandRow.style.display = '';
|
||
|
||
if (_gunSlotCache[gunId]) {
|
||
renderGunExpand(gunId, _gunSlotCache[gunId]);
|
||
return;
|
||
}
|
||
|
||
fetch('/loadout/gun/' + gunId + '/slots.json')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
_gunSlotCache[gunId] = data;
|
||
renderGunExpand(gunId, data);
|
||
})
|
||
.catch(() => {
|
||
const inner = expandRow.querySelector('.gun-expand-inner');
|
||
inner.innerHTML = '<span style="color:var(--muted)">Failed to load slots.</span>';
|
||
});
|
||
}
|
||
|
||
function renderGunExpand(gunId, slots) {
|
||
const inner = document.getElementById('expand-' + gunId).querySelector('.gun-expand-inner');
|
||
if (!slots.length) {
|
||
inner.innerHTML = '<span style="color:var(--muted);font-size:0.85rem">No slot data available for this gun.</span>';
|
||
return;
|
||
}
|
||
|
||
const KEY = new Set(['mod_muzzle', 'mod_magazine']);
|
||
let baseWeight = 0;
|
||
const baseCell = document.querySelector('[data-gun-id="' + gunId + '"] td:nth-child(6)');
|
||
if (baseCell) baseWeight = parseFloat(baseCell.textContent) || 0;
|
||
|
||
// Only sum required slots for the lightest build weight
|
||
let total = baseWeight;
|
||
let reqPills = '';
|
||
let optPills = '';
|
||
for (const s of slots) {
|
||
const isKey = KEY.has(s.slot_nameid);
|
||
const w = s.weight_kg != null ? s.weight_kg.toFixed(3) + ' kg' : '—';
|
||
if (s.required) {
|
||
if (s.weight_kg != null) total += s.weight_kg;
|
||
reqPills += `<div class="slot-pill${isKey ? ' key' : ''}">
|
||
<span class="sp-name">${s.slot_name}</span>
|
||
<span class="sp-mod">${s.mod_name || '—'}</span>
|
||
<span class="sp-w">${w}</span>
|
||
</div>`;
|
||
} else {
|
||
optPills += `<div class="slot-pill optional">
|
||
<span class="sp-name">${s.slot_name}</span>
|
||
<span class="sp-mod">${s.mod_name || '—'}</span>
|
||
<span class="sp-w">${w}</span>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
const optSection = optPills
|
||
? `<div style="font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin:8px 0 4px">Optional slots (not included in weight)</div>
|
||
<div class="slot-summary">${optPills}</div>`
|
||
: '';
|
||
|
||
inner.innerHTML = `
|
||
<div class="slot-summary">${reqPills || '<span style="color:var(--muted);font-size:0.82rem">No required slots</span>'}</div>
|
||
${optSection}
|
||
<div class="expand-footer">
|
||
<span class="expand-total">Lightest build (required slots): ${total.toFixed(3)} kg</span>
|
||
<a class="expand-link" href="/loadout/gun/${gunId}">Full breakdown →</a>
|
||
</div>`;
|
||
}
|
||
</script>
|
||
{% endif %}
|
||
|
||
{# =============================== ARMOR TAB =============================== #}
|
||
{% if tab == "armor" %}
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="armor">
|
||
<label>Min class</label>
|
||
<select name="min_class">
|
||
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||
{% for c in range(1,7) %}
|
||
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||
<option value="class_asc" {% if sort=='class_asc' %}selected{% endif %}>Class ↑</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
</form>
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th><th>Name</th><th>Class</th>
|
||
<th>Durability</th><th>Material</th><th>Zones</th><th>Weight</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in armor %}
|
||
<tr>
|
||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||
<td class="name-cell">
|
||
<strong>{{ item.short_name or item.name }}</strong>
|
||
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if item.armor_class %}
|
||
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||
<td class="muted">{{ item.material or '—' }}</td>
|
||
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||
<td class="w">
|
||
{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
|
||
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="7" class="empty">No armor found.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% endif %}
|
||
|
||
{# =============================== HELMETS TAB =============================== #}
|
||
{% if tab == "helmets" %}
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="helmets">
|
||
<label>Min class</label>
|
||
<select name="min_class">
|
||
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||
{% for c in range(1,7) %}
|
||
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||
<option value="class_asc" {% if sort=='class_asc' %}selected{% endif %}>Class ↑</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
</form>
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th><th>Name</th><th>Class</th>
|
||
<th>Durability</th><th>Head zones</th><th>Deafening</th><th>Weight</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in helmets %}
|
||
<tr>
|
||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||
<td class="name-cell">
|
||
<strong>{{ item.short_name or item.name }}</strong>
|
||
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if item.armor_class %}
|
||
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||
{% else %}<span class="muted">—</span>{% endif %}
|
||
</td>
|
||
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||
<td class="muted" style="font-size:0.8rem">{{ item.head_zones or '—' }}</td>
|
||
<td class="muted">{{ item.deafening or '—' }}</td>
|
||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="7" class="empty">No helmets found.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% endif %}
|
||
|
||
{# =============================== HEADWEAR TAB =============================== #}
|
||
{% if tab == "headwear" %}
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="headwear">
|
||
<label>Min class</label>
|
||
<select name="min_class">
|
||
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||
{% for c in range(1,7) %}
|
||
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||
<option value="class_asc" {% if sort=='class_asc' %}selected{% endif %}>Class ↑</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
</form>
|
||
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:12px;">Face masks, armored masks, and non-helmet head protection. Does not cover the top of the head.</p>
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th><th>Name</th><th>Class</th>
|
||
<th>Durability</th><th>Head zones</th><th>Weight</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in headwear %}
|
||
<tr>
|
||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||
<td class="name-cell">
|
||
<strong>{{ item.short_name or item.name }}</strong>
|
||
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if item.armor_class %}
|
||
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||
{% else %}<span class="muted">—</span>{% endif %}
|
||
</td>
|
||
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||
<td class="muted" style="font-size:0.8rem">{{ item.head_zones or '—' }}</td>
|
||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="6" class="empty">No headwear found.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% endif %}
|
||
|
||
{# =============================== BACKPACKS TAB =============================== #}
|
||
{% if tab == "backpacks" %}
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="backpacks">
|
||
<label>Min slots</label>
|
||
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
||
<option value="capacity_asc" {% if sort=='capacity_asc' %}selected{% endif %}>Capacity ↑</option>
|
||
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
</form>
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th><th>Name</th><th>Capacity (slots)</th><th>Weight</th><th>Carry Efficiency</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in backpacks %}
|
||
<tr>
|
||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||
<td class="name-cell">
|
||
<strong>{{ item.short_name or item.name }}</strong>
|
||
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||
</td>
|
||
<td class="muted">{{ item.capacity or '—' }}</td>
|
||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||
<td class="muted">
|
||
{% if item.slots_per_kg is not none %}
|
||
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="5" class="empty">No backpacks found.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% endif %}
|
||
|
||
{# =============================== SOFT RIGS TAB =============================== #}
|
||
{% if tab == "rigs" %}
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="rigs">
|
||
<label>Min slots</label>
|
||
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
||
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
</form>
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th><th>Name</th><th>Capacity (slots)</th><th>Weight</th><th>Carry Efficiency</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in rigs %}
|
||
<tr>
|
||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||
<td class="name-cell">
|
||
<strong>{{ item.short_name or item.name }}</strong>
|
||
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||
</td>
|
||
<td class="muted">{{ item.capacity or '—' }}</td>
|
||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||
<td class="muted">
|
||
{% if item.slots_per_kg is not none %}
|
||
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="5" class="empty">No soft rigs found.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% endif %}
|
||
|
||
{# =============================== ARMORED RIGS TAB =============================== #}
|
||
{% if tab == "armored_rigs" %}
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="armored_rigs">
|
||
<label>Min slots</label>
|
||
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
||
<label>Min class</label>
|
||
<select name="min_class">
|
||
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||
{% for c in range(1,7) %}
|
||
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
||
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
</form>
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th><th>Name</th><th>Class</th><th>Capacity (slots)</th><th>Zones</th><th>Weight</th><th>Carry Efficiency</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in armored_rigs %}
|
||
<tr>
|
||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||
<td class="name-cell">
|
||
<strong>{{ item.short_name or item.name }}</strong>
|
||
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||
</td>
|
||
<td>
|
||
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||
</td>
|
||
<td class="muted">{{ item.capacity or '—' }}</td>
|
||
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||
<td class="w">
|
||
{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
|
||
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
|
||
</td>
|
||
<td class="muted">
|
||
{% if item.slots_per_kg is not none %}
|
||
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="7" class="empty">No armored rigs found.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% endif %}
|
||
|
||
{# =============================== PLATES TAB =============================== #}
|
||
{% if tab == "plates" %}
|
||
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:12px;">
|
||
Armor plates that slot into plate carriers. Carrier shell weight does <em>not</em> include plates — add them separately when building your loadout.
|
||
</p>
|
||
<form method="get" class="filter-bar">
|
||
<input type="hidden" name="tab" value="plates">
|
||
<label>Min class</label>
|
||
<select name="min_class">
|
||
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||
{% for c in range(1,7) %}
|
||
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label>Sort</label>
|
||
<select name="sort">
|
||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||
<option value="class_asc" {% if sort=='class_asc' %}selected{% endif %}>Class ↑</option>
|
||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||
</select>
|
||
<button type="submit">Filter</button>
|
||
</form>
|
||
<table class="gear-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th><th>Name</th><th>Class</th>
|
||
<th>Durability</th><th>Material</th><th>Zones</th><th>Weight</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in plates %}
|
||
<tr>
|
||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||
<td class="name-cell">
|
||
<strong>{{ item.short_name or item.name }}</strong>
|
||
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if item.armor_class %}
|
||
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||
<td class="muted">{{ item.material or '—' }}</td>
|
||
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="7" class="empty">No plates found.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% endif %}
|
||
|
||
{# =============================== BUILD BUILDER TAB =============================== #}
|
||
{% if tab == "builder" %}
|
||
<script>
|
||
const WEIGHTS = {
|
||
{% for item in builder_guns + builder_armor + builder_helmets + builder_rigs + builder_backpacks %}
|
||
"{{ item.id }}": {{ item.weight_kg if item.weight_kg is not none else 0 }},
|
||
{% endfor %}
|
||
};
|
||
|
||
// carriers that have open plate slots (shell weight only)
|
||
const CARRIERS_WITH_OPEN_SLOTS = new Set({{ carrier_ids_with_open_slots | list | tojson }});
|
||
|
||
// plate weight cache: id -> weight_kg
|
||
const PLATE_WEIGHTS = {};
|
||
|
||
// currently selected plate weights per open slot, keyed by "carrierSlot|slotNameId"
|
||
const _plateSlotWeights = {};
|
||
|
||
function recalcWeight() {
|
||
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
||
let total = 0;
|
||
for (const slot of slots) {
|
||
const sel = document.getElementById('slot_' + slot);
|
||
const id = sel ? sel.value : '';
|
||
const w = id ? (WEIGHTS[id] || 0) : 0;
|
||
const disp = document.getElementById('sw_' + slot);
|
||
if (disp) {
|
||
if (id) {
|
||
const isCarrier = CARRIERS_WITH_OPEN_SLOTS.has(id);
|
||
disp.textContent = w.toFixed(3) + ' kg' + (isCarrier ? ' (shell only)' : '');
|
||
} else {
|
||
disp.textContent = '';
|
||
}
|
||
}
|
||
total += w;
|
||
}
|
||
// Add plate weights
|
||
for (const pw of Object.values(_plateSlotWeights)) {
|
||
total += pw;
|
||
}
|
||
document.getElementById('total-weight').textContent = total.toFixed(3);
|
||
}
|
||
|
||
const _carrierSlotCache = {};
|
||
|
||
function onCarrierChange(slot) {
|
||
const sel = document.getElementById('slot_' + slot);
|
||
const carrierId = sel ? sel.value : '';
|
||
const container = document.getElementById('plates_' + slot);
|
||
container.innerHTML = '';
|
||
// Clear plate slot weights for this carrier slot
|
||
for (const key of Object.keys(_plateSlotWeights)) {
|
||
if (key.startsWith(slot + '|')) delete _plateSlotWeights[key];
|
||
}
|
||
if (!carrierId || !CARRIERS_WITH_OPEN_SLOTS.has(carrierId)) {
|
||
recalcWeight();
|
||
return;
|
||
}
|
||
if (_carrierSlotCache[carrierId]) {
|
||
renderPlateSlots(slot, carrierId, _carrierSlotCache[carrierId]);
|
||
return;
|
||
}
|
||
fetch('/loadout/carrier/' + carrierId + '/slots.json')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
_carrierSlotCache[carrierId] = data;
|
||
renderPlateSlots(slot, carrierId, data);
|
||
});
|
||
}
|
||
|
||
function renderPlateSlots(carrierSlot, carrierId, slots) {
|
||
const container = document.getElementById('plates_' + carrierSlot);
|
||
container.innerHTML = '';
|
||
for (const slot of slots) {
|
||
const key = carrierSlot + '|' + slot.slot_nameid;
|
||
const label = document.createElement('label');
|
||
label.style.cssText = 'display:block;margin-top:8px;font-size:0.82rem;color:var(--muted)';
|
||
label.textContent = slot.slot_nameid.replace(/_/g, ' ') + (slot.zones ? ' (' + slot.zones + ')' : '');
|
||
const sel = document.createElement('select');
|
||
sel.style.cssText = 'width:100%;margin-top:2px';
|
||
const none = document.createElement('option');
|
||
none.value = '';
|
||
none.textContent = '— No plate —';
|
||
sel.appendChild(none);
|
||
for (const p of slot.plates) {
|
||
PLATE_WEIGHTS[p.id] = p.weight_kg || 0;
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = (p.short_name || p.name) +
|
||
' (Cls ' + (p.armor_class || '?') + ', ' +
|
||
(p.weight_kg != null ? p.weight_kg.toFixed(3) : '?') + ' kg)';
|
||
sel.appendChild(opt);
|
||
}
|
||
sel.addEventListener('change', () => {
|
||
const pid = sel.value;
|
||
_plateSlotWeights[key] = pid ? (PLATE_WEIGHTS[pid] || 0) : 0;
|
||
recalcWeight();
|
||
});
|
||
container.appendChild(label);
|
||
container.appendChild(sel);
|
||
_plateSlotWeights[key] = 0;
|
||
}
|
||
recalcWeight();
|
||
}
|
||
|
||
function saveBuild() {
|
||
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
||
const payload = { name: document.getElementById('build-name').value.trim() || 'My Build' };
|
||
for (const s of slots) {
|
||
const sel = document.getElementById('slot_' + s);
|
||
payload[s + '_id'] = (sel && sel.value) ? sel.value : null;
|
||
}
|
||
fetch('/loadout/save-build', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
document.getElementById('save-status').textContent = 'Saved as "' + d.name + '" (build #' + d.build_id + ')';
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('save-status').textContent = 'Error saving build.';
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<div class="builder-total">
|
||
<div class="label">Total loadout weight</div>
|
||
<div class="big"><span id="total-weight">0.000</span> kg</div>
|
||
</div>
|
||
|
||
<div class="builder-grid">
|
||
{% set slot_defs = [
|
||
('gun', 'Primary Weapon', builder_guns, false),
|
||
('armor', 'Body Armor', builder_armor, true),
|
||
('helmet', 'Helmet', builder_helmets, false),
|
||
('rig', 'Chest Rig', builder_rigs, true),
|
||
('backpack', 'Backpack', builder_backpacks, false),
|
||
] %}
|
||
{% for slot_id, slot_label, items, has_plates in slot_defs %}
|
||
<div class="slot-card">
|
||
<h3>{{ slot_label }}</h3>
|
||
<select id="slot_{{ slot_id }}" onchange="{% if has_plates %}onCarrierChange('{{ slot_id }}'){% else %}recalcWeight(){% endif %}">
|
||
<option value="">— None —</option>
|
||
{% for item in items %}
|
||
<option value="{{ item.id }}">
|
||
{{ item.name }}{% if item.weight_kg is not none %} ({{ "%.3f"|format(item.weight_kg) }} kg{% if item.id in carrier_ids_with_open_slots %} shell{% endif %}){% endif %}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
<div class="slot-weight" id="sw_{{ slot_id }}"></div>
|
||
{% if has_plates %}<div id="plates_{{ slot_id }}"></div>{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<div class="save-row">
|
||
<input type="text" id="build-name" placeholder="Build name…">
|
||
<button onclick="saveBuild()">Save Build</button>
|
||
<span class="save-status" id="save-status"></span>
|
||
</div>
|
||
{% endif %}
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|