feat: add Equipment tab with slot-type validation
Add a new Equipment tab to manage player gear and stat bonuses. Features: - 19 equipment slots across 8 categories (helmet, necklace, body armor, gloves, pants, shoes, rings, accessories) - Type-to-slot validation: each type has max equipped limits (1 helmet, 10 rings, 3 accessories, etc.) - Auto-slot assignment: equipping a ring fills the first available ring slot - Stat bonuses from equipped items display on RPG attributes (e.g. STR 10 +2) - Create/edit modal with stat checkboxes per RPG attribute - Inventory list for unequipped items Architecture: - Shared constants in src/systems/equipment/constants.js - Category-based types (Ring, Accessory) with auto-slot assignment - v7 migration converts legacy slot-specific types to generic categories - Full i18n support for all UI strings Files: - New: src/systems/equipment/constants.js - New: src/systems/interaction/equipmentActions.js - New: src/systems/rendering/equipment.js - Modified: state.js, persistence.js, template.html, index.js - Modified: userStats.js, desktop.js, mobile.js, layout.js, modals.js - Modified: apiClient.js, sillytavern.js, style.css, en.json
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Equipment Rendering Module
|
||||
* Handles UI rendering for the equipment grid and item creation
|
||||
*/
|
||||
|
||||
import { extensionSettings, $equipmentContainer } from '../../core/state.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js';
|
||||
|
||||
/**
|
||||
* Renders a single equipment slot
|
||||
* @param {Object} slotDef - Slot definition from SLOTS_LIST
|
||||
* @param {Object|null} item - The equipped item or null
|
||||
* @returns {string} HTML for the slot
|
||||
*/
|
||||
function renderSlot(slotDef, item) {
|
||||
const slotId = slotDef.id;
|
||||
const slotName = i18n.getTranslation(`equipment.slots.${slotId}`) || slotId.replace(/(\d+)/, ' $1');
|
||||
const equippedClass = item ? 'equipped' : '';
|
||||
|
||||
if (item) {
|
||||
const statsText = Object.entries(item.stats || {})
|
||||
.filter(([_, val]) => val > 0)
|
||||
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="rpg-equipment-slot ${equippedClass}" data-slot="${slotId}" data-item-id="${item.id}">
|
||||
<div class="rpg-equipment-slot-header">
|
||||
<i class="fa-solid ${slotDef.icon}"></i>
|
||||
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
|
||||
<button class="rpg-equipment-unequip-btn" data-action="unequip" title="${i18n.getTranslation('equipment.unequip') || 'Unequip'}">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="rpg-equipment-item-name">${escapeHtml(item.name)}</div>
|
||||
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
|
||||
${item.description ? `<div class="rpg-equipment-description">${escapeHtml(item.description)}</div>` : ''}
|
||||
<div class="rpg-equipment-item-actions">
|
||||
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="rpg-equipment-slot" data-slot="${slotId}">
|
||||
<div class="rpg-equipment-slot-header">
|
||||
<i class="fa-solid ${slotDef.icon}"></i>
|
||||
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
|
||||
</div>
|
||||
<div class="rpg-equipment-empty">${i18n.getTranslation('equipment.emptySlot') || 'Empty'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the full equipment section HTML
|
||||
* @returns {string} Complete HTML for the equipment section
|
||||
*/
|
||||
function generateEquipmentHTML() {
|
||||
const equipment = extensionSettings.userStats.equipment;
|
||||
const slots = equipment.slots || {};
|
||||
const items = equipment.items || [];
|
||||
|
||||
let html = '<div class="rpg-equipment-container">';
|
||||
|
||||
// Header with add button
|
||||
html += `
|
||||
<div class="rpg-equipment-header">
|
||||
<h3>
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
<span data-i18n-key="equipment.title">${i18n.getTranslation('equipment.title') || 'Equipment'}</span>
|
||||
</h3>
|
||||
<button class="rpg-equipment-add-btn" data-action="show-create-modal" title="${i18n.getTranslation('equipment.createItem') || 'Create new equipment'}">
|
||||
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('equipment.createItem') || 'Create Equipment'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Equipment grid
|
||||
html += '<div class="rpg-equipment-grid">';
|
||||
|
||||
// Render each slot
|
||||
for (const slotDef of SLOTS_LIST) {
|
||||
const item = items.find(i => i.slot === slotDef.id);
|
||||
html += renderSlot(slotDef, item);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Inventory list (items not currently equipped)
|
||||
const unequipped = items.filter(item => !item.slot);
|
||||
if (unequipped.length > 0) {
|
||||
html += '<div class="rpg-equipment-inventory">';
|
||||
html += `<h4>${i18n.getTranslation('equipment.inventoryTitle') || 'Inventory'}</h4>`;
|
||||
html += '<div class="rpg-equipment-inventory-list">';
|
||||
|
||||
for (const item of unequipped) {
|
||||
const category = EQUIPMENT_CATEGORIES[item.type];
|
||||
const statsText = Object.entries(item.stats || {})
|
||||
.filter(([_, val]) => val > 0)
|
||||
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
|
||||
.join('');
|
||||
|
||||
html += `
|
||||
<div class="rpg-equipment-inventory-item" data-item-id="${item.id}">
|
||||
<div class="rpg-equipment-inventory-item-header">
|
||||
<i class="fa-solid ${category ? category.icon : 'fa-circle'}"></i>
|
||||
<span class="rpg-equipment-inventory-item-name">${escapeHtml(item.name)}</span>
|
||||
<span class="rpg-equipment-inventory-item-type">${category ? (i18n.getTranslation(`equipment.types.${item.type}`) || item.type) : escapeHtml(item.type)}</span>
|
||||
</div>
|
||||
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
|
||||
<div class="rpg-equipment-item-actions">
|
||||
<button class="rpg-equipment-equip-btn" data-action="equip" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.equip') || 'Equip'}">
|
||||
<i class="fa-solid fa-hand"></i>
|
||||
</button>
|
||||
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main equipment rendering function
|
||||
* Gets data from state/settings and updates DOM directly.
|
||||
*/
|
||||
export function renderEquipment() {
|
||||
if (!$equipmentContainer || !extensionSettings.showEquipment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = generateEquipmentHTML();
|
||||
$equipmentContainer.html(html);
|
||||
|
||||
// Re-apply translations
|
||||
i18n.applyTranslations($equipmentContainer[0]);
|
||||
}
|
||||
Reference in New Issue
Block a user