From 76986eed4a24cd08e5a1f2c3b8f3cdfbf8dfbfce Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Sun, 1 Nov 2026 14:49:56 +0700 Subject: [PATCH] fix(showcase): responsive card, full localization, in-game stat icons and clean PNG export Layout: card now fills its container (3/2/1 columns) with no horizontal scrollbar. i18n: every modal/showcase/console string follows the selected language, named fallbacks for th/en plus English fallback for other locales, localized character names and light-cone passive text. Showcase stats use the official in-game property icons (same set as the overview tab) instead of emoji, including the element DMG icon. Export: fixed 1200px clone with poster metrics, cover-cropped splash art (html2canvas ignores object-fit), transparent background so the rounded corners have no dark wedges, and a two-pass measurement inside the capture iframe so the PNG is never cropped. --- css/styles.css | 76 +++++- index.html | 24 +- js/app.js | 530 +++++++++++++++++++++++++++------------ js/avatars-data.js | 23 +- js/command-assistant.mjs | 92 ++++++- js/equipment-data.js | 6 +- js/i18n.js | 207 +++++++++++++++ js/item-catalog.js | 2 +- js/unstuck.js | 2 +- 9 files changed, 775 insertions(+), 187 deletions(-) diff --git a/css/styles.css b/css/styles.css index 5e89794..ce42390 100644 --- a/css/styles.css +++ b/css/styles.css @@ -15,6 +15,7 @@ --accent-gold: #fbbf24; --accent-purple: #c084fc; --accent-red: #f87171; + --accent-blue: #0284c7; --glow-cyan: 0 0 20px rgba(56, 189, 248, 0.35); --glow-gold: 0 0 20px rgba(251, 191, 36, 0.35); @@ -1556,10 +1557,11 @@ input:checked + .slider:before { Modal Avatar Detail Widescreen Expansion ========================================================================== */ #modal-avatar-detail .modal-content { - max-width: 1120px !important; - width: 96% !important; + max-width: min(1280px, 96vw) !important; + width: 96vw !important; max-height: 92vh !important; overflow-y: auto !important; + overflow-x: hidden !important; } /* ========================================================================== @@ -1840,6 +1842,23 @@ input:checked + .slider:before { gap: 0.35rem; } +.showcase-stat-icon { + width: 15px; + height: 15px; + min-width: 15px; + object-fit: contain; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55)); +} + +.showcase-inline-stat { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + .showcase-stat-val { font-weight: 800; font-family: monospace; @@ -3760,18 +3779,59 @@ input:checked + .slider:before { .console-suggestion-description { max-width:55%; } @media(max-width:640px) { .console-suggestion-option { flex-wrap:wrap; gap:.2rem .5rem; } .console-suggestion-description { display:block; width:100%; max-width:100%; margin-left:1.1rem; } } -/* การ์ดกว้างตามเนื้อหาจริง ไม่ล็อก 1050px และไม่ตัดขอบ */ +/* การ์ดยืดหยุ่นตามพื้นที่จริง ไม่ล็อกความกว้างและไม่เกิด scrollbar แนวนอน */ .showcase-card-layout { - width: max-content; /* เดิม: width/min-width 1050px */ + width: 100%; + max-width: 1200px; min-width: 0; + margin: 0 auto; overflow: visible; /* เดิม: hidden ทำให้ส่วนเกินโดนตัด */ - grid-template-columns: 360px 340px max-content; + grid-template-columns: minmax(230px, 360px) minmax(210px, 340px) minmax(240px, 1fr); gap: 1.25rem; padding: 1.5rem; } -.showcase-splash-col { width: 360px; } +.showcase-scroll-wrapper { + overflow-x: visible; + max-width: 100%; + padding-bottom: 0.5rem; +} -/* คอลัมน์รีลิกส์ขยายตามชื่อเซ็ตและ main stat ที่ยาวที่สุด */ -.showcase-relics-col { min-width: 420px; } +.showcase-splash-col { width: 100%; min-width: 0; } +.showcase-center-col { min-width: 0; } +.showcase-relics-col { min-width: 0; } .showcase-relic-card:hover { transform: none; } + +/* Export clone: always use the wide three-column poster layout so a PNG keeps + the same composition regardless of the window size it was exported from. */ +.showcase-card-layout.showcase-export-mode { + width: 1200px !important; + max-width: none !important; + margin: 0 !important; + /* Pin the poster metrics so the exported layout does not inherit the + responsive (2-column / mobile) spacing of the current window size. */ + padding: 1.5rem !important; + gap: 1.25rem !important; + grid-template-columns: 360px 340px minmax(240px, 1fr) !important; +} +.showcase-card-layout.showcase-export-mode .showcase-splash-col { + grid-column: auto !important; + max-width: none !important; + width: 100% !important; +} + +@media (max-width: 1100px) { + .showcase-card-layout { + grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); + } + .showcase-splash-col { grid-column: 1 / -1; max-width: 420px; } +} + +@media (max-width: 720px) { + .showcase-card-layout { + grid-template-columns: 1fr; + gap: 1rem; + padding: 1rem; + } + .showcase-splash-col { max-width: none; } +} diff --git a/index.html b/index.html index 0132307..90ad229 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ GaCy-Srtool - + @@ -273,7 +273,7 @@
COMMAND PREVIEW
- พิมพ์ / เพื่อเลือกคำสั่ง + พิมพ์ / เพื่อเลือกคำสั่ง
@@ -285,7 +285,7 @@
- ↑ ↓ เลื่อนรายการ · Tab เติมค่า · Shift+Tab ย้อนรายการ · Esc ปิด · Enter ส่งคำสั่ง + ↑ ↓ เลื่อนรายการ · Tab เติมค่า · Shift+Tab ย้อนรายการ · Esc ปิด · Enter ส่งคำสั่ง ● Server Online
@@ -680,12 +680,12 @@
Freecam / FaceCam
-
เลือกโหมด แล้วกด Tab ในเกมเพื่อเปิดหรือปิดกล้อง
- - - +
เลือกโหมด แล้วกด Tab ในเกมเพื่อเปิดหรือปิดกล้อง
+ + + - +
@@ -1401,7 +1401,7 @@
🖼️ การ์ดแสดงผล - Build Overview + Build Overview
- -
+ +
@@ -1823,6 +1823,6 @@
- + diff --git a/js/app.js b/js/app.js index bf2272e..f88997d 100644 --- a/js/app.js +++ b/js/app.js @@ -1,10 +1,10 @@ -import { COMMANDS, usage, inspectCommand, completeCommand } from './command-assistant.mjs'; +import { COMMANDS, usage, inspectCommand, completeCommand, setCommandLanguage, describeCommand } 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.13'; +import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.16'; import { getAvatarIconUrl, getLightconeArtUrl, @@ -551,6 +551,13 @@ function getElementIconHtml(element, size = 20) { `; } +// Official in-game property icons (same set used by the overview tab) +const STAT_PROPERTY_ICON_CDN = 'https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/property'; +function getStatIconHtml(iconName, size = 15, alt = '') { + if (!iconName) return ''; + return `${alt}`; +} + // Render Path Icon HTML (Official Star Rail Game Icon) function getPathIconHtml(path, size = 20) { if (!path) return ''; @@ -941,6 +948,7 @@ async function loadCachedStorageData() { // Apply multi-language translations across UI function applyTranslations() { + setCommandLanguage(getCurrentLanguage()); document.querySelectorAll('[data-i18n]').forEach(elem => { const key = elem.getAttribute('data-i18n'); const val = t(key); @@ -999,10 +1007,59 @@ function applyTranslations() { } updateDashboardBattleBanner(); updateDocumentTitle(); + renderGMCommandPreview(); if (state.activeTab === 'tab-monster-settings') { renderMonsterSettings(); if (state.monsterSettings.activeMode === 'aha') loadAhaHelpSettings(); } + refreshOpenAvatarDetailModal(); +} + +// Re-localize the character detail modal while it is open so that names and +// dynamic content follow a language switch instead of keeping the old strings. +function refreshOpenAvatarDetailModal() { + const modal = document.getElementById('modal-avatar-detail'); + if (!modal || !modal.classList.contains('active') || !state.selectedAvatarDetail) return; + + const aid = state.selectedAvatarDetail.avatar_id; + const meta = getAvatarInfo(aid); + + if (el.modalCharTitle) el.modalCharTitle.textContent = `${t('modalTitleCharEdit')}: ${meta.name} (ID: ${aid})`; + if (el.modalCharName) el.modalCharName.textContent = meta.name; + if (el.modalCharMeta) { + el.modalCharMeta.innerHTML = ` + ${getElementIconHtml(meta.element, 24)} + ${getPathIconHtml(meta.path, 24)} + ★ ${meta.rarity} + `; + } + + renderModalSwitcher(aid); + + const activeBtn = document.querySelector('.modal-subtab-btn.active'); + const activeTab = activeBtn?.getAttribute('data-modaltab') || 'modaltab-overview'; + const activeContent = document.getElementById(activeTab); + const contentScroll = modal.querySelector('.modal-content')?.scrollTop ?? 0; + const tabScroll = activeContent?.scrollTop ?? 0; + + // Lightcone section lives outside the sub-tabs, so refresh it explicitly. + const equippedLc = (state.fullFreesrData?.lightcones || []).find(lc => lc.equip_avatar === aid); + const activeLcId = equippedLc?.item_id || SIGNATURE_LIGHTCONES[aid] || 21001; + populateModalChangeLcDropdown(meta.path); + updateModalLcFullView(activeLcId, selectedLcRank || equippedLc?.rank || 5, equippedLc?.level || 80); + + // Re-render every sub-tab so hidden tabs never keep strings from the old language. + renderModalOverviewTab(aid); + renderModalRelicsTab(aid); + renderModalSkillsTab(aid); + renderShowcaseCard(aid); + renderCharacterBuildGuide(aid); + + switchModalSubtab(activeTab); + + const modalContent = modal.querySelector('.modal-content'); + if (modalContent) modalContent.scrollTop = contentScroll; + if (activeContent) activeContent.scrollTop = tabScroll; } // Setup Language Selector @@ -1648,7 +1705,7 @@ function renderGMCommandPreview(model = consoleCommandModel()) { if (preview) preview.textContent = model.preview; if (hint) hint.textContent = model.hint; const description = document.getElementById('console-command-description'); - if (description) description.textContent = model.command?.description || 'เลือกคำสั่งเพื่อดูตัวอย่างและช่องที่ต้องใส่'; + if (description) description.textContent = model.command?.description || t('consoleDescriptionHint'); } function escapeHtml(str) { @@ -1796,7 +1853,7 @@ async function dispatchGMCommand(rawCmd) { // Handle local /help command immediately if (cmd.toLowerCase() === '/help') { logToConsole('Available GM commands:', 'SYS'); - COMMANDS.forEach(c => logToConsole(`${usage(c)} — ${c.description}`, 'SYS')); + COMMANDS.forEach(c => logToConsole(`${usage(c)} - ${describeCommand(c)}`, 'SYS')); return; } @@ -2867,7 +2924,11 @@ function updateLcRankPips(rank) { function getLightconePassiveDesc(lcId, rank = 5) { const id = parseInt(lcId, 10); const r = Math.max(1, Math.min(5, rank)); + const meta = LIGHTCONE_METADATA[id]; + // The rank-scaled descriptions below are authored in Thai. Other locales use + // the localized description shipped with the lightcone metadata instead. + if (getCurrentLanguage() === 'th') { const passives = { 21001: `ทำให้ DEF ของผู้สวมใส่เพิ่มขึ้น ${16 + (r - 1) * 2}% หลังจากเข้าสู่การต่อสู้ จะทำให้ความต้านทานทุกประเภทของตัวละครฝ่ายเราทั้งหมดเพิ่มขึ้น ${8 + (r - 1)}% โดยสกิลชนิดเดียวกันจะไม่สามารถซ้อนทับได้`, 23024: `ทำให้ CRIT DMG ของผู้สวมใส่เพิ่มขึ้น ${36 + (r - 1) * 6}% เมื่อโจมตีโดนเป้าหมาย จะติดสถานะ Mirage Fizzle ทำให้ได้รับความเสียหายเพิ่มขึ้น ${24 + (r - 1) * 4}% และความเสียหายท่าไม้ตายเพิ่มขึ้น ${24 + (r - 1) * 4}%`, @@ -2890,11 +2951,12 @@ function getLightconePassiveDesc(lcId, rank = 5) { }; if (passives[id]) return passives[id]; + } - const meta = LIGHTCONE_METADATA[id]; - if (meta?.desc) return meta.desc; - const pathName = meta ? translatePath(meta.path) : "สายต่อสู้"; - return `เพิ่มค่าสถานะพื้นฐานตามสาย ${pathName} และเพิ่มประสิทธิภาพความเสียหายและการสนับสนุน ${12 + (r - 1) * 3}% เมื่อเข้าสู่การต่อสู้`; + const metaDesc = meta?.desc; + if (metaDesc) return metaDesc; + const pathName = meta ? translatePath(meta.path) : translatePath('Unknown'); + return t('lcPassiveFallback', { path: pathName, value: 12 + (r - 1) * 3 }); } // Update Modal Lightcone Full Artwork and Details (Matching srtools / Firefly) @@ -2903,10 +2965,10 @@ function updateModalLcFullView(lcId, rank = 5, level = 80) { if (!id || !LIGHTCONE_METADATA[id]) { if (el.modalLcFullImg) el.modalLcFullImg.style.display = 'none'; if (el.modalLcNoArt) el.modalLcNoArt.style.display = 'block'; - if (el.modalLcNameDisplay) el.modalLcNameDisplay.textContent = 'ยังไม่ได้สวมใส่ Light Cone'; + if (el.modalLcNameDisplay) el.modalLcNameDisplay.textContent = t('lcNoneEquipped'); if (el.modalLcMetaDisplay) el.modalLcMetaDisplay.innerHTML = ''; if (el.modalLcRankBadge) el.modalLcRankBadge.textContent = '-'; - if (el.modalLcPassiveDesc) el.modalLcPassiveDesc.innerHTML = 'กรุณาเลือก Light Cone ที่ต้องการสวมใส่'; + if (el.modalLcPassiveDesc) el.modalLcPassiveDesc.innerHTML = t('lcSelectPrompt'); return; } @@ -2977,10 +3039,10 @@ function populateModalChangeLcDropdown(avatarPath) { }); const samePathGroup = document.createElement('optgroup'); - samePathGroup.label = `⭐ Light Cones สาย ${avatarPath}`; + samePathGroup.label = t('lcSamePathGroup', { path: avatarPath }); const otherPathGroup = document.createElement('optgroup'); - otherPathGroup.label = `🗡️ Light Cones สายอื่นๆ`; + otherPathGroup.label = t('lcOtherPathGroup'); allIds.forEach(id => { const meta = LIGHTCONE_METADATA[id]; @@ -3004,14 +3066,21 @@ function populateModalChangeLcDropdown(avatarPath) { // Slot full definitions for relics const SLOT_NAMES_FULL = { - 1: { name: "ส่วนหัว (Head)", icon: "🧢", defaultStat: "HP (Flat)" }, - 2: { name: "ส่วนมือ (Hands)", icon: "🧤", defaultStat: "ATK (Flat)" }, - 3: { name: "ส่วนลำตัว (Body)", icon: "🦺", defaultStat: "CRIT Rate / DMG" }, - 4: { name: "ส่วนเท้า (Feet)", icon: "👢", defaultStat: "SPD / ATK%" }, - 5: { name: "ลูกแก้ว (Planar Sphere)", icon: "🔮", defaultStat: "Elemental DMG%" }, - 6: { name: "สร้อยเชื่อม (Link Rope)", icon: "🔗", defaultStat: "ERR / Break / ATK%" } + 1: { nameKey: 'relicSlotHead', shortKey: 'slotHeadShort', icon: "🧢", defaultStat: "HP (Flat)" }, + 2: { nameKey: 'relicSlotHands', shortKey: 'slotHandsShort', icon: "🧤", defaultStat: "ATK (Flat)" }, + 3: { nameKey: 'relicSlotBody', shortKey: 'slotBodyShort', icon: "🦺", defaultStat: "CRIT Rate / DMG" }, + 4: { nameKey: 'relicSlotFeet', shortKey: 'slotFeetShort', icon: "👢", defaultStat: "SPD / ATK%" }, + 5: { nameKey: 'relicSlotSphere', shortKey: 'slotSphereShort', icon: "🔮", defaultStat: "Elemental DMG%" }, + 6: { nameKey: 'relicSlotRope', shortKey: 'slotRopeShort', icon: "🔗", defaultStat: "ERR / Break / ATK%" } }; +function getSlotInfo(slot) { + const info = SLOT_NAMES_FULL[slot]; + if (!info) return { name: `${t('slot')} ${slot}`, short: `${t('slot')} ${slot}`, icon: '🛡️', defaultStat: '' }; + const name = t(info.nameKey); + return { ...info, name, short: t(info.shortKey) }; +} + function getRelicMainAffixName(slot, affixId) { const affixesBySlot = { 1: { 1: "HP (Flat)" }, @@ -3120,9 +3189,15 @@ async function loadCharacterSkillsData(lang = null) { function formatSkillDesc(descTemplate, paramsArray, level) { if (!descTemplate) return ''; + // StarRailRes keeps gender-dependent wording as {M#...}{F#...} spans. Resolve + // them with the Trailblazer gender from the persistent settings panel so the + // description reads naturally instead of showing the raw markup. + const isFemaleTb = (document.getElementById('persistent-mc-select')?.value || '').startsWith('Female'); + let formatted = descTemplate + .replace(/\{M#([^{}]*)\}\{F#([^{}]*)\}/g, (_, male, female) => (isFemaleTb ? female : male)) + .replace(/\{[MF]#([^{}]*)\}/g, '$1'); const lvlIndex = Math.max(0, Math.min(level - 1, (paramsArray?.length || 1) - 1)); const lvlParams = paramsArray?.[lvlIndex] || []; - let formatted = descTemplate; lvlParams.forEach((param, idx) => { const pNum = idx + 1; const isPercent = formatted.includes(`#${pNum}[i]%`) || formatted.includes(`#${pNum}[f1]%`) || formatted.includes(`#${pNum}[f2]%`); @@ -3224,7 +3299,7 @@ function renderModalOverviewTab(avatarId) { if (el.overviewRelicsSummary) { if (activeSetBadges.length === 0) { - el.overviewRelicsSummary.innerHTML = `${relics.length}/6 ชิ้นสวมใส่ (ยังไม่มีโบนัสเซ็ต)`; + el.overviewRelicsSummary.innerHTML = `${t('overviewRelicCount', { count: relics.length })}`; } else { el.overviewRelicsSummary.innerHTML = activeSetBadges.map(b => `
@@ -3562,7 +3637,7 @@ function renderModalRelicsTab(avatarId) { }); for (let slot = 1; slot <= 6; slot++) { - const slotInfo = SLOT_NAMES_FULL[slot]; + const slotInfo = getSlotInfo(slot); const equipped = equippedRelics.find(r => (r.relic_id % 10) === slot); const box = document.createElement('div'); @@ -3578,14 +3653,14 @@ function renderModalRelicsTab(avatarId) { const rolls = s.count || s.cnt || 1; return `${sName} (+${rolls})`; }).join(' • ') - : '4 ออปชั่นรอง (คลิกเพื่อปรับแต่ง)'; + : t('relicSubstatFallback'); box.className = 'relic-slot-box equipped'; box.innerHTML = `
Relic - ${slotInfo.icon} ${slotInfo.name.split(' ')[0]} + ${slotInfo.icon} ${slotInfo.short}
@@ -3597,7 +3672,7 @@ function renderModalRelicsTab(avatarId) { ${subsSnippetHtml}
- + `; box.addEventListener('click', () => { @@ -3622,8 +3697,8 @@ function renderModalRelicsTab(avatarId) {
${slotInfo.name}
-
ยังไม่ได้สวมใส่
-
+ คลิกเพื่อเลือกหรือสร้าง (แนะนำ: ${slotInfo.defaultStat})
+
${t('relicSlotNotEquipped')}
+
${t('relicSlotCreateHint', { stat: slotInfo.defaultStat })}
`; @@ -3642,14 +3717,14 @@ function renderModalRelicsTab(avatarId) { const cnt = setCountMap[sId]; const sInfo = getRelicSetInfo(sId); if (cnt >= 4) { - activeSets.push(`🌟 ${sInfo.name} (4-Piece Set): ${sInfo.desc4 || 'เปิดใช้งานเอฟเฟกต์ครบ 4 ชิ้น'}`); + activeSets.push(`🌟 ${sInfo.name} (4-Piece Set): ${sInfo.desc4 || t('relicSet4Fallback')}`); } else if (cnt >= 2) { - activeSets.push(`✨ ${sInfo.name} (2-Piece Set): ${sInfo.desc2 || 'เปิดใช้งานเอฟเฟกต์ 2 ชิ้น'}`); + activeSets.push(`✨ ${sInfo.name} (2-Piece Set): ${sInfo.desc2 || t('relicSet2Fallback')}`); } }); if (activeSets.length === 0) { - el.modalRelicsActiveSets.innerHTML = `ยังไม่มีเอฟเฟกต์เซ็ตทำงาน (ต้องใส่เซ็ตเดียวกันอย่างน้อย 2 หรือ 4 ชิ้น)`; + el.modalRelicsActiveSets.innerHTML = `${t('relicSetNoBonus')}`; } else { el.modalRelicsActiveSets.innerHTML = activeSets.map(s => `
${s}
`).join(''); } @@ -3685,7 +3760,7 @@ function openSlotPicker(avatarId, slotNum, targetRelicToEdit = null) { const slot = state.slotPickerSlot; const meta = getAvatarInfo(aid); const rec = getOptimalBuildForAvatar(aid); - const slotInfo = SLOT_NAMES_FULL[slot] || { name: `Slot ${slot}` }; + const slotInfo = getSlotInfo(slot); const modal = document.getElementById('modal-select-relic-slot'); if (!modal) return; @@ -3699,15 +3774,15 @@ function openSlotPicker(avatarId, slotNum, targetRelicToEdit = null) { if (slotPickerTargetRelic) { const curSetId = Math.floor((slotPickerTargetRelic.relic_id % 10000) / 10); const curSetInfo = getRelicSetInfo(curSetId); - if (titleEl) titleEl.textContent = `🔧 แก้ไขรีลิกส์: ${curSetInfo.name} (${slotInfo.name}) - ${meta.name}`; - if (subtitleEl) subtitleEl.textContent = `ปรับแต่งเซ็ต สเตตัสหลัก และออฟรองของรีลิกส์ชิ้นนี้ (#${slotPickerTargetRelic.internal_uid})`; - if (editModeIndicator) editModeIndicator.innerHTML = `✏️ กำลังแก้ไขรีลิกส์ #${slotPickerTargetRelic.internal_uid}`; - if (btnConfirmCustom) btnConfirmCustom.textContent = `💾 บันทึกการแก้ไขออฟรองชิ้นนี้`; + if (titleEl) titleEl.textContent = t('relicPickerEditTitle', { set: curSetInfo.name, slot: slotInfo.name, name: meta.name }); + if (subtitleEl) subtitleEl.textContent = t('relicPickerEditSubtitle', { id: slotPickerTargetRelic.internal_uid }); + if (editModeIndicator) editModeIndicator.innerHTML = t('relicPickerEditing', { id: slotPickerTargetRelic.internal_uid }); + if (btnConfirmCustom) btnConfirmCustom.textContent = t('relicPickerSaveChanges'); } else { - if (titleEl) titleEl.textContent = `${t('relicPickerTitle').replace('{slot}', slotInfo.name)} - ${meta.name}`; + if (titleEl) titleEl.textContent = `${t('relicPickerTitle', { slot: slotInfo.name })} - ${meta.name}`; if (subtitleEl) subtitleEl.textContent = t('quickGenRecDesc'); if (editModeIndicator) editModeIndicator.textContent = ''; - if (btnConfirmCustom) btnConfirmCustom.textContent = `⚡ สวมใส่ชิ้นนี้ทันที (+15 5★ ออฟที่เลือก)`; + if (btnConfirmCustom) btnConfirmCustom.textContent = t('relicPickerEquipNow'); } // Current Equipped Relic Banner @@ -3737,7 +3812,7 @@ function openSlotPicker(avatarId, slotNum, targetRelicToEdit = null) {
- ${currentRelic.equip_avatar ? t('currentEquipped') : 'ชิ้นในคลัง'} (+${currentRelic.level || 15}) + ${currentRelic.equip_avatar ? t('currentEquipped') : t('relicPickerInInventory')} (+${currentRelic.level || 15}) ✦ ${curMainAffixName} @@ -3753,8 +3828,8 @@ function openSlotPicker(avatarId, slotNum, targetRelicToEdit = null) {
${!slotPickerTargetRelic ? ` - ` : ''} ${currentRelic.equip_avatar ? ` @@ -3834,7 +3909,7 @@ function openSlotPicker(avatarId, slotNum, targetRelicToEdit = null) { ✦ ${recMainName}
- ออฟรองแนะนำ 4 แถวเทพ ปรับแต่งได้ตามต้องการด้านล่าง + ${t('relicPickerRecommended')}
@@ -3957,23 +4032,23 @@ function renderSlotPickerSubstats(slot, mainAffixId) { const isChosenElsewhere = slotPickerSubstats.some((s, i) => i !== idx && s.sub_affix_id === stat.id); const isSelected = stat.id === sub.sub_affix_id; const disabledAttr = (isConflict || (isChosenElsewhere && !isSelected)) ? 'disabled' : ''; - const conflictTag = isConflict ? ' (ตรงกับ Main Stat)' : (isChosenElsewhere && !isSelected ? ' (เลือกแล้ว)' : ''); + const conflictTag = isConflict ? t('substatMainConflict') : (isChosenElsewhere && !isSelected ? t('substatAlreadyChosen') : ''); optsHtml += ``; }); card.innerHTML = `
- ออฟรอง ${idx + 1} - +${sVal} (${sub.count} โรล) + ${t('substatSlotLabel', { n: idx + 1 })} + ${t('substatRolls', { value: sVal, count: sub.count })}
- + - +
`; @@ -4024,7 +4099,7 @@ function updateSubstatCardPreview(idx) { const input = document.querySelector(`.substat-count-input[data-idx="${idx}"]`); if (badge) { const sVal = formatRelicSubstatValue(sub.sub_affix_id, sub.count); - badge.textContent = `+${sVal} (${sub.count} โรล)`; + badge.textContent = t('substatRolls', { value: sVal, count: sub.count }); } if (input) { input.value = sub.count; @@ -4231,9 +4306,9 @@ function renderSlotPickerInventoryGrid(aid, slot) { container.innerHTML = `
📦
-
ไม่มีรีลิกส์สำหรับช่องนี้ในคลัง
+
${t('relicEmptyInventoryTitle')}
- สามารถสลับไปที่แท็บ "⚡ สวมใส่ / สร้างชิ้นแนะนำ" เพื่อสร้างชิ้นใหม่ได้ทันที! + ${t('relicEmptyInventoryHint')}
`; @@ -4280,12 +4355,12 @@ function renderSlotPickerInventoryGrid(aid, slot) {
- ${isCurrent ? '(กำลังสวมใส่อยู่)' : - wearerMeta ? `(สวมใส่โดย: ${wearerMeta.name})` : - '(ว่างในคลัง)'} + ${isCurrent ? `(${t('relicEquippedNow')})` : + wearerMeta ? `(${t('relicWornBy', { name: wearerMeta.name })})` : + `(${t('relicFreeInInventory')})`}
`; @@ -4426,7 +4501,7 @@ function openLightconePicker(avatarId) { const titleEl = document.getElementById('modal-lc-picker-title'); if (titleEl) { - titleEl.textContent = `🗡️ เลือกสวมใส่กรวยแสง: ${meta.name}`; + titleEl.textContent = t('lcSelectTitle', { name: meta.name }); } const sigLcId = SIGNATURE_LIGHTCONES[aid]; @@ -4503,7 +4578,7 @@ function renderLightconePickerGrid() { }); if (filtered.length === 0) { - listEl.innerHTML = `
ไม่พบกรวยแสงตามเงื่อนไขที่ค้นหา
`; + listEl.innerHTML = `
${t('lcNoResults')}
`; return; } @@ -4518,9 +4593,9 @@ function renderLightconePickerGrid() { let badgeHtml = ''; if (isCurrent) { - badgeHtml = `สวมใส่อยู่`; + badgeHtml = `${t('lcEquippedBadge')}`; } else if (isSig) { - badgeHtml = `ประจำตัว`; + badgeHtml = `${t('lcSignatureBadge')}`; } card.innerHTML = ` @@ -4722,6 +4797,10 @@ async function renderModalSkillsTab(avatarId) { const { skills, trees } = await loadCharacterSkillsData(currentLang); + // A newer language switch may have started another render while this one was + // still fetching; drop the stale result so the tab never keeps the old language. + if ((getCurrentLanguage() || 'th') !== currentLang) return; + const avatarSkills = Object.values(skills || {}).filter(s => String(s.id).startsWith(aid)); const basicSkill = avatarSkills.find(s => s.type === 'Normal') || avatarSkills[0]; const combatSkill = avatarSkills.find(s => s.type === 'BPSkill') || avatarSkills[1]; @@ -4775,17 +4854,17 @@ async function renderModalSkillsTab(avatarId) { // Traces if (a2Node) { if (el.traceIconA2) el.traceIconA2.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${a2Node.icon}`; - if (el.traceNameA2) el.traceNameA2.textContent = `🌟 ${a2Node.name || 'ร่องรอย A2'}`; + if (el.traceNameA2) el.traceNameA2.textContent = `🌟 ${a2Node.name || t('traceFallbackA2')}`; if (el.traceDescA2) el.traceDescA2.innerHTML = formatSkillDesc(a2Node.desc, a2Node.params, 1); } if (a4Node) { if (el.traceIconA4) el.traceIconA4.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${a4Node.icon}`; - if (el.traceNameA4) el.traceNameA4.textContent = `🌟 ${a4Node.name || 'ร่องรอย A4'}`; + if (el.traceNameA4) el.traceNameA4.textContent = `🌟 ${a4Node.name || t('traceFallbackA4')}`; if (el.traceDescA4) el.traceDescA4.innerHTML = formatSkillDesc(a4Node.desc, a4Node.params, 1); } if (a6Node) { if (el.traceIconA6) el.traceIconA6.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${a6Node.icon}`; - if (el.traceNameA6) el.traceNameA6.textContent = `🌟 ${a6Node.name || 'ร่องรอย A6'}`; + if (el.traceNameA6) el.traceNameA6.textContent = `🌟 ${a6Node.name || t('traceFallbackA6')}`; if (el.traceDescA6) el.traceDescA6.innerHTML = formatSkillDesc(a6Node.desc, a6Node.params, 1); } } @@ -5299,7 +5378,7 @@ function renderShowcaseCard(avatarId) { activeSetBadges.forEach(sb => { setsHtml += `
- ${sb.name} + ${sb.name} ${sb.count}-PC
`; @@ -5307,12 +5386,12 @@ function renderShowcaseCard(avatarId) { // Relic cards (Slots 1 to 6) const slotNames = { - 1: { name: "ส่วนหัว", stat: "HP 706" }, - 2: { name: "ส่วนมือ", stat: "ATK 353" }, - 3: { name: "ลำตัว", stat: canonPath === 'Preservation' ? "DEF 54.0%" : (canonPath === 'Hunt' ? "CRIT Rate 32.4%" : "HP 43.2%") }, - 4: { name: "รองเท้า", stat: "SPD 25.0" }, - 5: { name: "ลูกแก้ว", stat: `${translateElement(canonElem)} 38.8%` }, - 6: { name: "สร้อย", stat: canonPath === 'Preservation' || canonPath === 'Harmony' ? "ERR 19.4%" : "ATK 43.2%" } + 1: { name: t('slotHeadShort'), stat: "HP 706" }, + 2: { name: t('slotHandsShort'), stat: "ATK 353" }, + 3: { name: t('slotBodyShort'), stat: canonPath === 'Preservation' ? "DEF 54.0%" : (canonPath === 'Hunt' ? "CRIT Rate 32.4%" : "HP 43.2%") }, + 4: { name: t('slotFeetShort'), stat: "SPD 25.0" }, + 5: { name: t('slotSphereShort'), stat: `${translateElement(canonElem)} 38.8%` }, + 6: { name: t('slotRopeShort'), stat: canonPath === 'Preservation' || canonPath === 'Harmony' ? "ERR 19.4%" : "ATK 43.2%" } }; const showRolls = localStorage.getItem('gac_srtool_showcase_rolls') !== 'false'; @@ -5357,7 +5436,7 @@ function renderShowcaseCard(avatarId) {
- ${rInfo.name} + ${rInfo.name} ✦ ${mainStatDisplay}
@@ -5372,6 +5451,12 @@ function renderShowcaseCard(avatarId) { const fallbackPortrait = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/character_portrait/${aid}.png`; const lcArtThumb = getLightconeArtSrc(lcId); + // Use the same official property icons as the character overview tab. + const elemStatIconName = `Icon${canonElem === 'Lightning' ? 'Thunder' : canonElem}AddedRatio`; + const propIcon = (name, alt, size = 15) => getStatIconHtml(name, size, alt); + const inlineStat = (iconName, alt, value, size = 13) => + `${propIcon(iconName, alt, size)} ${value}`; + el.showcaseCardContainer.innerHTML = `
@@ -5395,44 +5480,44 @@ function renderShowcaseCard(avatarId) {
${lcInfo.name}
-
${lcInfo.name}
+
${lcInfo.name}
S${lcRank} • Lv. ${lcLevel}/80
- ❤️ ${lcHp} - ⚔️ ${lcAtk} - 🛡️ ${lcDef} + ${inlineStat('IconMaxHP', 'HP', lcHp)} + ${inlineStat('IconAttack', 'ATK', lcAtk)} + ${inlineStat('IconDefence', 'DEF', lcDef)}
-
Active Set Bonuses
+
${t('showcaseActiveSets')}
${setsHtml}
-
❤️ HP${totalHp}
-
⚔️ ATK${totalAtk}
-
🛡️ DEF${totalDef}
-
👟 SPD${totalSpd}
-
🎯 CRIT Rate${critRate}%
-
💥 CRIT DMG${critDmg}%
-
⚡ Break Eff${breakEffect}%
-
🛡️ Eff RES${effectRes}%
-
🌀 ERR${err}%
-
🎯 Eff Hit${ehr}%
-
💚 Healing${healing}%
-
✨ ${meta.element}${elemDmg}%
+
${propIcon('IconMaxHP', 'HP')} ${t('statMaxHp')}${totalHp}
+
${propIcon('IconAttack', 'ATK')} ${t('statAtk')}${totalAtk}
+
${propIcon('IconDefence', 'DEF')} ${t('statDef')}${totalDef}
+
${propIcon('IconSpeed', 'SPD')} ${t('statSpd')}${totalSpd}
+
${propIcon('IconCriticalChance', 'CRIT Rate')} ${t('statCritRate')}${critRate}%
+
${propIcon('IconCriticalDamage', 'CRIT DMG')} ${t('statCritDmg')}${critDmg}%
+
${propIcon('IconBreakUp', 'Break Effect')} ${t('statBreakEffect')}${breakEffect}%
+
${propIcon('IconStatusResistance', 'Effect RES')} ${t('statRes')}${effectRes}%
+
${propIcon('IconEnergyRecovery', 'Energy Regen')} ${t('statEnergyRegen')}${err}%
+
${propIcon('IconStatusProbability', 'Effect Hit Rate')} ${t('statEhr')}${ehr}%
+
${propIcon('IconHealRatio', 'Healing')} ${t('statHealBoost')}${healing}%
+
${propIcon(elemStatIconName, translateElement(canonElem))} ${t('statDmgBoost', { element: translateElement(canonElem) })}${elemDmg}%
-
Equipped Relics (+15)
+
${t('showcaseEquippedRelics')}
${relicsHtml}
`; @@ -5558,11 +5643,11 @@ function copyShowcaseText(avatarId) { const lc = (state.fullFreesrData?.lightcones || []).find(l => l.equip_avatar === avatarId); const lcInfo = lc ? getLightconeInfo(lc.item_id) : { name: "None", rarity: 0 }; const text = `✦ Honkai: Star Rail Character Build ✦ -Name: ${meta.name} (ID: ${avatarId}) -Path: ${meta.path} | Element: ${meta.element} -Level: 80 | Eidolon: E${rank} -Light Cone: ${lcInfo.name} (Lv. ${lc?.level || 80} / S${lc?.rank || 1}) -Generated via GaC-Srtool`; +${t('copyCardName')}: ${meta.name} (ID: ${avatarId}) +${t('copyCardPath')}: ${meta.path} | ${t('copyCardElement')}: ${meta.element} +${t('copyCardLevel')}: 80 | ${t('copyCardEidolon')}: E${rank} +${t('copyCardLightCone')}: ${lcInfo.name} (Lv. ${lc?.level || 80} / S${lc?.rank || 1}) +${t('copyCardGenerated')}`; navigator.clipboard.writeText(text).then(() => { showToast(t('toastShowcaseCopied'), 'success'); @@ -5571,6 +5656,59 @@ Generated via GaC-Srtool`; }); } +// html2canvas ignores object-fit, so pre-render the splash art with the same +// "cover" crop the card uses on screen. Without this the portrait gets +// stretched (squeezed) to fill the tall column in the exported PNG. +async function prepareExportSplashCover(clone) { + const img = clone.querySelector('.showcase-splash-img'); + if (!img || img.tagName !== 'IMG') return; + + const src = img.currentSrc || img.src; + if (!src) return; + + const box = img.getBoundingClientRect(); + const boxW = Math.round(box.width); + const boxH = Math.round(box.height); + if (boxW <= 0 || boxH <= 0) return; + + const image = await new Promise(resolve => { + const loader = new Image(); + loader.crossOrigin = 'anonymous'; + loader.onload = () => resolve(loader); + loader.onerror = () => resolve(null); + loader.src = src; + }); + if (!image || !image.naturalWidth || !image.naturalHeight) return; + + // Match the html2canvas scale so the cropped art stays sharp. + const scaleFactor = 2; + const canvas = document.createElement('canvas'); + canvas.width = Math.round(boxW * scaleFactor); + canvas.height = Math.round(boxH * scaleFactor); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const cover = Math.max(canvas.width / image.naturalWidth, canvas.height / image.naturalHeight); + const drawW = image.naturalWidth * cover; + const drawH = image.naturalHeight * cover; + const drawX = (canvas.width - drawW) / 2; + const drawY = (canvas.height - drawH) * 0.25; // object-position: center 25% + + try { + ctx.drawImage(image, drawX, drawY, drawW, drawH); + // toDataURL throws when the source image is not CORS-clean; in that case + // keep the plain so the export still succeeds (just without the crop). + const cropped = document.createElement('img'); + cropped.src = canvas.toDataURL('image/png'); + cropped.alt = img.alt || ''; + cropped.className = 'showcase-splash-img'; + cropped.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:fill;display:block;'; + img.replaceWith(cropped); + } catch (err) { + console.warn('Splash cover pre-render skipped:', err); + } +} + // Export Showcase Card as high-res PNG image using html2canvas async function exportShowcaseImage(avatarId) { const meta = getAvatarInfo(avatarId); @@ -5580,32 +5718,114 @@ async function exportShowcaseImage(avatarId) { showToast(t('toastCardExporting'), 'info'); if (typeof html2canvas !== 'undefined') { + let holder = null; try { - const fullWidth = showcaseEl.scrollWidth || showcaseEl.offsetWidth; - const fullHeight = showcaseEl.scrollHeight || showcaseEl.offsetHeight; + // Render a detached copy at a fixed width so the exported PNG always uses + // the full three-column layout. Capturing the live element made html2canvas + // clip the canvas when the on-screen card was narrower than its content. + const exportWidth = 1200; + holder = document.createElement('div'); + holder.setAttribute('aria-hidden', 'true'); + holder.style.cssText = `position:fixed;top:0;left:0;width:${exportWidth}px;z-index:-1;pointer-events:none;background:transparent;`; + const clone = showcaseEl.cloneNode(true); + clone.classList.add('showcase-export-mode'); + clone.style.width = `${exportWidth}px`; + clone.style.maxWidth = 'none'; + clone.style.margin = '0'; + holder.appendChild(clone); + document.body.appendChild(holder); - const canvas = await html2canvas(showcaseEl, { - useCORS: true, - allowTaint: true, - scale: 2, - backgroundColor: '#0c1222', - width: fullWidth, - height: fullHeight, - windowWidth: fullWidth - }); + // html2canvas ignores object-fit, so bake the splash "cover" crop in first. + await prepareExportSplashCover(clone); + + // Give the cloned artwork a moment to load before painting the canvas. + const images = Array.from(clone.querySelectorAll('img')); + await Promise.race([ + Promise.all(images.map(img => img.complete ? Promise.resolve() : + new Promise(resolve => { + img.addEventListener('load', resolve, { once: true }); + img.addEventListener('error', resolve, { once: true }); + }))), + new Promise(resolve => setTimeout(resolve, 3000)) + ]); + + // Let web fonts settle too; fallback metrics can reflow the clone taller. + try { + await document.fonts?.ready; + } catch (err) { /* font loading API unavailable */ } + + // Yield a moment so the cloned layout settles. setTimeout is used instead of + // requestAnimationFrame so exporting still works when the tab is backgrounded. + await new Promise(resolve => setTimeout(resolve, 120)); + + const measureExportClone = (el) => { + const rect = el.getBoundingClientRect(); + let height = rect.height; + for (const child of el.children) { + height = Math.max(height, child.getBoundingClientRect().bottom - rect.top); + } + return { + width: Math.ceil(Math.max(rect.width, el.scrollWidth)), + height: Math.ceil(Math.max(height, el.scrollHeight)) + }; + }; + + // html2canvas paints a *cloned* document inside its own iframe. That iframe + // can lay the card out taller than the on-screen clone (different media + // query state / font metrics), which used to cut the bottom off the PNG + // while the canvas still reported the on-screen size. So measure inside the + // clone on every pass and re-render when the painted size differs. + let exportSize = measureExportClone(clone); + let canvas = null; + for (let pass = 0; pass < 3; pass++) { + let paintedSize = null; + canvas = await html2canvas(clone, { + useCORS: true, + allowTaint: true, + scale: 2, + // Transparent canvas: the card has rounded corners, so a solid + // background would show up as dark wedges in the four corners. + backgroundColor: null, + width: exportSize.width, + height: exportSize.height, + windowWidth: Math.max(window.innerWidth, exportWidth + 200), + windowHeight: Math.max(window.innerHeight, exportSize.height + 200), + scrollX: 0, + scrollY: 0, + onclone: (doc, el) => { + // Pin the root/body font sizes so the cloned iframe keeps the exact + // metrics of the live page instead of reflowing from its own defaults. + try { + doc.documentElement.style.fontSize = getComputedStyle(document.documentElement).fontSize; + doc.body.style.fontSize = getComputedStyle(document.body).fontSize; + } catch (err) { /* ignore */ } + paintedSize = measureExportClone(el); + } + }); + if (!paintedSize) break; + if (Math.abs(paintedSize.width - exportSize.width) <= 1 && + Math.abs(paintedSize.height - exportSize.height) <= 1) break; + exportSize = paintedSize; + } const dataUrl = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = dataUrl; - const cleanName = meta.name.replace(/[^a-zA-Z0-9ก-๙_-]/g, '_'); + // Keep letters from every script (Thai, CJK, Cyrillic, ...) in the file name. + const cleanName = (meta.name || 'Showcase') + .normalize('NFC') + .replace(/[^\p{L}\p{N}_-]+/gu, '_') + .replace(/^_+|_+$/g, '') || 'Showcase'; a.download = `${cleanName}_Build_${Date.now()}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); - showToast(t('toastCardDownloaded'), 'success'); + showToast(t('toastCardDownloaded'), 'success'); return; } catch (err) { console.warn('html2canvas failed, falling back to print view:', err); + } finally { + holder?.remove(); } } @@ -5649,8 +5869,8 @@ async function exportShowcaseImage(avatarId) {
${showcaseEl.outerHTML} @@ -6038,12 +6258,12 @@ async function saveFreesrChangesToServer() { // Equipment Sub-tab & Filter Helpers (Matching srtools neonteam) const ROMAN_RANKS = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V' }; const RELIC_SLOT_NAMES = { - 1: "ส่วนหัว", - 2: "ส่วนมือ", - 3: "ลำตัว", - 4: "รองเท้า", - 5: "ลูกแก้ว", - 6: "สร้อย" + 1: "slotHeadShort", + 2: "slotHandsShort", + 3: "slotBodyShort", + 4: "slotFeetShort", + 5: "slotSphereShort", + 6: "slotRopeShort" }; // Load Equipment (Lightcones & Relics) @@ -6133,7 +6353,7 @@ function renderLightconesGrid() { card.title = `${meta.name} (★${meta.rarity} ${meta.path || ''})`; const wearerBadgeHtml = lc.equip_avatar ? ` -
+
Wearer
` : ''; @@ -6192,7 +6412,7 @@ function renderRelicsGrid() { const slotNum = (r.relic_id % 10) || 1; const setId = Math.floor((r.relic_id % 10000) / 10); const rInfo = getRelicSetInfo(setId); - const slotName = RELIC_SLOT_NAMES[slotNum] || `Slot ${slotNum}`; + const slotName = RELIC_SLOT_NAMES[slotNum] ? t(RELIC_SLOT_NAMES[slotNum]) : `${t('slot')} ${slotNum}`; const card = document.createElement('div'); card.className = 'eq-tile-card rarity-5'; @@ -6200,7 +6420,7 @@ function renderRelicsGrid() { card.title = `${rInfo.name} (${slotName})`; const wearerBadgeHtml = r.equip_avatar ? ` -
+
Wearer
` : ''; @@ -6286,20 +6506,20 @@ function showLcInspector(lc) {
-
${lcMeta.skill_name || 'สกิลพิเศษประจำกรวยแสง'}
-
${lcMeta.desc || 'เพิ่มพลังต่อสู้และเสริมกลไกเฉพาะสายของตัวละครเมื่อติดตั้ง'}
+
${lcMeta.skill_name || t('eqSkillFallbackTitle')}
+
${lcMeta.desc || t('eqSkillFallbackDesc')}
- +
- - + +
`; @@ -6347,7 +6567,7 @@ function showRelicInspector(relic) { const slotNum = (relic.relic_id % 10) || 1; const setId = Math.floor((relic.relic_id % 10000) / 10); const rInfo = getRelicSetInfo(setId); - const slotName = RELIC_SLOT_NAMES[slotNum] || `ชิ้นที่ ${slotNum}`; + const slotName = RELIC_SLOT_NAMES[slotNum] ? t(RELIC_SLOT_NAMES[slotNum]) : t('eqSlotN', { n: slotNum }); let avatarOpts = ``; const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id); @@ -6381,11 +6601,11 @@ function showRelicInspector(relic) {
- สเตตัสหลัก (Main Stat) + ${t('eqMainStatLabel')} ✦ ${mainAffixName}
- ออฟรอง (Substats): + ${t('eqSubstatsLabel')}
${(relic.sub_affixes && relic.sub_affixes.length > 0) ? relic.sub_affixes.map(s => { const sName = getRelicSubAffixName(s.sub_affix_id); @@ -6399,30 +6619,30 @@ function showRelicInspector(relic) {
`; - }).join('') : '
ไม่มีข้อมูลออฟรอง
'} + }).join('') : `
${t('eqNoSubstats')}
`}
Set Bonus (${rInfo.name})
-
2-Piece: ${rInfo.desc2 || 'เพิ่มประสิทธิภาพตามเซ็ตที่กำหนด'}
4-Piece: ${rInfo.desc4 || 'ปลดล็อกเอฟเฟกต์ต่อสู้ขั้นสูง'}
+
2-Piece: ${rInfo.desc2 || t('eqSet2Fallback')}
4-Piece: ${rInfo.desc4 || t('eqSet4Fallback')}
- +
- - + +
`; @@ -6583,7 +6803,7 @@ function renderAddLcVisualGrid() { }); if (filtered.length === 0) { - grid.innerHTML = `
ไม่พบกรวยแสงตามเงื่อนไขที่ค้นหา
`; + grid.innerHTML = `
${t('lcNoResults')}
`; return; } @@ -7640,7 +7860,7 @@ function renderExRanks(exList) { if (!el.exrankList) return; el.exrankList.innerHTML = ''; if (!exList || exList.length === 0) { - el.exrankList.innerHTML = '
ไม่มีข้อมูล Ex-Rank
'; + el.exrankList.innerHTML = `
${t('exRankNoData')}
`; return; } @@ -7677,7 +7897,7 @@ function renderExRanks(exList) {
`; @@ -7712,26 +7932,26 @@ async function loadJsonData(mode) { el.btnSelectFreesr.className = 'btn btn-sm btn-primary active'; if (el.btnSelectPersistent) el.btnSelectPersistent.className = 'btn btn-sm btn-secondary'; if (el.btnSelectDatagame) el.btnSelectDatagame.className = 'btn btn-sm btn-secondary'; - el.jsonStatusInfo.textContent = 'กำลังโหลด freesr-data.json...'; + el.jsonStatusInfo.textContent = t('jsonStatusLoading', { file: 'freesr-data.json' }); try { if (state.fullFreesrData) { el.jsonEditor.value = JSON.stringify(state.fullFreesrData, null, 2); - el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากหน่วยความจำ/แคช)'; + el.jsonStatusInfo.textContent = t('jsonStatusMemory', { file: 'freesr-data.json' }); } else if (state.isConnected) { const res = await apiCall('/api/freesr'); state.fullFreesrData = res.data; saveFreesrCache(res.data); el.jsonEditor.value = JSON.stringify(res.data, null, 2); - el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากเซิร์ฟเวอร์)'; + el.jsonStatusInfo.textContent = t('jsonStatusServer', { file: 'freesr-data.json' }); } else { const cached = localStorage.getItem('gac_srtool_freesr_cache'); if (cached) { const parsed = JSON.parse(cached); state.fullFreesrData = parsed; el.jsonEditor.value = JSON.stringify(parsed, null, 2); - el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากแคชของเบราว์เซอร์)'; + el.jsonStatusInfo.textContent = t('jsonStatusBrowserCache', { file: 'freesr-data.json' }); } else { - el.jsonStatusInfo.textContent = 'Offline: ไม่มีแคช สามารถอัปโหลดหรือพิมพ์ JSON เองได้'; + el.jsonStatusInfo.textContent = t('jsonOfflineNoCache'); if (!el.jsonEditor.value) { el.jsonEditor.value = JSON.stringify({ avatars: {}, lightcones: [], relics: [], battle_config: {} }, null, 2); } @@ -7744,10 +7964,10 @@ async function loadJsonData(mode) { const parsed = JSON.parse(cached); state.fullFreesrData = parsed; el.jsonEditor.value = JSON.stringify(parsed, null, 2); - el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากแคชของเบราว์เซอร์)'; + el.jsonStatusInfo.textContent = t('jsonStatusBrowserCache', { file: 'freesr-data.json' }); } catch (e) {} } else { - el.jsonStatusInfo.textContent = `Offline: สามารถพิมพ์หรือวาง JSON เองได้`; + el.jsonStatusInfo.textContent = t('jsonOfflinePaste'); if (!el.jsonEditor.value) { el.jsonEditor.value = JSON.stringify({ avatars: {}, lightcones: [], relics: [], battle_config: {} }, null, 2); } @@ -7757,11 +7977,11 @@ async function loadJsonData(mode) { el.btnSelectFreesr.className = 'btn btn-sm btn-secondary'; if (el.btnSelectPersistent) el.btnSelectPersistent.className = 'btn btn-sm btn-secondary'; if (el.btnSelectDatagame) el.btnSelectDatagame.className = 'btn btn-sm btn-primary active'; - el.jsonStatusInfo.textContent = 'กำลังโหลด data-in-game.json...'; + el.jsonStatusInfo.textContent = t('jsonStatusLoading', { file: 'data-in-game.json' }); try { if (state.dataGameData) { el.jsonEditor.value = JSON.stringify(state.dataGameData, null, 2); - el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากหน่วยความจำ/แคช)'; + el.jsonStatusInfo.textContent = t('jsonStatusMemory', { file: 'data-in-game.json' }); } else if (state.isConnected) { const res = await apiCall('/api/data-in-game').catch(() => null); if (res && (res.data || res.lineups)) { @@ -7769,7 +7989,7 @@ async function loadJsonData(mode) { state.dataGameData = val; saveDataGameCache(val); el.jsonEditor.value = JSON.stringify(val, null, 2); - el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากเซิร์ฟเวอร์)'; + el.jsonStatusInfo.textContent = t('jsonStatusServer', { file: 'data-in-game.json' }); } else { throw new Error('Not available'); } @@ -7779,11 +7999,11 @@ async function loadJsonData(mode) { const parsed = JSON.parse(cached); state.dataGameData = parsed; el.jsonEditor.value = JSON.stringify(parsed, null, 2); - el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากแคช)'; + el.jsonStatusInfo.textContent = t('jsonStatusCache', { file: 'data-in-game.json' }); } else { state.dataGameData = { ...DEFAULT_DATA_GAME }; el.jsonEditor.value = JSON.stringify(DEFAULT_DATA_GAME, null, 2); - el.jsonStatusInfo.textContent = 'data-in-game.json (โครงสร้าง Firefly dump)'; + el.jsonStatusInfo.textContent = t('jsonStatusDump', { file: 'data-in-game.json' }); } } } catch (err) { @@ -7793,38 +8013,38 @@ async function loadJsonData(mode) { const parsed = JSON.parse(cached); state.dataGameData = parsed; el.jsonEditor.value = JSON.stringify(parsed, null, 2); - el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากแคช)'; + el.jsonStatusInfo.textContent = t('jsonStatusCache', { file: 'data-in-game.json' }); } catch (e) {} } else { state.dataGameData = { ...DEFAULT_DATA_GAME }; el.jsonEditor.value = JSON.stringify(DEFAULT_DATA_GAME, null, 2); - el.jsonStatusInfo.textContent = 'data-in-game.json (โครงสร้าง Firefly dump)'; + el.jsonStatusInfo.textContent = t('jsonStatusDump', { file: 'data-in-game.json' }); } } } else { el.btnSelectFreesr.className = 'btn btn-sm btn-secondary'; if (el.btnSelectPersistent) el.btnSelectPersistent.className = 'btn btn-sm btn-primary active'; if (el.btnSelectDatagame) el.btnSelectDatagame.className = 'btn btn-sm btn-secondary'; - el.jsonStatusInfo.textContent = 'กำลังโหลด persistent...'; + el.jsonStatusInfo.textContent = t('jsonStatusLoading', { file: 'persistent' }); try { if (state.persistentData) { el.jsonEditor.value = JSON.stringify(state.persistentData, null, 2); - el.jsonStatusInfo.textContent = 'persistent (โหลดจากหน่วยความจำ/แคช)'; + el.jsonStatusInfo.textContent = t('jsonStatusMemory', { file: 'persistent' }); } else if (state.isConnected) { const res = await apiCall('/api/persistent'); state.persistentData = res.data; savePersistentCache(res.data); el.jsonEditor.value = JSON.stringify(res.data, null, 2); - el.jsonStatusInfo.textContent = 'persistent (โหลดจากเซิร์ฟเวอร์)'; + el.jsonStatusInfo.textContent = t('jsonStatusServer', { file: 'persistent' }); } else { const cached = localStorage.getItem('gac_srtool_persistent_cache'); if (cached) { const parsed = JSON.parse(cached); state.persistentData = parsed; el.jsonEditor.value = JSON.stringify(parsed, null, 2); - el.jsonStatusInfo.textContent = 'persistent (โหลดจากแคชของเบราว์เซอร์)'; + el.jsonStatusInfo.textContent = t('jsonStatusBrowserCache', { file: 'persistent' }); } else { - el.jsonStatusInfo.textContent = 'Offline: ไม่มีแคช สามารถอัปโหลดหรือพิมพ์ JSON เองได้'; + el.jsonStatusInfo.textContent = t('jsonOfflineNoCache'); if (!el.jsonEditor.value) { el.jsonEditor.value = JSON.stringify({ lineups: DEFAULT_LINEUP, main_character: "FemaleHarmony", march_type: "MarchHunt" }, null, 2); } @@ -7837,10 +8057,10 @@ async function loadJsonData(mode) { const parsed = JSON.parse(cached); state.persistentData = parsed; el.jsonEditor.value = JSON.stringify(parsed, null, 2); - el.jsonStatusInfo.textContent = 'persistent (โหลดจากแคชของเบราว์เซอร์)'; + el.jsonStatusInfo.textContent = t('jsonStatusBrowserCache', { file: 'persistent' }); } catch (e) {} } else { - el.jsonStatusInfo.textContent = `Offline: สามารถพิมพ์หรือวาง JSON เองได้`; + el.jsonStatusInfo.textContent = t('jsonOfflinePaste'); if (!el.jsonEditor.value) { el.jsonEditor.value = JSON.stringify({ lineups: DEFAULT_LINEUP, main_character: "FemaleHarmony", march_type: "MarchHunt" }, null, 2); } @@ -7968,7 +8188,7 @@ function updateCacheUsageDisplay() { const kb = (displayBytes / 1024).toFixed(1); el.lblCacheUsage.textContent = `${kb} KB (IndexedDB + Storage)`; } catch (err) { - el.lblCacheUsage.textContent = 'ไม่สามารถตรวจสอบได้'; + el.lblCacheUsage.textContent = t('jsonCacheUnavailable'); } } @@ -8433,13 +8653,13 @@ function setupEvents() { const mode = document.getElementById('camera-mode').value; const distance = Number(document.getElementById('camera-distance').value); if (!Number.isFinite(distance) || distance < 0.5 || distance > 5) { - showToast('ระยะ FaceCam ต้องอยู่ระหว่าง 0.5 ถึง 5', 'error'); return; + showToast(t('cameraDistanceError'), 'error'); return; } button.disabled = true; try { const saved = await apiCall('/api/camera', 'POST', { mode, distance }); if (state.persistentData) state.persistentData.camera_settings = saved; - showToast('บันทึกกล้องแล้ว', 'success'); + showToast(t('cameraSaved'), 'success'); } catch (err) { showToast(err.message, 'error'); } finally { button.disabled = false; } }); diff --git a/js/avatars-data.js b/js/avatars-data.js index 3797d96..c8941e7 100644 --- a/js/avatars-data.js +++ b/js/avatars-data.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js"; +import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js?v=2.16"; export const AVATAR_METADATA = { "1001": { @@ -2099,6 +2099,23 @@ export const CW_ROLE_METADATA = { } }; +// Localized defaults used when a Trailblazer entry stores the {NICKNAME} placeholder. +const TRAILBLAZER_DEFAULT_NAMES = { + th: 'ผู้บุกเบิก (Trailblazer)', + en: 'Trailblazer', + 'zh-CN': '开拓者', + 'zh-TW': '開拓者', + ja: '開拓者', + ko: '개척자', + ru: 'Первопроходец', + vi: 'Nhà Khai Phá', + id: 'Trailblazer', + es: 'El Emisario', + fr: 'Le Pionnier', + de: 'Der Bahnbrecher', + pt: 'O Desbravador' +}; + export function getAvatarInfo(avatarId, langOverride = null) { const lang = langOverride || getCurrentLanguage(); const meta = AVATAR_METADATA[avatarId] || CW_ROLE_METADATA[avatarId]; @@ -2113,9 +2130,9 @@ export function getAvatarInfo(avatarId, langOverride = null) { }; } - let localizedName = meta.names?.[lang] || meta.names?.th || meta.names?.en || `Avatar ${avatarId}`; + let localizedName = meta.names?.[lang] || meta.names?.en || meta.names?.th || `Avatar ${avatarId}`; if (localizedName.includes('{NICKNAME}')) { - const tbName = lang === 'th' ? 'ผู้บุกเบิก (Trailblazer)' : 'Trailblazer'; + const tbName = TRAILBLAZER_DEFAULT_NAMES[lang] || TRAILBLAZER_DEFAULT_NAMES.en; localizedName = localizedName.replace(/\{NICKNAME\}/g, tbName).trim(); if (!localizedName) localizedName = tbName; } diff --git a/js/command-assistant.mjs b/js/command-assistant.mjs index beb63b9..f577e9a 100644 --- a/js/command-assistant.mjs +++ b/js/command-assistant.mjs @@ -1,5 +1,88 @@ // Command syntax mirrors the SDK dispatcher. Runtime IDs come from its read-only // completion catalog; sample quantities are examples, not game resource IDs. + +// The canonical command metadata below is authored in Thai. Non-Thai locales get +// English text from this map so the console follows the selected language. +let currentLanguage = 'th'; + +export function setCommandLanguage(lang) { + currentLanguage = lang === 'th' ? 'th' : 'en'; +} + +const EN_TEXT = { + 'แสดงรายการคำสั่งและวิธีใช้': 'Show the command list and usage', + 'ส่งคำสั่งฟื้นฟู HP, พลังงาน และแต้มสกิล': 'Restore HP, energy and skill points', + 'ปรับตัวละครทั้งหมดเป็นเลเวล 80 และ E6': 'Set all characters to level 80 and E6', + 'กู้หน้าจอที่หมุนค้าง โดยเก็บความคืบหน้า CW': 'Recover a spinning screen while keeping CW progress', + 'ล้างสมาชิกทีมปัจจุบัน': 'Clear the current team', + 'ปรับเลเวลและ Eidolon ของตัวละคร': 'Set a character level and Eidolon', + 'ส่งคำสั่งเพิ่มไอเท็ม': 'Add items', + 'เปลี่ยนฉากปลายทาง': 'Change the destination scene', + 'ดู/ตั้งเวลาเกม โดยไม่เปลี่ยนเวลา Windows': 'View or set game time without changing Windows time', + 'แสดงวิธีใช้คำสั่ง CW': 'Show CW command usage', + 'ดูเงิน HP เลเวล และสถานะรอบ CW': 'View money, HP, level and CW round status', + 'แสดงรายการ ID ของ CW': 'List CW IDs', + 'เพิ่ม EXP ในรอบ CW': 'Add EXP in the CW round', + 'ตั้งค่าพลังบุกเบิก (Trailblaze Power / เหรียญร้านรถไฟ) ในรอบ CW': 'Set Trailblaze Power (train shop currency) in the CW round', + 'เพิ่มตัวละคร CW (ใช้ได้ใน Preparation)': 'Add a CW character (Preparation only)', + 'จำนวนเต็มบวก เช่น 1, 10 หรือ 100': 'Positive integer, e.g. 1, 10 or 100', + 'เลือก ID ตัวละครจากรายชื่อ': 'Pick a character ID from the list', + 'เลเวล 1–80 (ไม่ใส่เพื่อคงค่าเดิม)': 'Level 1–80 (omit to keep the current value)', + 'Eidolon 0–6': 'Eidolon 0–6', + 'ID ไอเท็มจาก catalog เซิร์ฟเวอร์': 'Item ID from the server catalog', + 'เลือก Plane ID': 'Pick a Plane ID', + 'เลือกชั้นที่อยู่ใน Plane นี้': 'Pick a floor inside this Plane', + 'status = ดูเวลา · off = เวลาจริง · หรือ Unix seconds / RFC3339 พร้อม timezone': 'status = view time · off = real time · or Unix seconds / RFC3339 with timezone', + 'เลือกชนิดข้อมูล': 'Pick a data type', + 'HP 1 ถึงเพดานของรอบปัจจุบัน': 'HP from 1 up to the current round cap', + 'จำนวนเต็มไม่ติดลบ (เป็นยอดรวม ยกเว้น exp ที่เป็นจำนวนเพิ่ม)': 'Non-negative integer (a total, except exp which is added)', + 'จำนวนพลังบุกเบิก เช่น 10, 50, 100': 'Trailblaze Power amount, e.g. 10, 50, 100', + 'เลือก Role ID ของ CW': 'Pick a CW Role ID', + 'ดาวที่มีข้อมูลรองรับสำหรับ Role นี้': 'Supported stars for this Role', + 'เลือก ID จากข้อมูล CW ของ Client': 'Pick an ID from the Client CW data' +}; + +function txt(value) { + if (currentLanguage === 'th' || !value) return value; + if (EN_TEXT[value]) return EN_TEXT[value]; + let match = value.match(/^ตั้งค่า (.+) ในรอบ CW$/); + if (match) return `Set ${match[1]} in the CW round`; + match = value.match(/^เพิ่ม (.+) ในรอบ CW \(Preparation\)$/); + if (match) return `Add ${match[1]} in the CW round (Preparation)`; + return value; +} + +const LABELS = { + th: { + optional: 'ช่องเสริม', + required: 'ช่องที่ต้องใส่', + missing: (list) => 'ยังขาด: ' + list, + allSet: 'ใส่ครบแล้ว · Enter หรือ Run เพื่อส่งคำสั่ง', + nextArg: 'ช่องถัดไป: เลือกคำสั่งย่อยจากรายการด้านล่าง', + navHint: '↑ ↓ เลื่อน · Tab เติมค่าที่เลือก · Esc ปิดรายการ', + notFound: 'ไม่พบคำสั่งนี้ในรายการ — ตรวจชื่อคำสั่งก่อนส่ง', + typeSlash: 'พิมพ์ / เพื่อเลือกคำสั่ง' + }, + en: { + optional: 'Optional argument', + required: 'Required argument', + missing: (list) => 'Missing: ' + list, + allSet: 'All set · press Enter or Run to send', + nextArg: 'Next argument: pick a subcommand from the list below', + navHint: '↑ ↓ navigate · Tab fill selected · Esc close', + notFound: 'Command not found — check the name before sending', + typeSlash: 'Type / to choose a command' + } +}; + +function labels() { + return LABELS[currentLanguage] || LABELS.en; +} + +export function describeCommand(command) { + return txt(command?.description || ''); +} + const arg = (name, help, source, optional = false) => ({ name, help, source, optional }); const quantity = (name = 'count') => arg(name, 'จำนวนเต็มบวก เช่น 1, 10 หรือ 100', ['1', '10', '100'], true); const spec = (path, description, args = []) => ({ path: path.split(' '), description, args }); @@ -39,7 +122,7 @@ export function inspectCommand(value = '', caret = value.length, catalogs = {}) const command = value.trim() ? exact || matching.find(c => (c.path[index] || '').toLowerCase().startsWith(query)) || null : null; const suggestions = []; for (const c of matching) if (index < c.path.length && c.path[index].toLowerCase().startsWith(query)) { - suggestions.push({ value:c.path[index], label:c.path[index], description:c.description }); + suggestions.push({ value:c.path[index], label:c.path[index], description:txt(c.description) }); } const argIndex = exact ? index-exact.path.length : -1; const active = exact?.args[argIndex]; @@ -50,15 +133,16 @@ export function inspectCommand(value = '', caret = value.length, catalogs = {}) if (active.source === 'cw_stars') choices = (catalogs.cw_roles || []).filter(c => String(c.value) === words[exact.path.length]).map(c => String(c.star)); for (const entry of choices) { const choice = typeof entry === 'object' ? entry : {value:String(entry),label:String(entry)}; - if ((choice.value+' '+(choice.label || '')).toLowerCase().includes(query)) suggestions.push({...choice, description:active.help}); + if ((choice.value+' '+(choice.label || '')).toLowerCase().includes(query)) suggestions.push({...choice, description:txt(active.help)}); } } const seen = new Set(); const unique = suggestions.filter(s => { if(seen.has(s.value))return false;seen.add(s.value);return true; }); const missing = exact ? exact.args.filter((a,i) => !a.optional && !words[exact.path.length+i]).map(a => a.name) : []; + const L = labels(); return { command, active, argIndex, index, start, end, suggestions:unique, missing, - preview:command ? [...command.path, ...command.args.map((a,i) => words[command.path.length+i] || (a.optional ? '['+a.name+']' : '<'+a.name+'>'))].join(' ') : value.trim() ? 'ไม่พบคำสั่งนี้ในรายการ — ตรวจชื่อคำสั่งก่อนส่ง' : 'พิมพ์ / เพื่อเลือกคำสั่ง', - hint:active ? `${active.optional ? 'ช่องเสริม' : 'ช่องที่ต้องใส่'}: ${active.name} — ${active.help}` : exact ? (missing.length ? 'ยังขาด: '+missing.join(', ') : 'ใส่ครบแล้ว · Enter หรือ Run เพื่อส่งคำสั่ง') : command && index > 0 ? 'ช่องถัดไป: เลือกคำสั่งย่อยจากรายการด้านล่าง' : '↑ ↓ เลื่อน · Tab เติมค่าที่เลือก · Esc ปิดรายการ', + preview:command ? [...command.path, ...command.args.map((a,i) => words[command.path.length+i] || (a.optional ? '['+a.name+']' : '<'+a.name+'>'))].join(' ') : value.trim() ? L.notFound : L.typeSlash, + hint:active ? `${active.optional ? L.optional : L.required}: ${active.name} — ${txt(active.help)}` : exact ? (missing.length ? L.missing(missing.join(', ')) : L.allSet) : command && index > 0 ? L.nextArg : L.navHint, }; } diff --git a/js/equipment-data.js b/js/equipment-data.js index 5ed97cd..59f13c2 100644 --- a/js/equipment-data.js +++ b/js/equipment-data.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage, translatePath } from "./i18n.js"; +import { getCurrentLanguage, translatePath } from "./i18n.js?v=2.16"; export const LIGHTCONE_METADATA = { "20000": { @@ -4326,7 +4326,7 @@ export function getLightconeInfo(lcId, langOverride = null) { rawPath: "Unknown" }; } - const localizedName = meta.names?.[lang] || meta.names?.th || meta.names?.en || `Lightcone ${lcId}`; + const localizedName = meta.names?.[lang] || meta.names?.en || meta.names?.th || `Lightcone ${lcId}`; return { name: localizedName, rarity: meta.rarity, @@ -4349,7 +4349,7 @@ export function getRelicSetInfo(setIdOrRelicId, langOverride = null) { name: `Relic Set ${setId}` }; } - const localizedName = meta.names?.[lang] || meta.names?.th || meta.names?.en || `Relic Set ${setId}`; + const localizedName = meta.names?.[lang] || meta.names?.en || meta.names?.th || `Relic Set ${setId}`; return { id: setId, name: localizedName diff --git a/js/i18n.js b/js/i18n.js index 97fba9a..b3107f4 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -7044,6 +7044,213 @@ for (const [language, description] of Object.entries(ITEM_CATALOG_INTERACTION_DE TRANSLATIONS[language].itemCatalogDescription = description; } +// Strings that used to be hardcoded in the character modal, showcase card, +// dashboard console and camera panel. Only th/en are defined here; every other +// language falls back to en through t(), so no Thai leaks into other locales. +const MODAL_AND_TOOL_TRANSLATIONS = { + th: { + relicSlotNotEquipped: 'ยังไม่ได้สวมใส่', + relicSlotCreateHint: '+ คลิกเพื่อเลือกหรือสร้าง (แนะนำ: {stat})', + relicSlotUnequip: 'ถอดชิ้นนี้', + relicSubstatFallback: '4 ออปชั่นรอง (คลิกเพื่อปรับแต่ง)', + relicSetNoBonus: 'ยังไม่มีเอฟเฟกต์เซ็ตทำงาน (ต้องใส่เซ็ตเดียวกันอย่างน้อย 2 หรือ 4 ชิ้น)', + relicSet4Fallback: 'เปิดใช้งานเอฟเฟกต์ครบ 4 ชิ้น', + relicSet2Fallback: 'เปิดใช้งานเอฟเฟกต์ 2 ชิ้น', + overviewRelicCount: '{count}/6 ชิ้นสวมใส่ (ยังไม่มีโบนัสเซ็ต)', + relicPickerEditTitle: '🔧 แก้ไขรีลิกส์: {set} ({slot}) - {name}', + relicPickerEditSubtitle: 'ปรับแต่งเซ็ต สเตตัสหลัก และออฟรองของรีลิกส์ชิ้นนี้ (#{id})', + relicPickerEditing: '✏️ กำลังแก้ไขรีลิกส์ #{id}', + relicPickerSaveChanges: '💾 บันทึกการแก้ไขออฟรองชิ้นนี้', + relicPickerEquipNow: '⚡ สวมใส่ชิ้นนี้ทันที (+15 5★ ออฟที่เลือก)', + relicPickerInInventory: 'ชิ้นในคลัง', + relicPickerEditThis: '✏️ แก้ไขออฟรองชิ้นนี้', + relicPickerEditThisTitle: 'โหลดข้อมูลชิ้นนี้ลงมาแก้ไขออฟรอง', + relicPickerRecommended: 'ออฟรองแนะนำ 4 แถวเทพ ปรับแต่งได้ตามต้องการด้านล่าง', + substatMainConflict: ' (ตรงกับ Main Stat)', + substatAlreadyChosen: ' (เลือกแล้ว)', + substatSlotLabel: 'ออฟรอง {n}', + substatRolls: '+{value} ({count} โรล)', + substatDecreaseRoll: 'ลดโรล', + substatIncreaseRoll: 'เพิ่มโรล', + relicEmptyInventoryTitle: 'ไม่มีรีลิกส์สำหรับช่องนี้ในคลัง', + relicEmptyInventoryHint: 'สามารถสลับไปที่แท็บ "⚡ สวมใส่ / สร้างชิ้นแนะนำ" เพื่อสร้างชิ้นใหม่ได้ทันที!', + relicEquippedNow: 'กำลังสวมใส่อยู่', + relicWornBy: 'สวมใส่โดย: {name}', + relicFreeInInventory: 'ว่างในคลัง', + relicInUse: 'ใช้งานอยู่', + relicSelectThis: 'เลือกชิ้นนี้', + lcNoneEquipped: 'ยังไม่ได้สวมใส่ Light Cone', + lcSelectPrompt: 'กรุณาเลือก Light Cone ที่ต้องการสวมใส่', + lcSamePathGroup: '⭐ Light Cones สาย {path}', + lcOtherPathGroup: '🗡️ Light Cones สายอื่นๆ', + lcNoResults: 'ไม่พบกรวยแสงตามเงื่อนไขที่ค้นหา', + lcEquippedBadge: 'สวมใส่อยู่', + lcSignatureBadge: 'ประจำตัว', + lcSelectTitle: '🗡️ เลือกสวมใส่กรวยแสง: {name}', + eqSkillFallbackTitle: 'สกิลพิเศษประจำกรวยแสง', + eqSkillFallbackDesc: 'เพิ่มพลังต่อสู้และเสริมกลไกเฉพาะสายของตัวละครเมื่อติดตั้ง', + eqEquippedByLabel: 'ผู้สวมใส่ (Equipped By):', + eqSaveWearer: '💾 บันทึกสวมใส่', + eqDelete: '🗑️ ลบ', + eqDeleteTitle: 'ลบออกจากคลัง', + eqSlotN: 'ชิ้นที่ {n}', + eqMainStatLabel: 'สเตตัสหลัก (Main Stat)', + eqSubstatsLabel: 'ออฟรอง (Substats):', + eqNoSubstats: 'ไม่มีข้อมูลออฟรอง', + eqEditSubstats: '🔧 ปรับแต่งออฟรอง / สเตตัส (Edit Substats)', + eqSet2Fallback: 'เพิ่มประสิทธิภาพตามเซ็ตที่กำหนด', + eqSet4Fallback: 'ปลดล็อกเอฟเฟกต์ต่อสู้ขั้นสูง', + showcaseActiveSets: 'โบนัสเซ็ตที่ทำงาน', + showcaseEquippedRelics: 'รีลิกส์ที่สวมใส่ (+15)', + showcaseBuildTag: 'ภาพรวมบิลด์', + showcaseRankLevel: 'S{rank} • Lv. {level}/80', + consolePreviewHint: 'พิมพ์ / เพื่อเลือกคำสั่ง', + consoleDescriptionHint: 'เลือกคำสั่งเพื่อดูตัวอย่างและช่องที่ต้องใส่', + consoleArgumentHint: '↑ ↓ เลื่อน · Tab เติมค่าที่เลือก · Esc ปิดรายการ', + consoleKeyboardHint: '↑ ↓ เลื่อนรายการ · Tab เติมค่า · Shift+Tab ย้อนรายการ · Esc ปิด · Enter ส่งคำสั่ง', + cameraHint: 'เลือกโหมด แล้วกด Tab ในเกมเพื่อเปิดหรือปิดกล้อง', + cameraMode: 'โหมดกล้อง', + cameraFaceCamDistance: 'ระยะ FaceCam', + cameraSave: 'บันทึกกล้อง', + cameraFaceCamExperimental: 'FaceCam (ทดลอง)', + cameraDistanceError: 'ระยะ FaceCam ต้องอยู่ระหว่าง 0.5 ถึง 5', + cameraSaved: 'บันทึกกล้องแล้ว', + lcPassiveFallback: 'เพิ่มค่าสถานะพื้นฐานตามสาย {path} และเพิ่มประสิทธิภาพความเสียหายและการสนับสนุน {value}% เมื่อเข้าสู่การต่อสู้', + slotHeadShort: 'ส่วนหัว', + slotHandsShort: 'ส่วนมือ', + slotBodyShort: 'ลำตัว', + slotFeetShort: 'รองเท้า', + slotSphereShort: 'ลูกแก้ว', + slotRopeShort: 'สร้อย', + traceFallbackA2: 'ร่องรอย A2', + traceFallbackA4: 'ร่องรอย A4', + traceFallbackA6: 'ร่องรอย A6', + printSavePdf: 'พิมพ์ / บันทึกเป็น PDF หรือรูปภาพ', + printClose: 'ปิดหน้าต่าง', + exRankNoData: 'ไม่มีข้อมูล Ex-Rank', + exRankEnabled: 'เปิดใช้งานแล้ว', + exRankDisabled: 'ปิดการใช้งาน', + jsonStatusLoading: 'กำลังโหลด {file}...', + jsonStatusMemory: '{file} (โหลดจากหน่วยความจำ/แคช)', + jsonStatusServer: '{file} (โหลดจากเซิร์ฟเวอร์)', + jsonStatusBrowserCache: '{file} (โหลดจากแคชของเบราว์เซอร์)', + jsonStatusCache: '{file} (โหลดจากแคช)', + jsonStatusDump: '{file} (โครงสร้าง Firefly dump)', + jsonOfflineNoCache: 'Offline: ไม่มีแคช สามารถอัปโหลดหรือพิมพ์ JSON เองได้', + jsonOfflinePaste: 'Offline: สามารถพิมพ์หรือวาง JSON เองได้', + jsonCacheUnavailable: 'ไม่สามารถตรวจสอบได้', + copyCardName: 'ชื่อ', + copyCardPath: 'สาย', + copyCardElement: 'ธาตุ', + copyCardLevel: 'เลเวล', + copyCardEidolon: 'Eidolon', + copyCardLightCone: 'กรวยแสง', + copyCardGenerated: 'สร้างโดย GaC-Srtool' + }, + en: { + relicSlotNotEquipped: 'Not equipped', + relicSlotCreateHint: '+ Click to choose or create (recommended: {stat})', + relicSlotUnequip: 'Unequip this piece', + relicSubstatFallback: '4 substats (click to customize)', + relicSetNoBonus: 'No set effect active (equip at least 2 or 4 pieces of the same set)', + relicSet4Fallback: '4-piece set effect active', + relicSet2Fallback: '2-piece set effect active', + overviewRelicCount: '{count}/6 equipped (no set bonus yet)', + relicPickerEditTitle: '🔧 Edit relic: {set} ({slot}) - {name}', + relicPickerEditSubtitle: 'Customize the set, main stat and substats of this relic (#{id})', + relicPickerEditing: '✏️ Editing relic #{id}', + relicPickerSaveChanges: '💾 Save substat changes', + relicPickerEquipNow: '⚡ Equip this piece now (+15 5★ with selected stats)', + relicPickerInInventory: 'In inventory', + relicPickerEditThis: '✏️ Edit this piece', + relicPickerEditThisTitle: 'Load this piece to edit its substats', + relicPickerRecommended: 'Recommended 4 substats - customize them below', + substatMainConflict: ' (matches main stat)', + substatAlreadyChosen: ' (already chosen)', + substatSlotLabel: 'Substat {n}', + substatRolls: '+{value} ({count} rolls)', + substatDecreaseRoll: 'Decrease rolls', + substatIncreaseRoll: 'Increase rolls', + relicEmptyInventoryTitle: 'No relics for this slot in the inventory', + relicEmptyInventoryHint: 'Switch to the "⚡ Equip / Create recommended" tab to create a new piece.', + relicEquippedNow: 'Currently equipped', + relicWornBy: 'Equipped by: {name}', + relicFreeInInventory: 'Free in inventory', + relicInUse: 'In use', + relicSelectThis: 'Select this piece', + lcNoneEquipped: 'No Light Cone equipped', + lcSelectPrompt: 'Choose a Light Cone to equip', + lcSamePathGroup: '⭐ {path} Light Cones', + lcOtherPathGroup: '🗡️ Other paths', + lcNoResults: 'No Light Cones match your search', + lcEquippedBadge: 'Equipped', + lcSignatureBadge: 'Signature', + lcSelectTitle: '🗡️ Choose a Light Cone: {name}', + eqSkillFallbackTitle: 'Light Cone passive', + eqSkillFallbackDesc: 'Boosts combat power and reinforces the character path mechanics when equipped', + eqEquippedByLabel: 'Equipped by:', + eqSaveWearer: '💾 Save equipment', + eqDelete: '🗑️ Delete', + eqDeleteTitle: 'Remove from inventory', + eqSlotN: 'Piece {n}', + eqMainStatLabel: 'Main stat', + eqSubstatsLabel: 'Substats:', + eqNoSubstats: 'No substat data', + eqEditSubstats: '🔧 Edit substats', + eqSet2Fallback: 'Improves performance based on the set', + eqSet4Fallback: 'Unlocks advanced combat effects', + showcaseActiveSets: 'Active Set Bonuses', + showcaseEquippedRelics: 'Equipped Relics (+15)', + showcaseBuildTag: 'Build Overview', + showcaseRankLevel: 'S{rank} • Lv. {level}/80', + consolePreviewHint: 'Type / to choose a command', + consoleDescriptionHint: 'Select a command to see an example and its arguments', + consoleArgumentHint: '↑ ↓ navigate · Tab fill selected · Esc close', + consoleKeyboardHint: '↑ ↓ navigate · Tab fill · Shift+Tab previous · Esc close · Enter send', + cameraHint: 'Choose a mode, then press Tab in game to toggle the camera', + cameraMode: 'Camera mode', + cameraFaceCamDistance: 'FaceCam distance', + cameraSave: 'Save camera', + cameraFaceCamExperimental: 'FaceCam (experimental)', + cameraDistanceError: 'FaceCam distance must be between 0.5 and 5', + cameraSaved: 'Camera saved', + lcPassiveFallback: 'Increases basic stats following the {path} path and boosts damage and support performance by {value}% in battle', + slotHeadShort: 'Head', + slotHandsShort: 'Hands', + slotBodyShort: 'Body', + slotFeetShort: 'Feet', + slotSphereShort: 'Planar Sphere', + slotRopeShort: 'Link Rope', + traceFallbackA2: 'Trace A2', + traceFallbackA4: 'Trace A4', + traceFallbackA6: 'Trace A6', + printSavePdf: 'Print / save as PDF or image', + printClose: 'Close window', + exRankNoData: 'No Ex-Rank data', + exRankEnabled: 'Enabled', + exRankDisabled: 'Disabled', + jsonStatusLoading: 'Loading {file}...', + jsonStatusMemory: '{file} (from memory/cache)', + jsonStatusServer: '{file} (from server)', + jsonStatusBrowserCache: '{file} (from browser cache)', + jsonStatusCache: '{file} (from cache)', + jsonStatusDump: '{file} (Firefly dump structure)', + jsonOfflineNoCache: 'Offline: no cache. You can upload or paste JSON.', + jsonOfflinePaste: 'Offline: you can paste or type JSON.', + jsonCacheUnavailable: 'Unavailable', + copyCardName: 'Name', + copyCardPath: 'Path', + copyCardElement: 'Element', + copyCardLevel: 'Level', + copyCardEidolon: 'Eidolon', + copyCardLightCone: 'Light Cone', + copyCardGenerated: 'Generated via GaC-Srtool' + } +}; +for (const [language, additions] of Object.entries(MODAL_AND_TOOL_TRANSLATIONS)) { + if (TRANSLATIONS[language]) Object.assign(TRANSLATIONS[language], additions); +} + let currentLang = 'th'; if (typeof localStorage !== 'undefined') { currentLang = localStorage.getItem('gac_srtool_lang') || 'th'; diff --git a/js/item-catalog.js b/js/item-catalog.js index 45e2a49..7df9eea 100644 --- a/js/item-catalog.js +++ b/js/item-catalog.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.8'; +import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.16'; const CATALOG_URL = new URL('../assets/item-catalog.json?v=2', import.meta.url); const PAGE_SIZE = 48; diff --git a/js/unstuck.js b/js/unstuck.js index b31c859..e7f40da 100644 --- a/js/unstuck.js +++ b/js/unstuck.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage } from './i18n.js?v=2.8'; +import { getCurrentLanguage } from './i18n.js?v=2.16'; const strings = { en: {