Add: armor plates support with new database tables and loadout functionality

fix: only required mods are applied to guns for weight management.
This commit is contained in:
serversdwn
2026-02-24 16:08:58 +00:00
parent 84768ae587
commit 9d572f5d15
7 changed files with 471 additions and 126 deletions

View File

@@ -231,7 +231,7 @@
</p>
<div class="tab-bar">
{% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('builder','Build Builder')] %}
{% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('plates','Plates'),('builder','Build Builder')] %}
<a href="/loadout?tab={{ t_id }}" class="{% if tab == t_id %}active{% endif %}">{{ t_label }}</a>
{% endfor %}
</div>
@@ -328,6 +328,7 @@
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; }
@@ -382,23 +383,39 @@
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 pills = '';
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.weight_kg != null) total += s.weight_kg;
pills += `<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>`;
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">${pills}</div>
<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: ${total.toFixed(3)} kg</span>
<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>`;
}
@@ -450,7 +467,10 @@
<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>
<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>
@@ -653,7 +673,10 @@
</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 %}</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="6" class="empty">No rigs found.</td></tr>
@@ -662,6 +685,63 @@
</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>
@@ -671,6 +751,15 @@
{% 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;
@@ -679,12 +768,85 @@
const id = sel ? sel.value : '';
const w = id ? (WEIGHTS[id] || 0) : 0;
const disp = document.getElementById('sw_' + slot);
if (disp) disp.textContent = id ? w.toFixed(3) + ' kg' : '';
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' };
@@ -714,24 +876,25 @@
<div class="builder-grid">
{% set slot_defs = [
('gun', 'Primary Weapon', builder_guns),
('armor', 'Body Armor', builder_armor),
('helmet', 'Helmet', builder_helmets),
('rig', 'Chest Rig', builder_rigs),
('backpack', 'Backpack', builder_backpacks),
('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 in slot_defs %}
{% 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="recalcWeight()">
<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){% endif %}
{{ 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>