feat: add visual avatar picker for equipping new relics and light cones

- replace the ~100-option avatar <select>s in Add Relic / Add Light Cone with a searchable picker modal (keep-in-inventory card + avatar cards with element/path/level)
- warn when the chosen character already wears a relic in that slot or a light cone
- click+Confirm or double-click applies; Esc closes only the picker; search autofocused
- localize all new strings (th/en, other locales fall back to en) and refresh chips/picker on language change
- add AGENTS.md with localization and cache-busting rules
- bump app.js v3.20, styles.css v2.29, i18n.js v2.17 and align all i18n imports
This commit is contained in:
CAtlas0784 committed 2026-11-02 09:21:53 +07:00
1 parent 303332469a
commit 083848ee6e
9 files changed
+347 -41

No files matched your search

+258 -22
View File
@@ -4,7 +4,7 @@ import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetIn
import { initializeItemCatalog } from './item-catalog.js?v=2';
import { initializeUnstuck } from './unstuck.js?v=1';
import { PRYDWEN_BUILD_GUIDES } from './build-guides-data.js';
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.16';
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.17';
import {
getAvatarIconUrl,
getLightconeArtUrl,
@@ -1063,6 +1063,14 @@ function applyTranslations() {
if (state.avatarsList.length > 0) {
renderAvatarsGrid();
}
// Keep the equip-target chips and the avatar picker in the active language.
renderEquipTargetDisplay('relic');
renderEquipTargetDisplay('lc');
if (el.modalAvatarAssign?.classList.contains('active')) {
updateAvatarAssignCurrent();
renderAvatarAssignGrid();
}
loadEquipment();
if (analystState.currentReport) {
renderDamageAnalyst();
@@ -2273,6 +2281,233 @@ function renderPickerGrid() {
});
}
/* ==========================================================================
Avatar Assignment Picker (Add Relic / Add Light Cone)
========================================================================== */
const avatarAssignState = { kind: 'relic', pending: 0 };
function avatarAssignSelectEl(kind) {
return kind === 'lc' ? el.addLcAvatar : el.addRelicAvatar;
}
function avatarAssignButtonEl(kind) {
return kind === 'lc' ? el.addLcAvatarBtn : el.addRelicAvatarBtn;
}
function getRelicSlotNumber() {
return parseInt(el.addRelicSlot?.value || '1', 10) || 1;
}
// What (if anything) the given avatar currently wears in this slot.
function findAvatarEquippedItem(kind, avatarId, relicSlot) {
if (kind === 'lc') {
const lc = (state.fullFreesrData?.lightcones || []).find(l => l.equip_avatar === avatarId);
return lc ? { name: getLightconeInfo(lc.item_id).name } : null;
}
const relic = (state.fullFreesrData?.relics || [])
.find(r => r.equip_avatar === avatarId && (r.relic_id % 10) === relicSlot);
if (!relic) return null;
const setId = Math.floor((relic.relic_id % 10000) / 10);
return { name: getRelicSetInfo(setId).name };
}
// Keep the hidden <select> as the source of truth for the submit handlers,
// but only ever hold the single selected option (instead of ~100 <option>s).
function setAvatarAssignValue(kind, avatarId) {
const select = avatarAssignSelectEl(kind);
if (!select) return;
const value = parseInt(avatarId, 10) || 0;
const option = document.createElement('option');
option.value = String(value);
option.textContent = value ? getAvatarInfo(value).name : t('unassigned');
select.innerHTML = '';
select.appendChild(option);
select.value = String(value);
}
function getAssignItemLabel(kind) {
if (kind === 'lc') {
const lcId = parseInt(el.addLcSelectedId?.value || '23001', 10);
return getLightconeInfo(lcId).name;
}
const setId = parseInt(el.addRelicSet?.value || '0', 10);
return `${getRelicSetInfo(setId).name} • ${getSlotInfo(getRelicSlotNumber()).short}`;
}
function renderEquipTargetDisplay(kind) {
const btn = avatarAssignButtonEl(kind);
const select = avatarAssignSelectEl(kind);
if (!btn) return;
const avatarId = parseInt(select?.value || '0', 10) || 0;
const meta = avatarId ? getAvatarInfo(avatarId) : null;
if (!meta) {
btn.classList.add('is-empty');
btn.title = '';
btn.innerHTML = `
<span class="equip-target-avatar">🎒</span>
<span class="equip-target-body">
<span class="equip-target-name">${t('assignKeepInventory')}</span>
<span class="equip-target-sub">${t('assignChooseHint')}</span>
</span>
<span class="equip-target-cta">${t('assignChoose')}</span>
`;
return;
}
btn.classList.remove('is-empty');
const avatar = (state.avatarsList || []).find(a => a.avatar_id === avatarId);
const levelText = avatar ? `Lv.${avatar.level} • E${avatar.rank}` : '';
const existing = findAvatarEquippedItem(kind, avatarId, getRelicSlotNumber());
const warnText = existing
? t(kind === 'lc' ? 'assignReplaceLc' : 'assignReplaceRelic', { name: existing.name })
: '';
btn.title = warnText;
btn.innerHTML = `
<span class="equip-target-avatar">
<img src="${getAvatarIconSrc(avatarId)}" onerror="handleAvatarImgError(this, ${avatarId})" alt="${meta.name}">
</span>
<span class="equip-target-body">
<span class="equip-target-name">${meta.name}</span>
<span class="equip-target-sub">
${getElementIconHtml(meta.element, 12)}
${getPathIconHtml(meta.path, 12)}
<span>${levelText}</span>
</span>
</span>
<span class="equip-target-cta${existing ? ' has-warning' : ''}">${existing ? '⚠ ' : ''}${t('assignChange')}</span>
`;
}
function openAvatarAssignPicker(kind) {
if (!el.modalAvatarAssign) return;
avatarAssignState.kind = kind;
avatarAssignState.pending = parseInt(avatarAssignSelectEl(kind)?.value || '0', 10) || 0;
if (el.modalAssignTitle) {
el.modalAssignTitle.textContent = t(kind === 'lc' ? 'assignPickerTitleLc' : 'assignPickerTitleRelic');
}
if (el.modalAssignSubtitle) {
el.modalAssignSubtitle.innerHTML = t('assignPickerSubtitle', {
item: `<strong>${getAssignItemLabel(kind)}</strong>`
});
}
if (el.assignPickerSearch) el.assignPickerSearch.value = '';
renderAvatarAssignGrid();
updateAvatarAssignCurrent();
el.modalAvatarAssign.classList.add('active');
setTimeout(() => el.assignPickerSearch?.focus(), 40);
}
function renderAvatarAssignGrid() {
if (!el.assignPickerGrid) return;
const kind = avatarAssignState.kind;
const search = (el.assignPickerSearch?.value || '').trim().toLowerCase();
const relicSlot = getRelicSlotNumber();
el.assignPickerGrid.innerHTML = '';
// "Keep in inventory" option
const inventoryCard = document.createElement('div');
inventoryCard.className = 'assign-inventory-card' + (avatarAssignState.pending === 0 ? ' is-selected' : '');
inventoryCard.setAttribute('role', 'option');
inventoryCard.setAttribute('aria-selected', avatarAssignState.pending === 0 ? 'true' : 'false');
inventoryCard.innerHTML = `
<span class="assign-inventory-icon">🎒</span>
<span class="assign-inventory-text">
<strong>${t('assignKeepInventory')}</strong>
<small>${t('assignKeepInventoryHint')}</small>
</span>
<span class="assign-check">✓</span>
`;
inventoryCard.addEventListener('click', () => selectAvatarAssign(0, inventoryCard));
el.assignPickerGrid.appendChild(inventoryCard);
const sortedAvatars = [...(state.avatarsList || [])].sort((a, b) => b.avatar_id - a.avatar_id);
let visible = 0;
sortedAvatars.forEach(item => {
const meta = getAvatarInfo(item.avatar_id);
if (search && !meta.name.toLowerCase().includes(search) && !String(item.avatar_id).includes(search)) {
return;
}
visible += 1;
const equipped = findAvatarEquippedItem(kind, item.avatar_id, relicSlot);
const isSelected = avatarAssignState.pending === item.avatar_id;
const card = document.createElement('div');
card.className = `picker-card-item assign-avatar-card card-rarity-${meta.rarity}${isSelected ? ' is-selected' : ''}`;
card.setAttribute('role', 'option');
card.setAttribute('aria-selected', isSelected ? 'true' : 'false');
card.innerHTML = `
<div class="picker-avatar-img-wrap">
<img class="picker-avatar-img"
src="${getAvatarIconSrc(item.avatar_id)}"
onerror="handleAvatarImgError(this, ${item.avatar_id})"
alt="${meta.name}">
</div>
<div class="picker-avatar-info">
<div class="picker-avatar-name" title="${meta.name}">${meta.name}</div>
<div class="picker-avatar-subinfo">
${getElementIconHtml(meta.element, 18)}
${getPathIconHtml(meta.path, 18)}
<span style="font-weight: 600; margin-left: 0.2rem;">Lv. ${item.level} • E${item.rank}</span>
</div>
${equipped ? `<div class="assign-equipped-badge">⚠ ${t(kind === 'lc' ? 'assignReplaceLc' : 'assignReplaceRelic', { name: equipped.name })}</div>` : ''}
</div>
`;
card.addEventListener('click', () => selectAvatarAssign(item.avatar_id, card));
card.addEventListener('dblclick', () => {
selectAvatarAssign(item.avatar_id, card);
confirmAvatarAssign();
});
el.assignPickerGrid.appendChild(card);
});
if (visible === 0) {
const empty = document.createElement('div');
empty.className = 'assign-picker-empty';
empty.textContent = t('assignNoResults');
el.assignPickerGrid.appendChild(empty);
}
}
function selectAvatarAssign(avatarId, cardEl) {
avatarAssignState.pending = parseInt(avatarId, 10) || 0;
el.assignPickerGrid?.querySelectorAll('.is-selected').forEach(node => node.classList.remove('is-selected'));
cardEl?.classList.add('is-selected');
el.assignPickerGrid?.querySelectorAll('[role="option"]').forEach(node => {
node.setAttribute('aria-selected', node === cardEl ? 'true' : 'false');
});
updateAvatarAssignCurrent();
}
function updateAvatarAssignCurrent() {
if (!el.assignPickerCurrentValue) return;
el.assignPickerCurrentValue.textContent = avatarAssignState.pending === 0
? t('assignKeepInventory')
: getAvatarInfo(avatarAssignState.pending).name;
}
function closeAvatarAssignPicker() {
el.modalAvatarAssign?.classList.remove('active');
}
function confirmAvatarAssign() {
const kind = avatarAssignState.kind;
setAvatarAssignValue(kind, avatarAssignState.pending);
renderEquipTargetDisplay(kind);
closeAvatarAssignPicker();
}
function resetAvatarAssignForForm(kind) {
setAvatarAssignValue(kind, 0);
renderEquipTargetDisplay(kind);
}
// Assign avatar to lineup slot
async function assignAvatarToLineup(slot, avatarId) {
try {
@@ -6776,18 +7011,8 @@ function openAddLcModal() {
const filterRarity = document.getElementById('add-lc-filter-rarity');
if (filterRarity) filterRarity.value = 'all';
// Avatars dropdown
if (el.addLcAvatar) {
el.addLcAvatar.innerHTML = `<option value="0">${t('unassigned') || '-- ยังไม่สวมใส่ (เก็บในคลัง) --'}</option>`;
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sortedAvatars.forEach(av => {
const info = getAvatarInfo(av.avatar_id);
const opt = document.createElement('option');
opt.value = av.avatar_id;
opt.textContent = `${info.name} (ID: ${av.avatar_id})`;
el.addLcAvatar.appendChild(opt);
});
}
// Equip target starts empty ("keep in inventory")
resetAvatarAssignForForm('lc');
if (el.addLcLevel) el.addLcLevel.value = 80;
if (el.addLcPromotion) el.addLcPromotion.value = 6;
@@ -6970,15 +7195,8 @@ async function openAddRelicModal() {
el.addRelicSet.appendChild(opt);
});
el.addRelicAvatar.innerHTML = `<option value="0">${t('unassigned')}</option>`;
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sortedAvatars.forEach(av => {
const info = getAvatarInfo(av.avatar_id);
const opt = document.createElement('option');
opt.value = av.avatar_id;
opt.textContent = `${info.name} (ID: ${av.avatar_id})`;
el.addRelicAvatar.appendChild(opt);
});
// Equip target starts empty ("keep in inventory")
resetAvatarAssignForForm('relic');
const firstAvailable = Array.from(el.addRelicSet.options).find(option => !option.disabled);
if (firstAvailable) el.addRelicSet.value = firstAvailable.value;
@@ -8712,6 +8930,24 @@ function setupEvents() {
el.btnClosePickerModal.addEventListener('click', () => el.modalLineupPicker.classList.remove('active'));
el.pickerSearch.addEventListener('input', renderPickerGrid);
// Avatar assignment picker (Add Relic / Add Light Cone)
el.addRelicAvatarBtn?.addEventListener('click', () => openAvatarAssignPicker('relic'));
el.addLcAvatarBtn?.addEventListener('click', () => openAvatarAssignPicker('lc'));
el.assignPickerSearch?.addEventListener('input', renderAvatarAssignGrid);
el.btnCloseAssignPicker?.addEventListener('click', closeAvatarAssignPicker);
el.btnCancelAssignPicker?.addEventListener('click', closeAvatarAssignPicker);
el.btnConfirmAssignPicker?.addEventListener('click', confirmAvatarAssign);
el.modalAvatarAssign?.addEventListener('click', (e) => {
if (e.target === el.modalAvatarAssign) closeAvatarAssignPicker();
});
// Esc closes only the picker, keeping the parent Add Relic / Add LC form open.
el.modalAvatarAssign?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
closeAvatarAssignPicker();
}
});
// Creative Mode Toggle
if (el.toggleCreativeMode) {
el.toggleCreativeMode.checked = state.creativeMode;
+1 -1
View File
@@ -1,4 +1,4 @@
import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js?v=2.16";
import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js?v=2.17";
export const AVATAR_METADATA = {
"1001": {
+1 -1
View File
@@ -1,4 +1,4 @@
import { getCurrentLanguage, translatePath } from "./i18n.js?v=2.16";
import { getCurrentLanguage, translatePath } from "./i18n.js?v=2.17";
export const LIGHTCONE_METADATA = {
"20000": {
+28
View File
@@ -7049,6 +7049,20 @@ for (const [language, description] of Object.entries(ITEM_CATALOG_INTERACTION_DE
// language falls back to en through t(), so no Thai leaks into other locales.
const MODAL_AND_TOOL_TRANSLATIONS = {
th: {
assignPickerTitleRelic: 'สวมใส่รีลิกส์ให้ตัวละคร',
assignPickerTitleLc: 'สวมใส่กรวยแสงให้ตัวละคร',
assignPickerSubtitle: 'เลือกตัวละครที่จะสวมใส่ {item} — หรือเก็บไว้ในคลัง',
assignPickerSearch: '🔍 ค้นหาชื่อหรือ ID ตัวละคร...',
assignKeepInventory: 'เก็บในคลัง (ไม่สวมใส่)',
assignKeepInventoryHint: 'ไอเท็มจะถูกเพิ่มโดยไม่ผูกกับตัวละคร',
assignChoose: 'เลือก',
assignChange: 'เปลี่ยน',
assignChooseHint: 'ยังไม่ได้ผูกกับตัวละคร — คลิกเพื่อเลือก',
assignCurrent: 'เลือกอยู่:',
assignConfirm: 'ตกลง',
assignReplaceLc: 'มีกรวยแสงอยู่แล้ว: {name} (จะถูกแทนที่)',
assignReplaceRelic: 'สล็อตนี้มีอยู่แล้ว: {name} (จะถูกแทนที่)',
assignNoResults: 'ไม่พบตัวละครที่ตรงกับเงื่อนไข',
relicSlotNotEquipped: 'ยังไม่ได้สวมใส่',
relicSlotCreateHint: '+ คลิกเพื่อเลือกหรือสร้าง (แนะนำ: {stat})',
relicSlotUnequip: 'ถอดชิ้นนี้',
@@ -7148,6 +7162,20 @@ const MODAL_AND_TOOL_TRANSLATIONS = {
analystDamageAvPlaceholder: "เฉลี่ยต่อรอบ: -", analystCyclesPlaceholder: "0 รอบ", analystTurnsPlaceholder: "0 เทิร์น", analystAutoTurnsPlaceholder: "เทิร์นอัตโนมัติ: 0", analystDamageAvPlaceholder: "Avg per cycle: -", analystCyclesPlaceholder: "0 Cycles", analystTurnsPlaceholder: "0 Turns", analystAutoTurnsPlaceholder: "Auto Turns: 0", analystDamageAvPlaceholder: "ラウンド平均: -", analystCyclesPlaceholder: "0 ラウンド", analystTurnsPlaceholder: "0 ターン", analystAutoTurnsPlaceholder: "オートターン: 0", analystDamageAvPlaceholder: "라운드당 평균: -", analystCyclesPlaceholder: "0 라운드", analystTurnsPlaceholder: "0 턴", analystAutoTurnsPlaceholder: "자동 턴: 0", analystDamageAvPlaceholder: "Trung bình mỗi vòng: -", analystCyclesPlaceholder: "0 vòng", analystTurnsPlaceholder: "0 lượt", analystAutoTurnsPlaceholder: "Lượt tự động: 0", analystDamageAvPlaceholder: "Rata-rata per siklus: -", analystCyclesPlaceholder: "0 Siklus", analystTurnsPlaceholder: "0 Giliran", analystAutoTurnsPlaceholder: "Giliran otomatis: 0", analystDamageAvPlaceholder: "Promedio por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", analystDamageAvPlaceholder: "Moyenne par cycle : -", analystCyclesPlaceholder: "0 cycle", analystTurnsPlaceholder: "0 tour", analystAutoTurnsPlaceholder: "Tours auto : 0", analystDamageAvPlaceholder: "Durchschnitt pro Zyklus: -", analystCyclesPlaceholder: "0 Zyklen", analystTurnsPlaceholder: "0 Züge", analystAutoTurnsPlaceholder: "Auto-Züge: 0", analystDamageAvPlaceholder: "В среднем за цикл: -", analystCyclesPlaceholder: "0 циклов", analystTurnsPlaceholder: "0 ходов", analystAutoTurnsPlaceholder: "Авто-ходы: 0", analystDamageAvPlaceholder: "Média por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", miniViewFullAnalystTooltip: "เปิดหน้า Damage Analyst ตัวเต็ม", miniViewFullAnalystTooltip: "Open full Damage Analyst", miniViewFullAnalystTooltip: "完全なダメージ分析を開く", miniViewFullAnalystTooltip: "전체 데미지 분석 열기", miniViewFullAnalystTooltip: "Mở Trình phân tích sát thương đầy đủ", miniViewFullAnalystTooltip: "Buka Analis Kerusakan Lengkap", miniViewFullAnalystTooltip: "Abrir analizador de daño completo", miniViewFullAnalystTooltip: "Ouvrir l'analyseur de dégâts complet", miniViewFullAnalystTooltip: "Vollständigen Schadensanalysten öffnen", miniViewFullAnalystTooltip: "Открыть полный анализ урона", miniViewFullAnalystTooltip: "Abrir analisador de dano completo", copyCardGenerated: 'สร้างโดย GaCy-Srtool'
},
en: {
assignPickerTitleRelic: 'Equip Relic to a Character',
assignPickerTitleLc: 'Equip Light Cone to a Character',
assignPickerSubtitle: 'Choose who wears {item} — or keep it in your inventory',
assignPickerSearch: '🔍 Search character name or ID...',
assignKeepInventory: 'Keep in inventory (unequipped)',
assignKeepInventoryHint: 'The item is added without being bound to a character',
assignChoose: 'Choose',
assignChange: 'Change',
assignChooseHint: 'Not bound to a character — click to choose',
assignCurrent: 'Selected:',
assignConfirm: 'Confirm',
assignReplaceLc: 'Already has a Light Cone: {name} (will be replaced)',
assignReplaceRelic: 'This slot is occupied: {name} (will be replaced)',
assignNoResults: 'No characters match your search',
relicSlotNotEquipped: 'Not equipped',
relicSlotCreateHint: '+ Click to choose or create (recommended: {stat})',
relicSlotUnequip: 'Unequip this piece',
+1 -1
View File
@@ -1,4 +1,4 @@
import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.16';
import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.17';
const CATALOG_URL = new URL('../assets/item-catalog.json?v=2', import.meta.url);
const PAGE_SIZE = 48;
+1 -1
View File
@@ -1,4 +1,4 @@
import { getCurrentLanguage } from './i18n.js?v=2.16';
import { getCurrentLanguage } from './i18n.js?v=2.17';
const strings = {
en: {