Merge remote-tracking branch 'origin/Main' into test-gay
This commit is contained in:
commit
a3b722e132
13 files changed
+1464
-57
No files matched your search
@@ -1,8 +1,10 @@
|
||||
import { COMMANDS, usage, inspectCommand, completeCommand } from './command-assistant.mjs';
|
||||
import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
|
||||
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
|
||||
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.12';
|
||||
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.13';
|
||||
import {
|
||||
getAvatarIconUrl,
|
||||
getLightconeArtUrl,
|
||||
@@ -963,6 +965,12 @@ function applyTranslations() {
|
||||
if (val) elem.alt = val;
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-aria-label]').forEach(elem => {
|
||||
const key = elem.getAttribute('data-i18n-aria-label');
|
||||
const val = t(key);
|
||||
if (val) elem.setAttribute('aria-label', val);
|
||||
});
|
||||
|
||||
// Update dynamic filter dropdown options
|
||||
updateFilterDropdownTranslations();
|
||||
|
||||
@@ -1535,6 +1543,17 @@ function hasAvatarBattleStatsData(avatars) {
|
||||
});
|
||||
}
|
||||
|
||||
function hasAvatarDamageBreakdownData(avatar) {
|
||||
const damageTypes = avatar?.attack_type_damage;
|
||||
return Number(avatar?.total_break_damage) > 0
|
||||
|| (damageTypes && Object.keys(damageTypes).length > 0);
|
||||
}
|
||||
|
||||
function hasAvatarMaxHitData(avatar) {
|
||||
const maxDamage = avatar?.attack_type_max_damage;
|
||||
return Boolean(maxDamage && Object.values(maxDamage).some(value => Number(value) > 0));
|
||||
}
|
||||
|
||||
function getAvatarDamagePercent(avatar, teamDamage, available) {
|
||||
if (!available) return null;
|
||||
const reportedPercent = Number(avatar?.damage_percent);
|
||||
@@ -6988,7 +7007,10 @@ function renderMonsterChallengeMode(mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedGroup = groups.find(group => String(group.id) === String(state.monsterSettings.groupId)) || groups[0];
|
||||
const preferredGroupId = mode === 'aa'
|
||||
? (state.monsterSettings.aaRoundSelection?.peak_group_id ?? groups[0].id)
|
||||
: state.monsterSettings.groupId;
|
||||
const selectedGroup = groups.find(group => String(group.id) === String(preferredGroupId)) || groups[0];
|
||||
state.monsterSettings.groupId = String(selectedGroup.id);
|
||||
const selectedStage = selectedGroup.stages.find(stage => String(stage.id) === String(state.monsterSettings.stageId)) || selectedGroup.stages[0];
|
||||
state.monsterSettings.stageId = String(selectedStage.id);
|
||||
@@ -9230,30 +9252,30 @@ function renderEmptyDamageAnalyst() {
|
||||
|
||||
function orderBattleReportAvatars(avatars) {
|
||||
if (!Array.isArray(avatars) || avatars.length < 2) return avatars || [];
|
||||
if (analystState.currentReport?.is_currency_wars || avatars.length > 4) return avatars;
|
||||
if ((typeof analystState !== 'undefined' && analystState.currentReport?.is_currency_wars) || avatars.length > 4) return avatars;
|
||||
|
||||
const lineup = state.statusData?.lineups;
|
||||
if (!lineup) return avatars;
|
||||
|
||||
const lineupSlots = Array.isArray(lineup)
|
||||
? lineup.map((avatarId, slot) => [slot, avatarId])
|
||||
: Object.entries(lineup).sort(([left], [right]) => Number(left) - Number(right));
|
||||
const lineupIds = lineupSlots
|
||||
.map(([, avatarId]) => Number(avatarId))
|
||||
.filter(avatarId => Number.isSafeInteger(avatarId) && avatarId > 0);
|
||||
const reportById = new Map();
|
||||
|
||||
for (const avatar of avatars) {
|
||||
const avatarId = Number(avatar?.avatar_id);
|
||||
if (!Number.isSafeInteger(avatarId) || avatarId <= 0 || reportById.has(avatarId)) {
|
||||
return avatars;
|
||||
}
|
||||
reportById.set(avatarId, avatar);
|
||||
// Battle slots remain stable when the overworld team changes or the native
|
||||
// statistics dictionary enumerates characters in a different order.
|
||||
const slots = avatars.map(avatar => avatar?.slot);
|
||||
if (slots.every(slot => Number.isSafeInteger(slot) && slot >= 0)
|
||||
&& new Set(slots).size === avatars.length) {
|
||||
return [...avatars].sort((left, right) => left.slot - right.slot);
|
||||
}
|
||||
|
||||
const orderedIds = [...new Set(lineupIds)].filter(avatarId => reportById.has(avatarId));
|
||||
if (orderedIds.length !== avatars.length) return avatars;
|
||||
return orderedIds.map(avatarId => reportById.get(avatarId));
|
||||
// A historical report without battle slots must keep its recorded order.
|
||||
return avatars;
|
||||
}
|
||||
|
||||
function damageBreakdownForAvatar(avatar) {
|
||||
const damage = avatar.attack_type_damage || {};
|
||||
const normal = Number(damage.Normal) || 0;
|
||||
const skill = Number(damage.BPSkill ?? damage.Skill) || 0;
|
||||
const ultimate = Number(damage.Ultra ?? damage.Ultimate) || 0;
|
||||
const breakDamage = Number(avatar.total_break_damage || damage.BreakDamage) || 0;
|
||||
// Aggregate totals can include damage without an attack-type entry. Keep it
|
||||
// visible without counting named subcategories a second time.
|
||||
const other = Math.max(0, (Number(avatar.total_damage) || 0) - normal - skill - ultimate - breakDamage);
|
||||
return { normal, skill, ultimate, breakDamage, other };
|
||||
}
|
||||
|
||||
function renderDamageAnalyst() {
|
||||
@@ -9364,6 +9386,8 @@ function renderDamageAnalyst() {
|
||||
const color = colors[idx % colors.length];
|
||||
const info = getAvatarInfo(a.avatar_id);
|
||||
const pct = getAvatarDamagePercent(a, teamDmg, hasAvatarDamage);
|
||||
const hasDamageBreakdown = hasAvatarDamageBreakdownData(a);
|
||||
const hasMaxHit = hasAvatarMaxHitData(a);
|
||||
|
||||
let maxHit = 0;
|
||||
if (a.attack_type_max_damage) {
|
||||
@@ -9372,10 +9396,7 @@ function renderDamageAnalyst() {
|
||||
}
|
||||
}
|
||||
|
||||
const dNormal = a.attack_type_damage?.Normal || 0;
|
||||
const dSkill = a.attack_type_damage?.BPSkill || a.attack_type_damage?.Skill || 0;
|
||||
const dUlt = a.attack_type_damage?.Ultra || a.attack_type_damage?.Ultimate || 0;
|
||||
const dBreak = a.total_break_damage || a.attack_type_damage?.BreakDamage || 0;
|
||||
const { normal: dNormal, skill: dSkill, ultimate: dUlt, breakDamage: dBreak, other: dOther } = damageBreakdownForAvatar(a);
|
||||
const dFollowup = (a.attack_type_damage?.Insert || 0) + (a.attack_type_damage?.Pursued || 0);
|
||||
const dDot = a.attack_type_damage?.DOT || 0;
|
||||
|
||||
@@ -9416,10 +9437,11 @@ function renderDamageAnalyst() {
|
||||
<div>
|
||||
<div style="font-size: 0.725rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">${t('analystTotalDamage')}</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 900; color: #fff;">${hasAvatarDamage ? formatNumber(a.total_damage) : '—'}</div>
|
||||
${Number(a.memosprite_damage) > 0 ? `<div style="font-size: 0.7rem; color: var(--text-muted);">Memosprite: ${formatNumber(a.memosprite_damage)} (included)</div>` : ''}
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 0.725rem; color: var(--text-muted);">${t('analystMaxHit')}</div>
|
||||
<div style="font-size: 0.95rem; font-weight: 800; color: #f87171;">${hasAvatarDamage ? formatNumber(maxHit) : '—'}</div>
|
||||
<div style="font-size: 0.95rem; font-weight: 800; color: #f87171;">${hasMaxHit ? formatNumber(maxHit) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9433,19 +9455,23 @@ function renderDamageAnalyst() {
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; margin-bottom: 1rem;">
|
||||
<div style="padding: 0.4rem 0.6rem; border-radius: 6px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); display: flex; justify-content: space-between; font-size: 0.775rem;">
|
||||
<span style="color: var(--text-muted);">${t('analystDmgNormal')}</span>
|
||||
<span style="font-weight: 700; color: var(--text-main);">${hasAvatarDamage ? formatNumber(dNormal) : '—'}</span>
|
||||
<span style="font-weight: 700; color: var(--text-main);">${hasDamageBreakdown ? formatNumber(dNormal) : '—'}</span>
|
||||
</div>
|
||||
<div style="padding: 0.4rem 0.6rem; border-radius: 6px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); display: flex; justify-content: space-between; font-size: 0.775rem;">
|
||||
<span style="color: var(--text-muted);">${t('analystDmgSkill')}</span>
|
||||
<span style="font-weight: 700; color: #38bdf8;">${hasAvatarDamage ? formatNumber(dSkill) : '—'}</span>
|
||||
<span style="font-weight: 700; color: #38bdf8;">${hasDamageBreakdown ? formatNumber(dSkill) : '—'}</span>
|
||||
</div>
|
||||
<div style="padding: 0.4rem 0.6rem; border-radius: 6px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); display: flex; justify-content: space-between; font-size: 0.775rem;">
|
||||
<span style="color: var(--text-muted);">${t('analystDmgUlt')}</span>
|
||||
<span style="font-weight: 700; color: #c084fc;">${hasAvatarDamage ? formatNumber(dUlt) : '—'}</span>
|
||||
<span style="font-weight: 700; color: #c084fc;">${hasDamageBreakdown ? formatNumber(dUlt) : '—'}</span>
|
||||
</div>
|
||||
<div style="padding: 0.4rem 0.6rem; border-radius: 6px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); display: flex; justify-content: space-between; font-size: 0.775rem;">
|
||||
<span style="color: var(--text-muted);">${t('analystDmgBreak')}</span>
|
||||
<span style="font-weight: 700; color: #fbbf24;">${hasAvatarDamage ? formatNumber(dBreak) : '—'}</span>
|
||||
<span style="font-weight: 700; color: #fbbf24;">${hasDamageBreakdown ? formatNumber(dBreak) : '—'}</span>
|
||||
</div>
|
||||
<div style="padding: 0.4rem 0.6rem; border-radius: 6px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); display: flex; justify-content: space-between; font-size: 0.775rem;">
|
||||
<span style="color: var(--text-muted);">Other</span>
|
||||
<span style="font-weight: 700; color: #f472b6;">${hasAvatarDamage ? formatNumber(dOther) : '—'}</span>
|
||||
</div>
|
||||
${dFollowup > 0 ? `
|
||||
<div style="padding: 0.4rem 0.6rem; border-radius: 6px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); display: flex; justify-content: space-between; font-size: 0.775rem;">
|
||||
@@ -9459,6 +9485,14 @@ function renderDamageAnalyst() {
|
||||
<span style="font-weight: 700; color: #a78bfa;">${formatNumber(dDot)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${Object.entries(a.attack_type_damage || {})
|
||||
.filter(([type, damage]) => Number(damage) > 0 && !['Normal', 'BPSkill', 'Skill', 'Ultra', 'Ultimate', 'BreakDamage', 'Insert', 'Pursued', 'DOT'].includes(type))
|
||||
.map(([type, damage]) => `
|
||||
<div style="padding: 0.4rem 0.6rem; border-radius: 6px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); display: flex; justify-content: space-between; gap: 0.4rem; font-size: 0.775rem;">
|
||||
<span style="color: var(--text-muted);">${escapeHtml(type.replace(/([a-z])([A-Z])/g, '$1 $2'))}</span>
|
||||
<span style="font-weight: 700; color: #f472b6;">${formatNumber(Number(damage))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap; padding-top: 0.65rem; border-top: 1px solid rgba(255,255,255,0.06); font-size: 0.75rem; color: var(--text-muted);">
|
||||
@@ -9752,6 +9786,8 @@ async function init() {
|
||||
await loadCachedStorageData();
|
||||
setupServerModes();
|
||||
setupLanguageSelector();
|
||||
initializeItemCatalog();
|
||||
initializeUnstuck({ apiCall, isConnected: () => state.isConnected, showToast });
|
||||
setupTabs();
|
||||
setupModalSubtabs();
|
||||
setupEvents();
|
||||
|
||||
+177
-22
@@ -6786,29 +6786,9 @@ for (const [language, additions] of Object.entries(AA_ROOM_SELECTION_TRANSLATION
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
const AA_ROOM_SELECTOR_TRANSLATIONS = {
|
||||
en: { monsterAaRoomDescription:'Choose an entry here, then start Anomaly Arbitration from the game. GaC uses this room for the next AA start only; other modes keep their Client data.', monsterAaRoomPending:'Selected for the next in-game AA start: {room} (Entry {id}). Start it from the game.', monsterAaRoomSelectLabel:'Anomaly Arbitration room', monsterAaRoomDefaultOption:'Use the room selected in the game', monsterAaRoomDefault:'No room override is selected. The game’s own room selection will be used.', monsterAaRoomUpdating:'Updating the room selection…', monsterAaRoomLoadError:'Could not load the AA room selection: {error}', monsterAaRoomSavedToast:'AA room selected. Start Anomaly Arbitration from the game to use it.', monsterAaRoomClearedToast:'AA room override cleared. The game’s own room selection will be used.', monsterAaRoomSaveError:'Could not update the AA room selection: {error}' },
|
||||
th: { monsterAaRoomDescription:'เลือกด่านที่นี่ แล้วกดเริ่ม Anomaly Arbitration ในเกม GaC จะใช้ห้องนี้กับการเริ่ม AA ครั้งถัดไปเท่านั้น โหมดอื่นยังใช้ข้อมูลจาก Client ตามเดิม', monsterAaRoomPending:'เลือกไว้สำหรับเริ่ม AA จากในเกมครั้งถัดไปแล้ว: {room} (รายการ {id}) กดเริ่มจากในเกมได้เลย', monsterAaRoomSelectLabel:'ห้อง Anomaly Arbitration', monsterAaRoomDefaultOption:'ใช้ห้องที่เลือกในเกม', monsterAaRoomDefault:'ยังไม่ได้ตั้งห้องแทน ระบบจะใช้ห้องที่เลือกในเกม', monsterAaRoomUpdating:'กำลังเปลี่ยนห้อง…', monsterAaRoomLoadError:'โหลดการเลือกห้อง AA ไม่สำเร็จ: {error}', monsterAaRoomSavedToast:'เลือกห้อง AA แล้ว กดเริ่ม Anomaly Arbitration จากในเกมเพื่อเข้า', monsterAaRoomClearedToast:'ล้างการตั้งห้อง AA แล้ว ระบบจะใช้ห้องที่เลือกในเกม', monsterAaRoomSaveError:'เปลี่ยนห้อง AA ไม่สำเร็จ: {error}' },
|
||||
'zh-CN': { monsterAaRoomDescription:'在此选择关卡,然后从游戏内开始异相仲裁。GaC 仅在下次开始 AA 时使用此房间;其他模式仍使用客户端数据。', monsterAaRoomPending:'已选为下次从游戏内开始 AA 时使用的房间:{room}(条目 {id})。请在游戏内开始。', monsterAaRoomSelectLabel:'异相仲裁房间', monsterAaRoomDefaultOption:'使用游戏内所选房间', monsterAaRoomDefault:'未设置房间覆盖,将使用游戏内选择的房间。', monsterAaRoomUpdating:'正在更新房间选择…', monsterAaRoomLoadError:'无法读取 AA 房间选择:{error}', monsterAaRoomSavedToast:'已选择 AA 房间。请从游戏内开始异相仲裁。', monsterAaRoomClearedToast:'已清除 AA 房间覆盖,将使用游戏内选择的房间。', monsterAaRoomSaveError:'无法更新 AA 房间选择:{error}' },
|
||||
'zh-TW': { monsterAaRoomDescription:'在此選擇關卡,再從遊戲內開始 Anomaly Arbitration。GaC 僅在下次開始 AA 時使用此房間;其他模式仍使用用戶端資料。', monsterAaRoomPending:'已選為下次從遊戲內開始 AA 時使用的房間:{room}(項目 {id})。請在遊戲內開始。', monsterAaRoomSelectLabel:'Anomaly Arbitration 房間', monsterAaRoomDefaultOption:'使用遊戲內選取的房間', monsterAaRoomDefault:'尚未設定房間覆寫,將使用遊戲內選取的房間。', monsterAaRoomUpdating:'正在更新房間選擇…', monsterAaRoomLoadError:'無法載入 AA 房間選擇:{error}', monsterAaRoomSavedToast:'已選取 AA 房間。請從遊戲內開始 Anomaly Arbitration。', monsterAaRoomClearedToast:'已清除 AA 房間覆寫,將使用遊戲內選取的房間。', monsterAaRoomSaveError:'無法更新 AA 房間選擇:{error}' },
|
||||
ja: { monsterAaRoomDescription:'ここでステージを選び、ゲーム内からAnomaly Arbitrationを開始してください。GaCは次回のAA開始時のみこの部屋を使用し、他のモードはClientデータを維持します。', monsterAaRoomPending:'次回ゲーム内からAAを開始するときの部屋に設定しました:{room}(エントリー {id})。ゲーム内から開始してください。', monsterAaRoomSelectLabel:'Anomaly Arbitrationの部屋', monsterAaRoomDefaultOption:'ゲーム内で選択した部屋を使用', monsterAaRoomDefault:'部屋の上書きは未設定です。ゲーム内で選択した部屋を使用します。', monsterAaRoomUpdating:'部屋の選択を更新中…', monsterAaRoomLoadError:'AAの部屋設定を読み込めませんでした:{error}', monsterAaRoomSavedToast:'AAの部屋を選択しました。ゲーム内からAnomaly Arbitrationを開始してください。', monsterAaRoomClearedToast:'AAの部屋設定を解除しました。ゲーム内の選択を使用します。', monsterAaRoomSaveError:'AAの部屋設定を更新できませんでした:{error}' },
|
||||
ko: { monsterAaRoomDescription:'여기서 스테이지를 선택한 뒤 게임에서 Anomaly Arbitration을 시작하세요. GaC는 다음 AA 시작에만 이 방을 적용하며, 다른 모드는 Client 데이터를 그대로 사용합니다.', monsterAaRoomPending:'게임에서 다음 AA 시작 시 사용할 방으로 설정됨: {room} (항목 {id}). 게임에서 시작하세요.', monsterAaRoomSelectLabel:'Anomaly Arbitration 방', monsterAaRoomDefaultOption:'게임에서 선택한 방 사용', monsterAaRoomDefault:'방 덮어쓰기가 설정되지 않았습니다. 게임에서 선택한 방을 사용합니다.', monsterAaRoomUpdating:'방 선택 업데이트 중…', monsterAaRoomLoadError:'AA 방 선택을 불러오지 못했습니다: {error}', monsterAaRoomSavedToast:'AA 방을 선택했습니다. 게임에서 Anomaly Arbitration을 시작하세요.', monsterAaRoomClearedToast:'AA 방 덮어쓰기를 지웠습니다. 게임에서 선택한 방을 사용합니다.', monsterAaRoomSaveError:'AA 방 선택을 업데이트하지 못했습니다: {error}' },
|
||||
vi: { monsterAaRoomDescription:'Chọn stage tại đây, sau đó bắt đầu Anomaly Arbitration trong game. GaC chỉ dùng phòng này cho lần bắt đầu AA tiếp theo; các chế độ khác vẫn dùng dữ liệu Client.', monsterAaRoomPending:'Đã chọn phòng cho lần bắt đầu AA tiếp theo trong game: {room} (Mục {id}). Hãy bắt đầu trong game.', monsterAaRoomSelectLabel:'Phòng Anomaly Arbitration', monsterAaRoomDefaultOption:'Dùng phòng đã chọn trong game', monsterAaRoomDefault:'Chưa ghi đè phòng. Sẽ dùng phòng được chọn trong game.', monsterAaRoomUpdating:'Đang cập nhật lựa chọn phòng…', monsterAaRoomLoadError:'Không thể tải lựa chọn phòng AA: {error}', monsterAaRoomSavedToast:'Đã chọn phòng AA. Hãy bắt đầu Anomaly Arbitration trong game.', monsterAaRoomClearedToast:'Đã xóa ghi đè phòng AA. Sẽ dùng phòng đã chọn trong game.', monsterAaRoomSaveError:'Không thể cập nhật lựa chọn phòng AA: {error}' },
|
||||
id: { monsterAaRoomDescription:'Pilih stage di sini, lalu mulai Anomaly Arbitration dari dalam game. GaC hanya memakai ruang ini untuk AA berikutnya; mode lain tetap menggunakan data Client.', monsterAaRoomPending:'Dipilih untuk mulai AA berikutnya dari game: {room} (Entri {id}). Mulai dari dalam game.', monsterAaRoomSelectLabel:'Ruang Anomaly Arbitration', monsterAaRoomDefaultOption:'Gunakan ruang yang dipilih di game', monsterAaRoomDefault:'Belum ada penggantian ruang. Ruang pilihan di game akan digunakan.', monsterAaRoomUpdating:'Memperbarui pilihan ruang…', monsterAaRoomLoadError:'Tidak dapat memuat pilihan ruang AA: {error}', monsterAaRoomSavedToast:'Ruang AA dipilih. Mulai Anomaly Arbitration dari game untuk menggunakannya.', monsterAaRoomClearedToast:'Penggantian ruang AA dihapus. Ruang pilihan di game akan digunakan.', monsterAaRoomSaveError:'Tidak dapat memperbarui pilihan ruang AA: {error}' },
|
||||
es: { monsterAaRoomDescription:'Elige una fase aquí y luego inicia Anomaly Arbitration desde el juego. GaC usará esta sala solo en el próximo inicio de AA; los demás modos conservan los datos del cliente.', monsterAaRoomPending:'Elegida para el próximo inicio de AA desde el juego: {room} (entrada {id}). Inícialo desde el juego.', monsterAaRoomSelectLabel:'Sala de Anomaly Arbitration', monsterAaRoomDefaultOption:'Usar la sala elegida en el juego', monsterAaRoomDefault:'No hay una sala sobrescrita. Se usará la sala elegida en el juego.', monsterAaRoomUpdating:'Actualizando la sala…', monsterAaRoomLoadError:'No se pudo cargar la selección de sala de AA: {error}', monsterAaRoomSavedToast:'Sala de AA elegida. Inicia Anomaly Arbitration desde el juego para usarla.', monsterAaRoomClearedToast:'Se borró la sala sobrescrita de AA. Se usará la sala elegida en el juego.', monsterAaRoomSaveError:'No se pudo actualizar la selección de sala de AA: {error}' },
|
||||
fr: { monsterAaRoomDescription:'Choisissez un stage ici, puis lancez Anomaly Arbitration depuis le jeu. GaC utilisera cette salle au prochain lancement AA uniquement ; les autres modes gardent les données du client.', monsterAaRoomPending:'Sélectionnée pour le prochain lancement AA depuis le jeu : {room} (entrée {id}). Lancez-le dans le jeu.', monsterAaRoomSelectLabel:'Salle Anomaly Arbitration', monsterAaRoomDefaultOption:'Utiliser la salle choisie dans le jeu', monsterAaRoomDefault:'Aucune salle ne remplace celle du jeu. La sélection du jeu sera utilisée.', monsterAaRoomUpdating:'Mise à jour de la salle…', monsterAaRoomLoadError:'Impossible de charger la sélection de salle AA : {error}', monsterAaRoomSavedToast:'Salle AA sélectionnée. Lancez Anomaly Arbitration depuis le jeu pour l’utiliser.', monsterAaRoomClearedToast:'Remplacement de salle AA effacé. La sélection du jeu sera utilisée.', monsterAaRoomSaveError:'Impossible de modifier la salle AA : {error}' },
|
||||
de: { monsterAaRoomDescription:'Wähle hier eine Stage und starte Anomaly Arbitration anschließend im Spiel. GaC verwendet diesen Raum nur beim nächsten AA-Start; andere Modi nutzen weiterhin Clientdaten.', monsterAaRoomPending:'Für den nächsten AA-Start im Spiel ausgewählt: {room} (Eintrag {id}). Starte den Modus im Spiel.', monsterAaRoomSelectLabel:'Anomaly-Arbitration-Raum', monsterAaRoomDefaultOption:'Im Spiel ausgewählten Raum verwenden', monsterAaRoomDefault:'Kein Raum überschrieben. Der im Spiel ausgewählte Raum wird verwendet.', monsterAaRoomUpdating:'Raumauswahl wird aktualisiert…', monsterAaRoomLoadError:'AA-Raumauswahl konnte nicht geladen werden: {error}', monsterAaRoomSavedToast:'AA-Raum ausgewählt. Starte Anomaly Arbitration im Spiel, um ihn zu betreten.', monsterAaRoomClearedToast:'AA-Raumüberschreibung entfernt. Die Spielauswahl wird verwendet.', monsterAaRoomSaveError:'AA-Raumauswahl konnte nicht aktualisiert werden: {error}' },
|
||||
ru: { monsterAaRoomDescription:'Выберите этап здесь, затем запустите Anomaly Arbitration в игре. GaC применит эту комнату только при следующем запуске AA; остальные режимы используют данные клиента.', monsterAaRoomPending:'Выбрано для следующего запуска AA из игры: {room} (этап {id}). Запустите режим в игре.', monsterAaRoomSelectLabel:'Комната Anomaly Arbitration', monsterAaRoomDefaultOption:'Использовать комнату, выбранную в игре', monsterAaRoomDefault:'Комната не переопределена. Будет использован выбор в игре.', monsterAaRoomUpdating:'Обновление выбора комнаты…', monsterAaRoomLoadError:'Не удалось загрузить выбор комнаты AA: {error}', monsterAaRoomSavedToast:'Комната AA выбрана. Запустите Anomaly Arbitration в игре.', monsterAaRoomClearedToast:'Переопределение комнаты AA очищено. Будет использован выбор в игре.', monsterAaRoomSaveError:'Не удалось обновить выбор комнаты AA: {error}' },
|
||||
pt: { monsterAaRoomDescription:'Escolha uma fase aqui e inicie Anomaly Arbitration no jogo. O GaC usará esta sala apenas na próxima entrada em AA; os outros modos continuam usando os dados do Client.', monsterAaRoomPending:'Selecionada para a próxima entrada em AA pelo jogo: {room} (Entrada {id}). Inicie pelo jogo.', monsterAaRoomSelectLabel:'Sala de Anomaly Arbitration', monsterAaRoomDefaultOption:'Usar a sala selecionada no jogo', monsterAaRoomDefault:'Nenhuma sala foi substituída. A sala selecionada no jogo será usada.', monsterAaRoomUpdating:'Atualizando a sala…', monsterAaRoomLoadError:'Não foi possível carregar a seleção da sala AA: {error}', monsterAaRoomSavedToast:'Sala AA selecionada. Inicie Anomaly Arbitration no jogo para usá-la.', monsterAaRoomClearedToast:'Substituição da sala AA removida. A seleção do jogo será usada.', monsterAaRoomSaveError:'Não foi possível atualizar a sala AA: {error}' }
|
||||
};
|
||||
|
||||
for (const [language, additions] of Object.entries(AA_ROOM_SELECTOR_TRANSLATIONS)) {
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
const AA_ROUND_SELECTION_TRANSLATIONS = {
|
||||
en: { monsterAaRoundTitle:'Choose the AA round', monsterAaRoundDescription:'Choose the AA round to sync to the game. Changes wait until you leave a battle or active run. Choose the room in game; entries below are previews.', monsterAaRoundPending:'Saved round: {round} (Group {id}). The game refreshes AA after the active battle or run ends.', monsterAaRoundSelectLabel:'Anomaly Arbitration round', monsterAaRoundDefaultOption:'Use the Client default round', monsterAaRoundDefault:'No round override. The Client default round will be shown.', monsterAaRoundUpdating:'Updating the AA round…', monsterAaRoundLoading:'Loading the saved AA round…', monsterAaRoundConnect:'Connect to GaC to choose the AA round shown in game.', monsterAaRoundLoadError:'Could not load the AA round: {error}', monsterAaRoundSavedToast:'AA round saved. Sync waits until you leave the active battle or run.', monsterAaRoundClearedToast:'AA round override cleared. The Client default round will be shown.', monsterAaRoundSaveError:'Could not update the AA round: {error}', monsterAaRoundGroupId:'Round {id}', monsterSettingsAARoomPreview:'Rooms in this round · preview only' },
|
||||
th: { monsterAaRoundTitle:'เลือกรอบ AA', monsterAaRoundDescription:'เลือกรอบ AA เพื่อซิงก์เข้าเกม หากกำลังสู้หรืออยู่ในรอบที่เล่นค้างไว้ ระบบจะรอให้ออกก่อน ส่วนห้องเลือกจากในเกม รายการด้านล่างเป็นตัวอย่าง', monsterAaRoundPending:'บันทึกรอบ {round} (กลุ่ม {id}) แล้ว เกมจะโหลดข้อมูล AA ใหม่เมื่อออกจาก Battle หรือรอบที่กำลังเล่น', monsterAaRoundSelectLabel:'รอบ Anomaly Arbitration', monsterAaRoundDefaultOption:'ใช้รอบเริ่มต้นของ Client', monsterAaRoundDefault:'ไม่ได้แทนรอบ ระบบจะแสดงรอบเริ่มต้นของ Client', monsterAaRoundUpdating:'กำลังเปลี่ยนรอบ AA…', monsterAaRoundLoading:'กำลังโหลดรอบ AA ที่บันทึกไว้…', monsterAaRoundConnect:'เชื่อมต่อ GaC เพื่อเลือกรอบ AA ที่จะแสดงในเกม', monsterAaRoundLoadError:'โหลดรอบ AA ไม่สำเร็จ: {error}', monsterAaRoundSavedToast:'บันทึกรอบ AA แล้ว รอออกจาก Battle หรือรอบที่กำลังเล่นเพื่อซิงก์เข้าเกม', monsterAaRoundClearedToast:'ล้างการแทนรอบ AA แล้ว ระบบจะแสดงรอบเริ่มต้นของ Client', monsterAaRoundSaveError:'เปลี่ยนรอบ AA ไม่สำเร็จ: {error}', monsterAaRoundGroupId:'รอบ {id}', monsterSettingsAARoomPreview:'ห้องในรอบนี้ · ตัวอย่างเท่านั้น' },
|
||||
en: { monsterAaRoundTitle:'Choose the AA round', monsterAaRoundDescription:'Select the round the game should show when its AA data loads again. Choose a room in the game; entries below are only previews.', monsterAaRoundPending:'Selected round: {round} (Group {id}). Reopen Anomaly Arbitration in the game to show it.', monsterAaRoundSelectLabel:'Anomaly Arbitration round', monsterAaRoundDefaultOption:'Use the Client default round', monsterAaRoundDefault:'No round override. The Client default round will be shown.', monsterAaRoundUpdating:'Updating the AA round…', monsterAaRoundLoading:'Loading the saved AA round…', monsterAaRoundConnect:'Connect to GaC to choose the AA round shown in game.', monsterAaRoundLoadError:'Could not load the AA round: {error}', monsterAaRoundSavedToast:'AA round selected. Reopen Anomaly Arbitration in the game to show it.', monsterAaRoundClearedToast:'AA round override cleared. The Client default round will be shown.', monsterAaRoundSaveError:'Could not update the AA round: {error}', monsterAaRoundGroupId:'Round {id}', monsterSettingsAARoomPreview:'Rooms in this round · preview only' },
|
||||
th: { monsterAaRoundTitle:'เลือกรอบ AA', monsterAaRoundDescription:'เลือกรอบที่จะแสดงในเกมเมื่อโหลดข้อมูล AA อีกครั้ง ส่วนห้องให้เลือกจากในเกม รายการด้านล่างมีไว้ดูตัวอย่างเท่านั้น', monsterAaRoundPending:'เลือกรอบ {round} (กลุ่ม {id}) แล้ว เปิด Anomaly Arbitration ในเกมอีกครั้งเพื่อให้แสดงรอบนี้', monsterAaRoundSelectLabel:'รอบ Anomaly Arbitration', monsterAaRoundDefaultOption:'ใช้รอบเริ่มต้นของ Client', monsterAaRoundDefault:'ไม่ได้แทนรอบ ระบบจะแสดงรอบเริ่มต้นของ Client', monsterAaRoundUpdating:'กำลังเปลี่ยนรอบ AA…', monsterAaRoundLoading:'กำลังโหลดรอบ AA ที่บันทึกไว้…', monsterAaRoundConnect:'เชื่อมต่อ GaC เพื่อเลือกรอบ AA ที่จะแสดงในเกม', monsterAaRoundLoadError:'โหลดรอบ AA ไม่สำเร็จ: {error}', monsterAaRoundSavedToast:'เลือกรอบ AA แล้ว เปิด Anomaly Arbitration ในเกมอีกครั้งเพื่อให้แสดงรอบนี้', monsterAaRoundClearedToast:'ล้างการแทนรอบ AA แล้ว ระบบจะแสดงรอบเริ่มต้นของ Client', monsterAaRoundSaveError:'เปลี่ยนรอบ AA ไม่สำเร็จ: {error}', monsterAaRoundGroupId:'รอบ {id}', monsterSettingsAARoomPreview:'ห้องในรอบนี้ · ตัวอย่างเท่านั้น' },
|
||||
'zh-CN': { monsterAaRoundTitle:'选择 AA 轮次', monsterAaRoundDescription:'选择游戏重新加载 AA 数据时显示的轮次。具体房间仍在游戏内选择;下方条目仅供预览。', monsterAaRoundPending:'已选择轮次:{round}(组 {id})。请重新打开游戏内的异相仲裁以显示。', monsterAaRoundSelectLabel:'Anomaly Arbitration 轮次', monsterAaRoundDefaultOption:'使用 Client 默认轮次', monsterAaRoundDefault:'未覆盖轮次,将显示 Client 默认轮次。', monsterAaRoundUpdating:'正在更新 AA 轮次…', monsterAaRoundLoading:'正在加载已保存的 AA 轮次…', monsterAaRoundConnect:'连接 GaC 后才能选择游戏中显示的 AA 轮次。', monsterAaRoundLoadError:'无法加载 AA 轮次:{error}', monsterAaRoundSavedToast:'已选择 AA 轮次。请重新打开游戏内的异相仲裁以显示。', monsterAaRoundClearedToast:'已清除 AA 轮次覆盖,将显示 Client 默认轮次。', monsterAaRoundSaveError:'无法更新 AA 轮次:{error}', monsterAaRoundGroupId:'轮次 {id}', monsterSettingsAARoomPreview:'本轮房间 · 仅预览' },
|
||||
'zh-TW': { monsterAaRoundTitle:'選擇 AA 輪次', monsterAaRoundDescription:'選擇遊戲重新載入 AA 資料時要顯示的輪次。房間仍在遊戲內選擇;下方項目僅供預覽。', monsterAaRoundPending:'已選擇輪次:{round}(組別 {id})。請重新開啟遊戲內的 Anomaly Arbitration 以顯示。', monsterAaRoundSelectLabel:'Anomaly Arbitration 輪次', monsterAaRoundDefaultOption:'使用 Client 預設輪次', monsterAaRoundDefault:'尚未覆寫輪次,將顯示 Client 預設輪次。', monsterAaRoundUpdating:'正在更新 AA 輪次…', monsterAaRoundLoading:'正在載入已儲存的 AA 輪次…', monsterAaRoundConnect:'連線 GaC 後才能選擇遊戲中顯示的 AA 輪次。', monsterAaRoundLoadError:'無法載入 AA 輪次:{error}', monsterAaRoundSavedToast:'已選擇 AA 輪次。請重新開啟遊戲內的 Anomaly Arbitration 以顯示。', monsterAaRoundClearedToast:'已清除 AA 輪次覆寫,將顯示 Client 預設輪次。', monsterAaRoundSaveError:'無法更新 AA 輪次:{error}', monsterAaRoundGroupId:'輪次 {id}', monsterSettingsAARoomPreview:'本輪房間 · 僅供預覽' },
|
||||
ja: { monsterAaRoundTitle:'AAのラウンドを選択', monsterAaRoundDescription:'ゲームがAAデータを再読み込みしたときに表示するラウンドを選びます。部屋はゲーム内で選択してください。下の項目はプレビューのみです。', monsterAaRoundPending:'選択中のラウンド:{round}(グループ {id})。表示するにはゲーム内でAnomaly Arbitrationを開き直してください。', monsterAaRoundSelectLabel:'Anomaly Arbitrationのラウンド', monsterAaRoundDefaultOption:'Clientのデフォルトを使用', monsterAaRoundDefault:'ラウンドは上書きされていません。Clientのデフォルトを表示します。', monsterAaRoundUpdating:'AAラウンドを更新中…', monsterAaRoundLoading:'保存済みAAラウンドを読み込み中…', monsterAaRoundConnect:'ゲームに表示するAAラウンドを選ぶにはGaCに接続してください。', monsterAaRoundLoadError:'AAラウンドを読み込めませんでした:{error}', monsterAaRoundSavedToast:'AAラウンドを選択しました。表示するにはゲーム内のAnomaly Arbitrationを開き直してください。', monsterAaRoundClearedToast:'AAラウンド設定を解除しました。Clientのデフォルトを表示します。', monsterAaRoundSaveError:'AAラウンドを更新できませんでした:{error}', monsterAaRoundGroupId:'ラウンド {id}', monsterSettingsAARoomPreview:'このラウンドの部屋 · プレビューのみ' },
|
||||
@@ -6889,6 +6869,181 @@ for (const [language, additions] of Object.entries(SERVER_MODE_AVAILABILITY_TRAN
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
|
||||
const ITEM_CATALOG_TRANSLATIONS = {
|
||||
th: {
|
||||
tabItemCatalog: '📚 สมุดไอเทม', itemCatalogTitle: '📚 สมุดรวมไอเทม',
|
||||
itemCatalogDescription: 'ค้นชื่อหรือ ID เพื่อดูรูปและรหัสจากตารางเกม รายการที่มีป้าย GaC รองรับใช้จัดการในเครื่องมือกระเป๋าได้ ส่วนรายการอื่นใช้ค้นดู ID เท่านั้น',
|
||||
itemCatalogSearchPlaceholder: 'ค้นหาชื่อหรือ Item ID...', itemCatalogFilterLabel: 'กรองสมุดไอเทม', itemCatalogFilterAll: 'ทั้งหมด',
|
||||
itemCatalogFilterSupported: 'GaC รองรับ', itemCatalogFilterItems: 'ไอเทม',
|
||||
itemCatalogFilterLightCones: 'กรวยแสง', itemCatalogFilterRelics: 'รีลิกส์',
|
||||
itemCatalogSupportedTag: 'GaC รองรับ', itemCatalogBrowseOnlyTag: 'ดูข้อมูลเท่านั้น',
|
||||
itemCatalogUnknownName: 'ไม่มีชื่อในตาราง #{id}', itemCatalogNoImage: 'ไม่มีภาพในข้อมูลเกม',
|
||||
itemCatalogLoading: 'กำลังโหลดสมุดไอเทม...', itemCatalogLoadFailed: 'โหลดสมุดไอเทมไม่สำเร็จ'
|
||||
},
|
||||
en: {
|
||||
tabItemCatalog: '📚 Item Book', itemCatalogTitle: '📚 Item Catalog',
|
||||
itemCatalogDescription: 'Search game table names or IDs and view their icons. Entries marked GaC supported can be managed in the inventory tool; other entries are browse-only.',
|
||||
itemCatalogSearchPlaceholder: 'Search by name or item ID...', itemCatalogFilterLabel: 'Filter item catalog', itemCatalogFilterAll: 'All entries',
|
||||
itemCatalogFilterSupported: 'GaC supported', itemCatalogFilterItems: 'Items',
|
||||
itemCatalogFilterLightCones: 'Light Cones', itemCatalogFilterRelics: 'Relics',
|
||||
itemCatalogSupportedTag: 'GaC supported', itemCatalogBrowseOnlyTag: 'Browse only',
|
||||
itemCatalogUnknownName: 'Unnamed item #{id}', itemCatalogNoImage: 'No icon in game data',
|
||||
itemCatalogLoading: 'Loading item catalog...', itemCatalogLoadFailed: 'Could not load item catalog'
|
||||
},
|
||||
'zh-CN': {
|
||||
tabItemCatalog: '📚 物品图鉴', itemCatalogTitle: '📚 物品目录',
|
||||
itemCatalogDescription: '搜索游戏表格中的名称或 ID 并查看图标。标记为 GaC 支持的条目可在背包工具中管理;其他条目仅供查询。',
|
||||
itemCatalogSearchPlaceholder: '搜索名称或物品 ID...', itemCatalogFilterLabel: '筛选物品目录', itemCatalogFilterAll: '全部条目',
|
||||
itemCatalogFilterSupported: 'GaC 支持', itemCatalogFilterItems: '物品',
|
||||
itemCatalogFilterLightCones: '光锥', itemCatalogFilterRelics: '遗器',
|
||||
itemCatalogSupportedTag: 'GaC 支持', itemCatalogBrowseOnlyTag: '仅供查询',
|
||||
itemCatalogUnknownName: '未命名物品 #{id}', itemCatalogNoImage: '游戏数据未配置图标',
|
||||
itemCatalogLoading: '正在加载物品目录...', itemCatalogLoadFailed: '无法加载物品目录'
|
||||
},
|
||||
'zh-TW': {
|
||||
tabItemCatalog: '📚 物品圖鑑', itemCatalogTitle: '📚 物品目錄',
|
||||
itemCatalogDescription: '搜尋遊戲表格中的名稱或 ID 並查看圖示。標記為 GaC 支援的項目可在背包工具中管理;其他項目僅供查詢。',
|
||||
itemCatalogSearchPlaceholder: '搜尋名稱或物品 ID...', itemCatalogFilterLabel: '篩選物品目錄', itemCatalogFilterAll: '全部項目',
|
||||
itemCatalogFilterSupported: 'GaC 支援', itemCatalogFilterItems: '物品',
|
||||
itemCatalogFilterLightCones: '光錐', itemCatalogFilterRelics: '遺器',
|
||||
itemCatalogSupportedTag: 'GaC 支援', itemCatalogBrowseOnlyTag: '僅供查詢',
|
||||
itemCatalogUnknownName: '未命名物品 #{id}', itemCatalogNoImage: '遊戲資料未設定圖示',
|
||||
itemCatalogLoading: '正在載入物品目錄...', itemCatalogLoadFailed: '無法載入物品目錄'
|
||||
},
|
||||
ja: {
|
||||
tabItemCatalog: '📚 アイテム図鑑', itemCatalogTitle: '📚 アイテム一覧',
|
||||
itemCatalogDescription: 'ゲームデータの名前や ID を検索してアイコンを確認できます。GaC 対応と表示された項目は所持品ツールで管理でき、その他は閲覧のみです。',
|
||||
itemCatalogSearchPlaceholder: '名前またはアイテム ID を検索...', itemCatalogFilterLabel: 'アイテム一覧を絞り込む', itemCatalogFilterAll: 'すべて',
|
||||
itemCatalogFilterSupported: 'GaC 対応', itemCatalogFilterItems: 'アイテム',
|
||||
itemCatalogFilterLightCones: '光円錐', itemCatalogFilterRelics: '遺物',
|
||||
itemCatalogSupportedTag: 'GaC 対応', itemCatalogBrowseOnlyTag: '閲覧のみ',
|
||||
itemCatalogUnknownName: '名前なし #{id}', itemCatalogNoImage: 'ゲームデータに画像なし',
|
||||
itemCatalogLoading: 'アイテム一覧を読み込み中...', itemCatalogLoadFailed: 'アイテム一覧を読み込めませんでした'
|
||||
},
|
||||
ko: {
|
||||
tabItemCatalog: '📚 아이템 도감', itemCatalogTitle: '📚 아이템 목록',
|
||||
itemCatalogDescription: '게임 데이터의 이름이나 ID를 검색해 아이콘을 확인하세요. GaC 지원 표시가 있는 항목은 인벤토리 도구에서 관리할 수 있으며, 나머지는 조회 전용입니다.',
|
||||
itemCatalogSearchPlaceholder: '이름 또는 아이템 ID 검색...', itemCatalogFilterLabel: '아이템 목록 필터', itemCatalogFilterAll: '전체 항목',
|
||||
itemCatalogFilterSupported: 'GaC 지원', itemCatalogFilterItems: '아이템',
|
||||
itemCatalogFilterLightCones: '광추', itemCatalogFilterRelics: '유물',
|
||||
itemCatalogSupportedTag: 'GaC 지원', itemCatalogBrowseOnlyTag: '조회 전용',
|
||||
itemCatalogUnknownName: '이름 없음 #{id}', itemCatalogNoImage: '게임 데이터에 아이콘 없음',
|
||||
itemCatalogLoading: '아이템 목록을 불러오는 중...', itemCatalogLoadFailed: '아이템 목록을 불러오지 못했습니다'
|
||||
},
|
||||
vi: {
|
||||
tabItemCatalog: '📚 Sổ tay vật phẩm', itemCatalogTitle: '📚 Danh mục vật phẩm',
|
||||
itemCatalogDescription: 'Tìm tên hoặc ID trong bảng dữ liệu trò chơi để xem biểu tượng. Mục có nhãn GaC hỗ trợ có thể quản lý trong công cụ túi đồ; mục khác chỉ dùng để tra cứu.',
|
||||
itemCatalogSearchPlaceholder: 'Tìm theo tên hoặc ID vật phẩm...', itemCatalogFilterLabel: 'Lọc danh mục vật phẩm', itemCatalogFilterAll: 'Tất cả mục',
|
||||
itemCatalogFilterSupported: 'GaC hỗ trợ', itemCatalogFilterItems: 'Vật phẩm',
|
||||
itemCatalogFilterLightCones: 'Nón Ánh Sáng', itemCatalogFilterRelics: 'Di Vật',
|
||||
itemCatalogSupportedTag: 'GaC hỗ trợ', itemCatalogBrowseOnlyTag: 'Chỉ tra cứu',
|
||||
itemCatalogUnknownName: 'Vật phẩm chưa có tên #{id}', itemCatalogNoImage: 'Dữ liệu game không có biểu tượng',
|
||||
itemCatalogLoading: 'Đang tải danh mục vật phẩm...', itemCatalogLoadFailed: 'Không tải được danh mục vật phẩm'
|
||||
},
|
||||
id: {
|
||||
tabItemCatalog: '📚 Buku Item', itemCatalogTitle: '📚 Katalog Item',
|
||||
itemCatalogDescription: 'Cari nama atau ID dari tabel game dan lihat ikonnya. Item berlabel didukung GaC dapat dikelola melalui alat inventaris; item lainnya hanya untuk dilihat.',
|
||||
itemCatalogSearchPlaceholder: 'Cari nama atau ID item...', itemCatalogFilterLabel: 'Filter katalog item', itemCatalogFilterAll: 'Semua item',
|
||||
itemCatalogFilterSupported: 'Didukung GaC', itemCatalogFilterItems: 'Item',
|
||||
itemCatalogFilterLightCones: 'Light Cone', itemCatalogFilterRelics: 'Relik',
|
||||
itemCatalogSupportedTag: 'Didukung GaC', itemCatalogBrowseOnlyTag: 'Hanya dilihat',
|
||||
itemCatalogUnknownName: 'Item tanpa nama #{id}', itemCatalogNoImage: 'Tidak ada ikon di data game',
|
||||
itemCatalogLoading: 'Memuat katalog item...', itemCatalogLoadFailed: 'Katalog item gagal dimuat'
|
||||
},
|
||||
es: {
|
||||
tabItemCatalog: '📚 Catálogo', itemCatalogTitle: '📚 Catálogo de objetos',
|
||||
itemCatalogDescription: 'Busca nombres o ID de las tablas del juego y consulta sus iconos. Los elementos compatibles con GaC se pueden gestionar en el inventario; los demás solo se pueden consultar.',
|
||||
itemCatalogSearchPlaceholder: 'Buscar por nombre o ID...', itemCatalogFilterLabel: 'Filtrar catálogo de objetos', itemCatalogFilterAll: 'Todos',
|
||||
itemCatalogFilterSupported: 'Compatible con GaC', itemCatalogFilterItems: 'Objetos',
|
||||
itemCatalogFilterLightCones: 'Conos de luz', itemCatalogFilterRelics: 'Reliquias',
|
||||
itemCatalogSupportedTag: 'Compatible con GaC', itemCatalogBrowseOnlyTag: 'Solo consulta',
|
||||
itemCatalogUnknownName: 'Objeto sin nombre #{id}', itemCatalogNoImage: 'Sin icono en los datos del juego',
|
||||
itemCatalogLoading: 'Cargando catálogo...', itemCatalogLoadFailed: 'No se pudo cargar el catálogo'
|
||||
},
|
||||
fr: {
|
||||
tabItemCatalog: '📚 Catalogue', itemCatalogTitle: '📚 Catalogue des objets',
|
||||
itemCatalogDescription: 'Recherchez les noms ou ID des tables du jeu et consultez leurs icônes. Les éléments compatibles avec GaC peuvent être gérés dans l’inventaire ; les autres sont consultables uniquement.',
|
||||
itemCatalogSearchPlaceholder: 'Rechercher par nom ou ID...', itemCatalogFilterLabel: 'Filtrer le catalogue', itemCatalogFilterAll: 'Tous les éléments',
|
||||
itemCatalogFilterSupported: 'Compatible GaC', itemCatalogFilterItems: 'Objets',
|
||||
itemCatalogFilterLightCones: 'Cônes de lumière', itemCatalogFilterRelics: 'Reliques',
|
||||
itemCatalogSupportedTag: 'Compatible GaC', itemCatalogBrowseOnlyTag: 'Consultation seule',
|
||||
itemCatalogUnknownName: 'Objet sans nom #{id}', itemCatalogNoImage: 'Aucune icône dans les données du jeu',
|
||||
itemCatalogLoading: 'Chargement du catalogue...', itemCatalogLoadFailed: 'Impossible de charger le catalogue'
|
||||
},
|
||||
de: {
|
||||
tabItemCatalog: '📚 Gegenstandsbuch', itemCatalogTitle: '📚 Gegenstandskatalog',
|
||||
itemCatalogDescription: 'Suche nach Namen oder IDs aus den Spieltabellen und sieh dir die Symbole an. GaC-unterstützte Einträge lassen sich im Inventarwerkzeug verwalten; alle anderen dienen nur zum Nachschlagen.',
|
||||
itemCatalogSearchPlaceholder: 'Nach Name oder Gegenstands-ID suchen...', itemCatalogFilterLabel: 'Katalog filtern', itemCatalogFilterAll: 'Alle Einträge',
|
||||
itemCatalogFilterSupported: 'Von GaC unterstützt', itemCatalogFilterItems: 'Gegenstände',
|
||||
itemCatalogFilterLightCones: 'Lichtkegel', itemCatalogFilterRelics: 'Relikte',
|
||||
itemCatalogSupportedTag: 'Von GaC unterstützt', itemCatalogBrowseOnlyTag: 'Nur Nachschlagen',
|
||||
itemCatalogUnknownName: 'Unbenannter Gegenstand #{id}', itemCatalogNoImage: 'Kein Symbol in den Spieldaten',
|
||||
itemCatalogLoading: 'Gegenstandskatalog wird geladen...', itemCatalogLoadFailed: 'Gegenstandskatalog konnte nicht geladen werden'
|
||||
},
|
||||
ru: {
|
||||
tabItemCatalog: '📚 Каталог предметов', itemCatalogTitle: '📚 Каталог предметов',
|
||||
itemCatalogDescription: 'Ищите названия или ID из игровых таблиц и просматривайте значки. Предметы с отметкой GaC можно менять в инструменте инвентаря; остальные доступны только для просмотра.',
|
||||
itemCatalogSearchPlaceholder: 'Поиск по названию или ID...', itemCatalogFilterLabel: 'Фильтр каталога предметов', itemCatalogFilterAll: 'Все записи',
|
||||
itemCatalogFilterSupported: 'Поддерживается GaC', itemCatalogFilterItems: 'Предметы',
|
||||
itemCatalogFilterLightCones: 'Световые конусы', itemCatalogFilterRelics: 'Реликвии',
|
||||
itemCatalogSupportedTag: 'Поддерживается GaC', itemCatalogBrowseOnlyTag: 'Только просмотр',
|
||||
itemCatalogUnknownName: 'Предмет без названия #{id}', itemCatalogNoImage: 'В игровых данных нет значка',
|
||||
itemCatalogLoading: 'Загрузка каталога предметов...', itemCatalogLoadFailed: 'Не удалось загрузить каталог предметов'
|
||||
},
|
||||
pt: {
|
||||
tabItemCatalog: '📚 Catálogo de itens', itemCatalogTitle: '📚 Catálogo de itens',
|
||||
itemCatalogDescription: 'Pesquise nomes ou IDs das tabelas do jogo e veja os ícones. Itens marcados como compatíveis com GaC podem ser gerenciados no inventário; os demais são apenas para consulta.',
|
||||
itemCatalogSearchPlaceholder: 'Pesquisar por nome ou ID...', itemCatalogFilterLabel: 'Filtrar catálogo de itens', itemCatalogFilterAll: 'Todos os itens',
|
||||
itemCatalogFilterSupported: 'Compatível com GaC', itemCatalogFilterItems: 'Itens',
|
||||
itemCatalogFilterLightCones: 'Cones de Luz', itemCatalogFilterRelics: 'Relíquias',
|
||||
itemCatalogSupportedTag: 'Compatível com GaC', itemCatalogBrowseOnlyTag: 'Somente consulta',
|
||||
itemCatalogUnknownName: 'Item sem nome #{id}', itemCatalogNoImage: 'Sem ícone nos dados do jogo',
|
||||
itemCatalogLoading: 'Carregando catálogo de itens...', itemCatalogLoadFailed: 'Não foi possível carregar o catálogo de itens'
|
||||
}
|
||||
};
|
||||
for (const [language, additions] of Object.entries(ITEM_CATALOG_TRANSLATIONS)) {
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
const ITEM_CATALOG_DETAIL_TRANSLATIONS = {
|
||||
th: { itemCatalogDetailTitle: 'รายละเอียดไอเทม', itemCatalogItemId: 'Item ID {id}', itemCatalogSourceTable: 'ตารางต้นทาง', itemCatalogItemDescription: 'คำอธิบาย', itemCatalogLore: 'ข้อมูลเพิ่มเติม', itemCatalogNoDescription: 'ไม่มีคำอธิบายในข้อมูลเกม', itemCatalogNoDetails: 'ไม่มีรายละเอียดเพิ่มเติม', itemCatalogOpenDetails: 'ดูรายละเอียด {name}', itemCatalogQuantity: 'จำนวนที่เลือก', itemCatalogMaxQuantity: 'สูงสุด {count}', itemCatalogClose: 'ปิด', itemCatalogDecrease: 'ลดจำนวน', itemCatalogIncrease: 'เพิ่มจำนวน' },
|
||||
en: { itemCatalogDetailTitle: 'Item Details', itemCatalogItemId: 'Item ID {id}', itemCatalogSourceTable: 'Source tables', itemCatalogItemDescription: 'Description', itemCatalogLore: 'Additional Details', itemCatalogNoDescription: 'No description in game data', itemCatalogNoDetails: 'No additional details', itemCatalogOpenDetails: 'View details for {name}', itemCatalogQuantity: 'Selected quantity', itemCatalogMaxQuantity: 'Maximum {count}', itemCatalogClose: 'Close', itemCatalogDecrease: 'Decrease quantity', itemCatalogIncrease: 'Increase quantity' },
|
||||
'zh-CN': { itemCatalogDetailTitle: '物品详情', itemCatalogItemId: '物品 ID {id}', itemCatalogSourceTable: '来源表', itemCatalogItemDescription: '描述', itemCatalogLore: '补充信息', itemCatalogNoDescription: '游戏数据中没有描述', itemCatalogNoDetails: '没有更多信息', itemCatalogOpenDetails: '查看{name}详情', itemCatalogQuantity: '选择数量', itemCatalogMaxQuantity: '最多 {count}', itemCatalogClose: '关闭', itemCatalogDecrease: '减少数量', itemCatalogIncrease: '增加数量' },
|
||||
'zh-TW': { itemCatalogDetailTitle: '物品詳情', itemCatalogItemId: '物品 ID {id}', itemCatalogSourceTable: '來源表', itemCatalogItemDescription: '說明', itemCatalogLore: '補充資訊', itemCatalogNoDescription: '遊戲資料中沒有說明', itemCatalogNoDetails: '沒有更多資訊', itemCatalogOpenDetails: '查看{name}詳情', itemCatalogQuantity: '選擇數量', itemCatalogMaxQuantity: '最多 {count}', itemCatalogClose: '關閉', itemCatalogDecrease: '減少數量', itemCatalogIncrease: '增加數量' },
|
||||
ja: { itemCatalogDetailTitle: 'アイテム詳細', itemCatalogItemId: 'アイテム ID {id}', itemCatalogSourceTable: '参照テーブル', itemCatalogItemDescription: '説明', itemCatalogLore: '補足情報', itemCatalogNoDescription: 'ゲームデータに説明がありません', itemCatalogNoDetails: '追加情報はありません', itemCatalogOpenDetails: '{name}の詳細を見る', itemCatalogQuantity: '選択数', itemCatalogMaxQuantity: '上限 {count}', itemCatalogClose: '閉じる', itemCatalogDecrease: '数量を減らす', itemCatalogIncrease: '数量を増やす' },
|
||||
ko: { itemCatalogDetailTitle: '아이템 상세 정보', itemCatalogItemId: '아이템 ID {id}', itemCatalogSourceTable: '출처 테이블', itemCatalogItemDescription: '설명', itemCatalogLore: '추가 정보', itemCatalogNoDescription: '게임 데이터에 설명이 없습니다', itemCatalogNoDetails: '추가 정보가 없습니다', itemCatalogOpenDetails: '{name} 상세 정보 보기', itemCatalogQuantity: '선택 수량', itemCatalogMaxQuantity: '최대 {count}', itemCatalogClose: '닫기', itemCatalogDecrease: '수량 줄이기', itemCatalogIncrease: '수량 늘리기' },
|
||||
vi: { itemCatalogDetailTitle: 'Chi tiết vật phẩm', itemCatalogItemId: 'ID vật phẩm {id}', itemCatalogSourceTable: 'Bảng nguồn', itemCatalogItemDescription: 'Mô tả', itemCatalogLore: 'Thông tin thêm', itemCatalogNoDescription: 'Dữ liệu game không có mô tả', itemCatalogNoDetails: 'Không có thông tin thêm', itemCatalogOpenDetails: 'Xem chi tiết {name}', itemCatalogQuantity: 'Số lượng đã chọn', itemCatalogMaxQuantity: 'Tối đa {count}', itemCatalogClose: 'Đóng', itemCatalogDecrease: 'Giảm số lượng', itemCatalogIncrease: 'Tăng số lượng' },
|
||||
id: { itemCatalogDetailTitle: 'Detail Item', itemCatalogItemId: 'ID item {id}', itemCatalogSourceTable: 'Tabel sumber', itemCatalogItemDescription: 'Deskripsi', itemCatalogLore: 'Info Tambahan', itemCatalogNoDescription: 'Tidak ada deskripsi di data game', itemCatalogNoDetails: 'Tidak ada info tambahan', itemCatalogOpenDetails: 'Lihat detail {name}', itemCatalogQuantity: 'Jumlah dipilih', itemCatalogMaxQuantity: 'Maksimal {count}', itemCatalogClose: 'Tutup', itemCatalogDecrease: 'Kurangi jumlah', itemCatalogIncrease: 'Tambah jumlah' },
|
||||
es: { itemCatalogDetailTitle: 'Detalles del objeto', itemCatalogItemId: 'ID del objeto {id}', itemCatalogSourceTable: 'Tablas de origen', itemCatalogItemDescription: 'Descripción', itemCatalogLore: 'Información adicional', itemCatalogNoDescription: 'Sin descripción en los datos del juego', itemCatalogNoDetails: 'Sin más detalles', itemCatalogOpenDetails: 'Ver detalles de {name}', itemCatalogQuantity: 'Cantidad seleccionada', itemCatalogMaxQuantity: 'Máximo {count}', itemCatalogClose: 'Cerrar', itemCatalogDecrease: 'Reducir cantidad', itemCatalogIncrease: 'Aumentar cantidad' },
|
||||
fr: { itemCatalogDetailTitle: 'Détails de l’objet', itemCatalogItemId: 'ID de l’objet {id}', itemCatalogSourceTable: 'Tables sources', itemCatalogItemDescription: 'Description', itemCatalogLore: 'Informations supplémentaires', itemCatalogNoDescription: 'Aucune description dans les données du jeu', itemCatalogNoDetails: 'Aucun autre détail', itemCatalogOpenDetails: 'Voir les détails de {name}', itemCatalogQuantity: 'Quantité choisie', itemCatalogMaxQuantity: 'Maximum {count}', itemCatalogClose: 'Fermer', itemCatalogDecrease: 'Diminuer la quantité', itemCatalogIncrease: 'Augmenter la quantité' },
|
||||
de: { itemCatalogDetailTitle: 'Gegenstandsdetails', itemCatalogItemId: 'Gegenstands-ID {id}', itemCatalogSourceTable: 'Quelltabellen', itemCatalogItemDescription: 'Beschreibung', itemCatalogLore: 'Zusätzliche Details', itemCatalogNoDescription: 'Keine Beschreibung in den Spieldaten', itemCatalogNoDetails: 'Keine weiteren Details', itemCatalogOpenDetails: 'Details für {name} anzeigen', itemCatalogQuantity: 'Ausgewählte Menge', itemCatalogMaxQuantity: 'Maximum {count}', itemCatalogClose: 'Schließen', itemCatalogDecrease: 'Menge verringern', itemCatalogIncrease: 'Menge erhöhen' },
|
||||
ru: { itemCatalogDetailTitle: 'Сведения о предмете', itemCatalogItemId: 'ID предмета {id}', itemCatalogSourceTable: 'Исходные таблицы', itemCatalogItemDescription: 'Описание', itemCatalogLore: 'Дополнительная информация', itemCatalogNoDescription: 'В игровых данных нет описания', itemCatalogNoDetails: 'Дополнительных сведений нет', itemCatalogOpenDetails: 'Подробнее: {name}', itemCatalogQuantity: 'Выбранное количество', itemCatalogMaxQuantity: 'Максимум: {count}', itemCatalogClose: 'Закрыть', itemCatalogDecrease: 'Уменьшить количество', itemCatalogIncrease: 'Увеличить количество' },
|
||||
pt: { itemCatalogDetailTitle: 'Detalhes do item', itemCatalogItemId: 'ID do item {id}', itemCatalogSourceTable: 'Tabelas de origem', itemCatalogItemDescription: 'Descrição', itemCatalogLore: 'Informações adicionais', itemCatalogNoDescription: 'Sem descrição nos dados do jogo', itemCatalogNoDetails: 'Sem detalhes adicionais', itemCatalogOpenDetails: 'Ver detalhes de {name}', itemCatalogQuantity: 'Quantidade selecionada', itemCatalogMaxQuantity: 'Máximo {count}', itemCatalogClose: 'Fechar', itemCatalogDecrease: 'Diminuir quantidade', itemCatalogIncrease: 'Aumentar quantidade' }
|
||||
};
|
||||
for (const [language, additions] of Object.entries(ITEM_CATALOG_DETAIL_TRANSLATIONS)) {
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
const ITEM_CATALOG_INTERACTION_DESCRIPTIONS = {
|
||||
th: 'ค้นหาชื่อหรือ ID แล้วคลิกรูปเพื่ออ่านรายละเอียดและเลือกจำนวน การเลือกจำนวนในหน้านี้ยังไม่เพิ่มไอเทม รายการที่ GaC รองรับจัดการได้ในแท็บกระเป๋า ส่วนรายการอื่นใช้ดูข้อมูลเท่านั้น',
|
||||
en: 'Search by name or ID, then click an image to read details and select a quantity. Selecting a quantity here does not add the item. GaC-supported entries can be managed in the inventory tab; other entries are for browsing.',
|
||||
'zh-CN': '按名称或 ID 搜索,然后点击图片查看详情并选择数量。在此选择数量不会添加物品。GaC 支持的条目可在背包页管理,其他条目仅供查看。',
|
||||
'zh-TW': '依名稱或 ID 搜尋,再點擊圖片查看詳情並選擇數量。在此選擇數量不會新增物品。GaC 支援的項目可在背包頁管理,其他項目僅供瀏覽。',
|
||||
ja: '名前または ID で検索し、画像をクリックすると詳細を確認して数量を選べます。この画面で数量を選んでもアイテムは追加されません。GaC 対応アイテムは所持品タブで管理でき、その他は閲覧のみです。',
|
||||
ko: '이름 또는 ID를 검색한 뒤 이미지를 클릭하면 상세 정보를 확인하고 수량을 선택할 수 있습니다. 여기서 수량을 선택해도 아이템은 추가되지 않습니다. GaC 지원 항목은 인벤토리 탭에서 관리할 수 있고 나머지는 조회 전용입니다.',
|
||||
vi: 'Tìm theo tên hoặc ID rồi nhấn vào hình để xem chi tiết và chọn số lượng. Việc chọn số lượng ở đây không thêm vật phẩm. Vật phẩm được GaC hỗ trợ có thể quản lý trong tab túi đồ; các mục khác chỉ để tra cứu.',
|
||||
id: 'Cari berdasarkan nama atau ID, lalu klik gambar untuk melihat detail dan memilih jumlah. Memilih jumlah di sini tidak menambahkan item. Item yang didukung GaC dapat dikelola di tab inventaris; item lainnya hanya untuk dilihat.',
|
||||
es: 'Busca por nombre o ID y haz clic en una imagen para consultar los detalles y elegir una cantidad. Elegir una cantidad aquí no añade el objeto. Los elementos compatibles con GaC se gestionan en la pestaña de inventario; los demás son solo de consulta.',
|
||||
fr: 'Recherchez un nom ou un ID, puis cliquez sur une image pour consulter les détails et choisir une quantité. Ce choix n’ajoute pas l’objet. Les éléments compatibles avec GaC se gèrent dans l’inventaire ; les autres sont consultables uniquement.',
|
||||
de: 'Suche nach Name oder ID und klicke auf ein Bild, um Details anzuzeigen und eine Menge auszuwählen. Dadurch wird der Gegenstand nicht hinzugefügt. GaC-unterstützte Einträge lassen sich im Inventar verwalten; die übrigen dienen nur zum Nachschlagen.',
|
||||
ru: 'Найдите предмет по названию или ID, затем нажмите на изображение, чтобы посмотреть сведения и выбрать количество. Выбор количества здесь не добавляет предмет. Предметы с поддержкой GaC можно менять во вкладке инвентаря; остальные доступны только для просмотра.',
|
||||
pt: 'Pesquise por nome ou ID e clique na imagem para ver os detalhes e escolher uma quantidade. Escolher a quantidade aqui não adiciona o item. Itens compatíveis com GaC podem ser gerenciados na aba de inventário; os demais servem apenas para consulta.'
|
||||
};
|
||||
for (const [language, description] of Object.entries(ITEM_CATALOG_INTERACTION_DESCRIPTIONS)) {
|
||||
TRANSLATIONS[language].itemCatalogDescription = description;
|
||||
}
|
||||
|
||||
let currentLang = 'th';
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
currentLang = localStorage.getItem('gac_srtool_lang') || 'th';
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.8';
|
||||
|
||||
const CATALOG_URL = new URL('../assets/item-catalog.json?v=2', import.meta.url);
|
||||
const PAGE_SIZE = 48;
|
||||
let catalog = null;
|
||||
let catalogLoading = null;
|
||||
let page = 0;
|
||||
let activeDetailId = null;
|
||||
|
||||
const elements = () => ({
|
||||
tab: document.querySelector('.tab-btn[data-tab="tab-inventory"]'),
|
||||
browseButton: document.getElementById('btn-inventory-catalog'),
|
||||
manageButton: document.getElementById('btn-inventory-manage'),
|
||||
catalogPanel: document.getElementById('item-catalog-panel'),
|
||||
managePanel: document.getElementById('inventory-manage-panel'),
|
||||
search: document.getElementById('item-catalog-search'),
|
||||
filter: document.getElementById('item-catalog-filter'),
|
||||
grid: document.getElementById('item-catalog-grid'),
|
||||
status: document.getElementById('item-catalog-status'),
|
||||
count: document.getElementById('item-catalog-count'),
|
||||
pageLabel: document.getElementById('item-catalog-page-label'),
|
||||
previous: document.getElementById('item-catalog-prev'),
|
||||
next: document.getElementById('item-catalog-next'),
|
||||
detail: document.getElementById('item-catalog-detail'),
|
||||
detailArt: document.getElementById('item-catalog-detail-art'),
|
||||
detailName: document.getElementById('item-catalog-detail-name'),
|
||||
detailId: document.getElementById('item-catalog-detail-id'),
|
||||
detailSource: document.getElementById('item-catalog-detail-source'),
|
||||
detailMeta: document.getElementById('item-catalog-detail-meta'),
|
||||
detailDescription: document.getElementById('item-catalog-detail-description'),
|
||||
detailLore: document.getElementById('item-catalog-detail-lore'),
|
||||
detailLoreSection: document.getElementById('item-catalog-detail-lore-section'),
|
||||
amount: document.getElementById('item-catalog-detail-amount'),
|
||||
amountLimit: document.getElementById('item-catalog-amount-limit')
|
||||
});
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function plainGameText(value) {
|
||||
return String(value ?? '').replace(/<[^>]*>/g, '').replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\t/g, ' ').trim();
|
||||
}
|
||||
|
||||
function displayName(item) {
|
||||
const names = item.names || {};
|
||||
return plainGameText(names[getCurrentLanguage()] || names.en || t('itemCatalogUnknownName', { id: item.id }));
|
||||
}
|
||||
|
||||
function assetUrl(path, base) {
|
||||
if (typeof path !== 'string' || !/^(icon|image)\/[A-Za-z0-9_./-]+\.png$/.test(path) || path.includes('..')) return '';
|
||||
try { return new URL(path, base).href; } catch { return ''; }
|
||||
}
|
||||
|
||||
function itemCategoryLabel(category) {
|
||||
const key = {
|
||||
item: 'itemCatalogFilterItems',
|
||||
lightcone: 'itemCatalogFilterLightCones',
|
||||
relic: 'itemCatalogFilterRelics'
|
||||
}[category];
|
||||
return key ? t(key) : t('itemCatalogFilterItems');
|
||||
}
|
||||
|
||||
function visibleItems() {
|
||||
if (!catalog) return [];
|
||||
const query = String(elements().search?.value || '').trim().toLocaleLowerCase();
|
||||
const category = elements().filter?.value || 'all';
|
||||
return catalog.items.filter(item => {
|
||||
if (category === 'supported' && !item.supported) return false;
|
||||
if (category !== 'all' && category !== 'supported' && item.category !== category) return false;
|
||||
if (!query) return true;
|
||||
if (String(item.id).includes(query)) return true;
|
||||
return Object.values(item.names || {}).some(name => plainGameText(name).toLocaleLowerCase().includes(query));
|
||||
});
|
||||
}
|
||||
|
||||
function imageMarkup(item, source) {
|
||||
const src = assetUrl(item.icon, source.assetBaseUrl);
|
||||
if (!src) {
|
||||
return '<button type="button" class="item-catalog-art-button" data-item-catalog-id="' + item.id + '" aria-label="' + escapeHtml(t('itemCatalogOpenDetails', { name: displayName(item) })) + '"><span class="item-catalog-art item-catalog-art-missing" title="' + escapeHtml(t('itemCatalogNoImage')) + '"><span aria-hidden="true">✦</span><small>' + escapeHtml(t('itemCatalogNoImage')) + '</small></span></button>';
|
||||
}
|
||||
const fallback = assetUrl(item.icon, source.assetFallbackBaseUrl);
|
||||
return '<button type="button" class="item-catalog-art-button" data-item-catalog-id="' + item.id + '" aria-label="' + escapeHtml(t('itemCatalogOpenDetails', { name: displayName(item) })) + '"><span class="item-catalog-art"><img class="item-catalog-image" src="' + escapeHtml(src) + '" data-fallback="' + escapeHtml(fallback) + '" alt="" loading="lazy" decoding="async"></span></button>';
|
||||
}
|
||||
|
||||
function localizedItemText(item, field) {
|
||||
const values = item[field] || {};
|
||||
return values[getCurrentLanguage()] || values.en || '';
|
||||
}
|
||||
|
||||
function openItemDetails(itemId) {
|
||||
const el = elements();
|
||||
const item = catalog?.items.find(row => row.id === Number(itemId));
|
||||
if (!item || !el.detail) return;
|
||||
activeDetailId = item.id;
|
||||
const name = displayName(item);
|
||||
const src = assetUrl(item.icon, catalog.source.assetBaseUrl);
|
||||
const fallback = assetUrl(item.icon, catalog.source.assetFallbackBaseUrl);
|
||||
const description = localizedItemText(item, 'descriptions');
|
||||
const lore = localizedItemText(item, 'backgroundDescriptions');
|
||||
const rarity = Number(item.rarity);
|
||||
const rarityText = Number.isInteger(rarity) && rarity > 0 && rarity <= 5 ? '★'.repeat(rarity) : '';
|
||||
const category = itemCategoryLabel(item.category);
|
||||
const type = [item.type, item.subType].filter((value, index, values) => value && values.indexOf(value) === index).join(' · ');
|
||||
const path = item.path ? translatePath(item.path) : '';
|
||||
const slot = item.slot ? translatePath(item.slot) : '';
|
||||
const meta = [category, type, slot, path, rarityText].filter(Boolean).join(' · ');
|
||||
|
||||
el.detailName.textContent = name;
|
||||
el.detailId.textContent = t('itemCatalogItemId', { id: item.id });
|
||||
el.detailSource.textContent = (item.sourceTables || []).map(path => path.split('/').pop()).join(', ') || t('itemCatalogNoDetails');
|
||||
el.detailMeta.textContent = meta || t('itemCatalogNoDetails');
|
||||
el.detailMeta.dataset.rarity = rarityText ? String(rarity) : '';
|
||||
el.detailDescription.textContent = plainGameText(description || lore || t('itemCatalogNoDescription'));
|
||||
el.detailLoreSection.hidden = !description || !lore || description === lore;
|
||||
el.detailLore.textContent = el.detailLoreSection.hidden ? '' : plainGameText(lore);
|
||||
if (src) {
|
||||
el.detailArt.innerHTML = '<img src="' + escapeHtml(src) + '" data-fallback="' + escapeHtml(fallback) + '" alt="' + escapeHtml(name) + '">';
|
||||
const image = el.detailArt.querySelector('img');
|
||||
image.addEventListener('error', () => {
|
||||
if (image.dataset.fallback && !image.dataset.fallbackTried) {
|
||||
image.dataset.fallbackTried = 'true';
|
||||
image.src = image.dataset.fallback;
|
||||
} else {
|
||||
el.detailArt.textContent = t('itemCatalogNoImage');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
el.detailArt.textContent = t('itemCatalogNoImage');
|
||||
}
|
||||
const limit = Number(item.pileLimit);
|
||||
const hasLimit = Number.isSafeInteger(limit) && limit > 0;
|
||||
el.amount.min = '1';
|
||||
if (hasLimit) {
|
||||
el.amount.max = String(limit);
|
||||
el.amountLimit.textContent = t('itemCatalogMaxQuantity', { count: limit.toLocaleString() });
|
||||
} else {
|
||||
el.amount.removeAttribute('max');
|
||||
el.amountLimit.textContent = '';
|
||||
}
|
||||
el.amount.value = '1';
|
||||
if (!el.detail.open) el.detail.showModal();
|
||||
}
|
||||
|
||||
function updateSelectedAmount(delta = 0) {
|
||||
const input = elements().amount;
|
||||
if (!input) return;
|
||||
const max = Number(input.max);
|
||||
const hasMax = Number.isSafeInteger(max) && max > 0;
|
||||
const current = Number.parseInt(input.value, 10);
|
||||
const next = Math.max(1, (Number.isFinite(current) ? current : 1) + delta);
|
||||
input.value = String(hasMax ? Math.min(max, next) : next);
|
||||
}
|
||||
|
||||
function renderCatalog() {
|
||||
const el = elements();
|
||||
if (!el.grid || !catalog) return;
|
||||
el.filter.setAttribute('aria-label', t('itemCatalogFilterLabel'));
|
||||
const filtered = visibleItems();
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
page = Math.min(page, pages - 1);
|
||||
const start = page * PAGE_SIZE;
|
||||
const rows = filtered.slice(start, start + PAGE_SIZE);
|
||||
|
||||
el.count.textContent = t('inventoryItemTotal', { count: filtered.length });
|
||||
el.pageLabel.textContent = t('inventoryPage', { page: page + 1, pages, count: filtered.length });
|
||||
el.previous.disabled = page === 0;
|
||||
el.next.disabled = page >= pages - 1;
|
||||
|
||||
if (!rows.length) {
|
||||
el.grid.innerHTML = '<div class="item-catalog-empty">' + escapeHtml(t('inventoryNoMatch')) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.grid.innerHTML = rows.map(item => {
|
||||
const name = displayName(item);
|
||||
const category = itemCategoryLabel(item.category);
|
||||
const subtype = item.slot || item.subType || item.type || '';
|
||||
const path = item.path ? translatePath(item.path) : '';
|
||||
const rarity = Number(item.rarity);
|
||||
const rarityText = Number.isInteger(rarity) && rarity > 0 && rarity <= 5 ? '★'.repeat(rarity) : '';
|
||||
const details = [category, subtype, path, rarityText].filter(Boolean).join(' · ');
|
||||
const supportText = item.supported ? t('itemCatalogSupportedTag') : t('itemCatalogBrowseOnlyTag');
|
||||
return '<article class="item-catalog-card">' +
|
||||
imageMarkup(item, catalog.source) +
|
||||
'<div class="item-catalog-info">' +
|
||||
'<div class="item-catalog-name" title="' + escapeHtml(name) + '">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="item-catalog-id">ID ' + item.id + '</div>' +
|
||||
'<div class="item-catalog-details" title="' + escapeHtml(details) + '">' + escapeHtml(details) + '</div>' +
|
||||
'<span class="item-catalog-support ' + (item.supported ? 'is-supported' : 'is-browse-only') + '">' + escapeHtml(supportText) + '</span>' +
|
||||
'</div>' +
|
||||
'</article>';
|
||||
}).join('');
|
||||
|
||||
el.grid.querySelectorAll('.item-catalog-image').forEach(image => {
|
||||
image.addEventListener('error', () => {
|
||||
if (image.dataset.fallback && !image.dataset.fallbackTried) {
|
||||
image.dataset.fallbackTried = 'true';
|
||||
image.src = image.dataset.fallback;
|
||||
return;
|
||||
}
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'item-catalog-art item-catalog-art-missing';
|
||||
placeholder.title = t('itemCatalogNoImage');
|
||||
const mark = document.createElement('span');
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
mark.textContent = '✦';
|
||||
const label = document.createElement('small');
|
||||
label.textContent = t('itemCatalogNoImage');
|
||||
placeholder.append(mark, label);
|
||||
image.parentElement.replaceWith(placeholder);
|
||||
});
|
||||
});
|
||||
el.grid.querySelectorAll('[data-item-catalog-id]').forEach(button => {
|
||||
button.addEventListener('click', () => openItemDetails(button.dataset.itemCatalogId));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
const el = elements();
|
||||
if (catalog) return catalog;
|
||||
if (catalogLoading) return catalogLoading;
|
||||
el.status.textContent = t('itemCatalogLoading');
|
||||
catalogLoading = fetch(CATALOG_URL).then(response => {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
}).then(data => {
|
||||
if (!Array.isArray(data.items) || !data.source?.assetBaseUrl) throw new Error('Invalid catalog data');
|
||||
catalog = data;
|
||||
el.status.textContent = t('inventoryItemTotal', { count: data.counts.total });
|
||||
renderCatalog();
|
||||
return catalog;
|
||||
}).catch(error => {
|
||||
el.status.textContent = t('itemCatalogLoadFailed') + ': ' + error.message;
|
||||
el.grid.innerHTML = '<div class="item-catalog-empty">' + escapeHtml(t('itemCatalogLoadFailed')) + '</div>';
|
||||
catalogLoading = null;
|
||||
throw error;
|
||||
});
|
||||
return catalogLoading;
|
||||
}
|
||||
|
||||
function setInventoryView(view) {
|
||||
const el = elements();
|
||||
const showCatalog = view === 'catalog';
|
||||
el.catalogPanel.hidden = !showCatalog;
|
||||
el.managePanel.hidden = showCatalog;
|
||||
el.browseButton.classList.toggle('active', showCatalog);
|
||||
el.manageButton.classList.toggle('active', !showCatalog);
|
||||
el.browseButton.setAttribute('aria-selected', String(showCatalog));
|
||||
el.manageButton.setAttribute('aria-selected', String(!showCatalog));
|
||||
if (showCatalog) loadCatalog().catch(() => {});
|
||||
}
|
||||
|
||||
export function initializeItemCatalog() {
|
||||
const el = elements();
|
||||
el.search?.addEventListener('input', () => { page = 0; renderCatalog(); });
|
||||
el.filter?.addEventListener('change', () => { page = 0; renderCatalog(); });
|
||||
el.previous?.addEventListener('click', () => { page = Math.max(0, page - 1); renderCatalog(); });
|
||||
el.next?.addEventListener('click', () => { page += 1; renderCatalog(); });
|
||||
document.getElementById('item-catalog-detail-close')?.addEventListener('click', () => el.detail?.close());
|
||||
document.getElementById('item-catalog-detail-done')?.addEventListener('click', () => el.detail?.close());
|
||||
el.detail?.addEventListener('close', () => { activeDetailId = null; });
|
||||
document.getElementById('item-catalog-amount-minus')?.addEventListener('click', () => updateSelectedAmount(-1));
|
||||
document.getElementById('item-catalog-amount-plus')?.addEventListener('click', () => updateSelectedAmount(1));
|
||||
el.amount?.addEventListener('change', () => {
|
||||
const value = Number.parseInt(el.amount.value, 10);
|
||||
const max = Number(el.amount.max);
|
||||
el.amount.value = String(Math.max(1, Number.isFinite(value) ? (Number.isSafeInteger(max) && max > 0 ? Math.min(max, value) : value) : 1));
|
||||
});
|
||||
el.detail?.addEventListener('click', event => {
|
||||
if (event.target === el.detail) el.detail.close();
|
||||
});
|
||||
el.tab?.addEventListener('click', () => { if (!el.catalogPanel.hidden) loadCatalog().catch(() => {}); });
|
||||
el.browseButton?.addEventListener('click', () => setInventoryView('catalog'));
|
||||
el.manageButton?.addEventListener('click', () => setInventoryView('manage'));
|
||||
document.getElementById('lang-select')?.addEventListener('change', () => {
|
||||
if (catalog) renderCatalog();
|
||||
else if (el.status) el.status.textContent = t('itemCatalogLoading');
|
||||
if (catalog && el.detail?.open && activeDetailId !== null) openItemDetails(activeDetailId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getCurrentLanguage } from './i18n.js?v=2.8';
|
||||
|
||||
const strings = {
|
||||
en: {
|
||||
button: 'Unstuck',
|
||||
description: 'Clear a stuck loading spinner without restarting the game. Keeps saved CW progress.',
|
||||
offline: 'Connect to the GaC server first.',
|
||||
queued: 'Recovery queued. Waiting for the game…',
|
||||
completed: 'The game cleared its network wait. Try closing or retrying the page.',
|
||||
delayed: 'No acknowledgement from the game yet. Recovery is still queued; the game must be connected.',
|
||||
failed: 'The game could not clear the wait.',
|
||||
unsupported: 'This server does not support Unstuck yet.'
|
||||
},
|
||||
th: {
|
||||
button: 'Unstuck / แก้ค้าง',
|
||||
description: 'ปลดตัวหมุนค้างโดยไม่ต้องออกเกม และเก็บเซฟรอบ CW ไว้',
|
||||
offline: 'เชื่อมต่อเซิร์ฟเวอร์ GaC ก่อน',
|
||||
queued: 'ส่งคำสั่งแล้ว กำลังรอเกมตอบกลับ…',
|
||||
completed: 'เกมปลดการรอแพ็กเก็ตแล้ว ลองปิดหน้าต่างหรือกดใหม่ได้',
|
||||
delayed: 'เกมยังไม่ตอบรับ คำสั่งยังอยู่ในคิวและต้องเชื่อมต่อเกมไว้',
|
||||
failed: 'เกมปลดการรอไม่สำเร็จ',
|
||||
unsupported: 'เซิร์ฟเวอร์นี้ยังไม่รองรับ Unstuck'
|
||||
}
|
||||
};
|
||||
|
||||
export function initializeUnstuck({ apiCall, isConnected, showToast }) {
|
||||
const button = document.getElementById('btn-client-unstuck');
|
||||
const description = document.getElementById('client-unstuck-description');
|
||||
const status = document.getElementById('client-unstuck-status');
|
||||
if (!button) return;
|
||||
const text = () => strings[getCurrentLanguage()] || strings.en;
|
||||
const translate = () => {
|
||||
button.textContent = text().button;
|
||||
if (description) description.textContent = text().description;
|
||||
};
|
||||
translate();
|
||||
document.getElementById('lang-select')?.addEventListener('change', translate);
|
||||
button.addEventListener('click', async () => {
|
||||
if (!isConnected()) { showToast(text().offline, 'error'); return; }
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await apiCall('/api/client/unstuck', 'POST', {});
|
||||
if (!result?.request_id || result.state !== 'queued') throw new Error(text().unsupported);
|
||||
if (status) status.textContent = text().queued;
|
||||
for (let attempt = 0; attempt < 16; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 750));
|
||||
const acknowledgement = await apiCall('/api/client/unstuck');
|
||||
if (acknowledgement?.request_id !== result.request_id) continue;
|
||||
if (acknowledgement.state === 'completed') {
|
||||
if (status) status.textContent = text().completed;
|
||||
showToast(text().completed, 'success');
|
||||
return;
|
||||
}
|
||||
if (acknowledgement.state === 'failed') {
|
||||
throw new Error(acknowledgement.error || text().failed);
|
||||
}
|
||||
}
|
||||
if (status) status.textContent = text().delayed;
|
||||
} catch (error) {
|
||||
if (status) status.textContent = error.message;
|
||||
showToast(error.message, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user