feat: complete localization and add character build guides

This commit is contained in:
CAtlas0784 committed 2026-10-19 01:54:29 +07:00
1 parent 1e98bfea7e
commit 51302e36c8
8 files changed
+10779 -11

No files matched your search

+7
View File
@@ -40,6 +40,7 @@
### 👥 4. ระบบจัดการตัวละคร (Avatars & Character Builder)
- **ข้อมูลครบถ้วน:** ครอบคลุมตัวละครทั้งหมดในเกมตาม Resource มาตรฐานและเนื้อหาล่าสุด
- **คู่มือ Build รายตัว:** เปิดดูกรวยแสง, เซ็ตรีลิกส์/มิติ และออฟหลักที่พบบ่อยจากข้อมูลตัวอย่าง Endgame พร้อมโหมด/จำนวนตัวอย่าง วันที่เก็บข้อมูล และลิงก์คู่มือเต็มของ Prydwen
- **ปรับแต่งสถานะ:** เลเวล (1-80), เลื่อนขั้น (Ascension 0-6), ระดับ Eidolon (E0-E6), และร่องรอยสกิล (Traces 10/10)
- **ปุ่มลัดจัดการด่วน:**
- ⚡ *ปรับ E0S1 ทุกตัว:* ตั้งค่าตัวละครทุกตัวเป็น E0 พร้อมกรวยแสงประจำตัว S1 เลเวล 80
@@ -122,3 +123,9 @@ GaC-Srtool สื่อสารกับเซิร์ฟเวอร์ GaC-h
### บันทึกตัวละครและ Traces
การบันทึกจากหน้ารายละเอียดส่งข้อมูลตัวละครทั้งชุดผ่าน `/api/freesr` ในคำขอเดียว รวมถึง `data.skills` และ `data.skills_by_anchor_type` สำหรับ Traces เพื่อไม่ให้ server sync ข้อมูลระดับ/Eidolon ระหว่างทางก่อนข้อมูล Trace จะถูกบันทึก `saveAvatarDetails(closeModal = true)` ยังรองรับการเรียกจากปุ่ม build ที่ไม่ต้องปิดหน้าต่าง ถ้า server ตอบผิดพลาดจะแสดงข้อความบันทึกไม่สำเร็จแทนการแจ้งว่าสำเร็จ
### ข้อมูล Build และแหล่งที่มา
หน้า **รายละเอียดตัวละคร → แนวทางบิลด์** แสดงอุปกรณ์และออฟหลักที่พบบ่อยในตัวอย่าง Endgame จากข้อมูลที่แสดงบน Prydwen โดยใส่โหมด/จำนวนผู้เล่นและวันที่เก็บ snapshot ไว้กำกับ ตัวเลขเป็นสัดส่วนการใช้งานในกลุ่มตัวอย่าง ไม่ใช่คะแนนจัดอันดับหรือคำแนะนำทางการ ตัวละครที่ snapshot ไม่มีตัวอย่างจะแสดงลิงก์ไปคู่มือของ Prydwen แทนการแสดงข้อมูลสำรองที่อาจทำให้เข้าใจผิด
ข้อมูลที่สร้างไว้ใน `js/build-guides-data.js` ใช้ชุดข้อมูล MIT จาก [LvlUrArti/MocDataProcessed](https://huggingface.co/datasets/LvlUrArti/MocDataProcessed) ซึ่งระบุว่าเป็นข้อมูลที่ประมวลผลจากเว็บไซต์ Prydwen โปรดให้เครดิต LvlUrArti เมื่อเผยแพร่หรือวิเคราะห์ข้อมูลต่อ สามารถอัปเดต snapshot ด้วย `npm run sync-build-guides` ได้ โดยสคริปต์จะเลือกเวอร์ชันล่าสุดที่มีใน dataset และจับคู่ตัวละครตาม ID จาก `js/avatars-data.js`.
+134
View File
@@ -3209,3 +3209,137 @@ input:checked + .slider:before {
flex-shrink: 0;
opacity: 0.85;
}
.build-guide-content {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.build-guide-topline,
.build-guide-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.build-guide-title {
color: var(--text-highlight);
font-size: 1.05rem;
font-weight: 800;
}
.build-guide-meta,
.build-guide-note,
.build-guide-sample,
.build-guide-footer {
color: var(--text-muted);
font-size: 0.78rem;
}
.build-guide-note {
margin: 0;
line-height: 1.55;
}
.build-guide-sample {
display: inline-flex;
align-self: flex-start;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 999px;
background: rgba(56, 189, 248, 0.08);
color: #bae6fd;
padding: 0.3rem 0.7rem;
}
.build-guide-low-sample {
color: var(--accent-gold);
font-size: 0.75rem;
}
.build-guide-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.build-guide-card {
min-width: 0;
padding: 0.85rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: rgba(18, 24, 38, 0.72);
}
.build-guide-card h4 {
margin: 0 0 0.6rem;
color: var(--accent-cyan);
font-size: 0.85rem;
}
.build-guide-ranked-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.build-guide-ranked-list li {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.6rem;
font-size: 0.78rem;
line-height: 1.4;
}
.build-guide-ranked-list li span {
min-width: 0;
}
.build-guide-ranked-list li strong {
flex-shrink: 0;
color: var(--accent-gold);
font-size: 0.72rem;
}
.build-guide-empty,
.build-guide-no-data {
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.build-guide-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.build-guide-stat-grid > div > span {
display: block;
margin-bottom: 0.4rem;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.build-guide-footer {
border-top: 1px solid var(--border-color);
padding-top: 0.65rem;
}
.build-guide-footer a:not(.btn) {
color: var(--accent-cyan);
}
@media (max-width: 640px) {
.build-guide-grid,
.build-guide-stat-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+9 -1
View File
@@ -869,6 +869,9 @@
<button type="button" class="modal-subtab-btn" data-modaltab="modaltab-showcase" data-i18n="modalTabShowcase">
🖼️ การ์ดสรุปบิลด์
</button>
<button type="button" class="modal-subtab-btn" data-modaltab="modaltab-build-guide" data-i18n="modalTabBuildGuide">
📖 แนวทาง Build
</button>
</div>
<!-- Sub Tab 0: Overview & Combat Stats (Neonteam style https://srtools.neonteam.dev/1001/detail) -->
@@ -1317,6 +1320,11 @@
</div>
</div>
<!-- Sub Tab 5: Per-character build data and Prydwen guide -->
<div id="modaltab-build-guide" class="modal-subtab-content" style="display: none;">
<div id="build-guide-content" class="build-guide-content"></div>
</div>
<!-- Modal Footer -->
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem; justify-content: flex-end; flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" id="btn-modal-cancel-avatar" data-i18n="modalBtnClose">
@@ -1673,6 +1681,6 @@
<div class="toast-container" id="toast-container"></div>
<!-- Script Modules -->
<script type="module" src="js/app.js?v=2.3"></script>
<script type="module" src="js/app.js?v=2.4"></script>
</body>
</html>
+91 -8
View File
@@ -1,6 +1,7 @@
import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.3';
import { PRYDWEN_BUILD_GUIDES } from './build-guides-data.js';
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.4';
import {
getAvatarIconUrl,
getLightconeArtUrl,
@@ -2494,10 +2495,76 @@ function switchModalSubtab(targetId) {
renderModalRelicsTab(aid);
} else if (targetId === 'modaltab-skills') {
renderModalSkillsTab(aid);
} else if (targetId === 'modaltab-build-guide') {
renderCharacterBuildGuide(aid);
}
}
}
function renderGuideRankedList(items, emptyText) {
if (!items?.length) return `<div class="build-guide-empty">${escapeHtml(emptyText)}</div>`;
return `<ol class="build-guide-ranked-list">${items.map(item => `
<li><span>${escapeHtml(item.name)}</span><strong>${Number(item.usage).toLocaleString(undefined, { maximumFractionDigits: 2 })}%</strong></li>
`).join('')}</ol>`;
}
const BUILD_GUIDE_MODE_KEYS = {
'Memory of Chaos': 'buildGuideModeMoc',
'Pure Fiction': 'buildGuideModePf',
'Apocalyptic Shadow': 'buildGuideModeAs',
'Anomaly Arbitration': 'buildGuideModeAa'
};
function renderCharacterBuildGuide(avatarId) {
const container = document.getElementById('build-guide-content');
if (!container) return;
const profile = PRYDWEN_BUILD_GUIDES.profiles[String(avatarId)];
const empty = t('buildGuideNoSample');
const source = PRYDWEN_BUILD_GUIDES.source;
const sampleCount = Number(profile?.count || profile?.sampleSize || 0);
const modeKey = BUILD_GUIDE_MODE_KEYS[profile?.mode];
const modeUrl = profile?.slug
? `https://www.prydwen.gg/star-rail/characters/${encodeURIComponent(profile.slug)}`
: 'https://www.prydwen.gg/star-rail/characters/';
const sampleLabel = sampleCount > 0
? t('buildGuideSampleCount', { count: sampleCount.toLocaleString(), mode: modeKey ? t(modeKey) : (profile.mode || 'endgame') })
: t('buildGuideSampleUnavailable');
container.innerHTML = `
<div class="build-guide-topline">
<div>
<div class="build-guide-title">${escapeHtml(t('buildGuideTitle'))}</div>
<div class="build-guide-meta">${escapeHtml(t('buildGuideSnapshot', { version: source.version, date: source.collectedAt }))}</div>
</div>
<a class="btn btn-sm btn-secondary" href="${escapeHtml(modeUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t('buildGuideOpenPrydwen'))} ↗</a>
</div>
<p class="build-guide-note">${escapeHtml(t('buildGuideDescription'))}</p>
<div class="build-guide-sample">${escapeHtml(sampleLabel)}</div>
${profile && sampleCount > 0 && sampleCount < 20 ? `<div class="build-guide-low-sample">${escapeHtml(t('buildGuideLowSample'))}</div>` : ''}
${profile && sampleCount > 0 ? `
<div class="build-guide-grid">
<section class="build-guide-card"><h4>${escapeHtml(t('buildGuideLightCones'))}</h4>${renderGuideRankedList(profile.lightCones, empty)}</section>
<section class="build-guide-card"><h4>${escapeHtml(t('buildGuideRelicSets'))}</h4>${renderGuideRankedList(profile.relicSets, empty)}</section>
<section class="build-guide-card"><h4>${escapeHtml(t('buildGuidePlanarSets'))}</h4>${renderGuideRankedList(profile.planarSets, empty)}</section>
<section class="build-guide-card"><h4>${escapeHtml(t('buildGuideMainStats'))}</h4>
<div class="build-guide-stat-grid">
<div><span>${escapeHtml(t('buildGuideBody'))}</span>${renderGuideRankedList(profile.mainStats.body, empty)}</div>
<div><span>${escapeHtml(t('buildGuideFeet'))}</span>${renderGuideRankedList(profile.mainStats.feet, empty)}</div>
<div><span>${escapeHtml(t('buildGuideSphere'))}</span>${renderGuideRankedList(profile.mainStats.sphere, empty)}</div>
<div><span>${escapeHtml(t('buildGuideRope'))}</span>${renderGuideRankedList(profile.mainStats.rope, empty)}</div>
</div>
</section>
</div>
` : `<div class="build-guide-no-data">${escapeHtml(t('buildGuideNoData'))}</div>`}
<div class="build-guide-footer">
<span>${escapeHtml(t('buildGuideCredit', { credit: source.credit }))} <a href="https://huggingface.co/datasets/LvlUrArti/MocDataProcessed" target="_blank" rel="noopener noreferrer">${escapeHtml(source.dataset)}</a></span>
<button type="button" class="btn btn-sm btn-primary" id="btn-guide-open-equipment">${escapeHtml(t('buildGuideOpenEquipment'))}</button>
</div>
`;
document.getElementById('btn-guide-open-equipment')?.addEventListener('click', () => switchModalSubtab('modaltab-relics'));
}
function setupModalSubtabs() {
const tabBtns = document.querySelectorAll('.modal-subtab-btn');
tabBtns.forEach(btn => {
@@ -7109,7 +7176,7 @@ function setupEvents() {
});
if (unequippedCount > 0) {
if (state.isConnected) await saveFreesrChangesToServer();
showToast(t('toastRelicsCleared') || `ถอดรีลิกส์เรียบร้อย (${unequippedCount} ชิ้น)`, 'info');
showToast(t('toastRelicsCleared', { count: unequippedCount }) || `ถอดรีลิกส์เรียบร้อย (${unequippedCount} ชิ้น)`, 'info');
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
renderShowcaseCard(aid);
@@ -7872,6 +7939,8 @@ const analystState = {
currentReport: null,
isFetching: false,
lastBattleTimestamp: 0,
lastReportRevision: 0,
isLive: false,
autoSyncInterval: null
};
@@ -7999,14 +8068,20 @@ async function fetchLatestBattleReport(silent = false) {
try {
const data = await apiCall('/api/battle_report', 'GET');
if (data && data.has_report && data.report) {
const isNew = data.report.timestamp !== analystState.lastBattleTimestamp;
const revision = Number(data.revision || data.report.timestamp || 0);
const isLive = data.is_live === true || data.report.end_status === 'LIVE';
const isNew = revision !== analystState.lastReportRevision || isLive !== analystState.isLive;
analystState.currentReport = data.report;
analystState.lastBattleTimestamp = data.report.timestamp;
renderDamageAnalyst();
updateDashboardBattleBanner();
analystState.lastReportRevision = revision;
analystState.isLive = isLive;
if (isNew) {
renderDamageAnalyst();
updateDashboardBattleBanner();
}
if (!silent) {
showToast(t('analystToastFetched'), 'success');
} else if (isNew) {
} else if (isNew && !isLive) {
showToast(t('analystToastFetched'), 'info');
}
} else {
@@ -8097,7 +8172,11 @@ function renderDamageAnalyst() {
}
if (el.badgeAnalystStatus) {
if (report.end_status === 'WIN') {
if (analystState.isLive || report.end_status === 'LIVE') {
el.badgeAnalystStatus.textContent = t('analystStatusLive');
el.badgeAnalystStatus.style.background = 'rgba(34, 197, 94, 0.2)';
el.badgeAnalystStatus.style.color = '#4ade80';
} else if (report.end_status === 'WIN') {
el.badgeAnalystStatus.textContent = t('analystStatusWin');
el.badgeAnalystStatus.style.background = 'rgba(34, 197, 94, 0.15)';
el.badgeAnalystStatus.style.color = '#4ade80';
@@ -8318,6 +8397,8 @@ function exportBattleReportJson() {
async function clearBattleReport() {
analystState.currentReport = null;
analystState.lastBattleTimestamp = 0;
analystState.lastReportRevision = 0;
analystState.isLive = false;
try {
await apiCall('/api/battle_report/reset', 'POST');
} catch (_) {}
@@ -8482,6 +8563,8 @@ function setupDamageAnalystEvents() {
if (report && (report.avatars || report.team_total_damage !== undefined)) {
analystState.currentReport = report;
analystState.lastBattleTimestamp = report.timestamp || Date.now();
analystState.lastReportRevision = Date.now();
analystState.isLive = false;
renderDamageAnalyst();
updateDashboardBattleBanner();
showToast(t('analystToastImported'), 'success');
@@ -8513,7 +8596,7 @@ function setupDamageAnalystEvents() {
if (el.chkAnalystAutosync && el.chkAnalystAutosync.checked && state.isConnected) {
fetchLatestBattleReport(true);
}
}, 3000);
}, 1000);
}
}
File diff suppressed because it is too large. Load diff
+1301 -2
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -5,6 +5,7 @@
"main": "index.html",
"scripts": {
"sync-branch": "node scripts/sync-branch.js",
"sync-build-guides": "node scripts/sync-build-guides.mjs",
"dev": "node scripts/sync-branch.js && npx serve . -l 5173",
"start": "node scripts/sync-branch.js && npx serve . -l 5173",
"serve": "npx serve . -l 5173"
+151
View File
@@ -0,0 +1,151 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const avatarSource = await readFile(resolve(repoRoot, 'js/avatars-data.js'), 'utf8');
const metadataMatch = avatarSource.match(/export const AVATAR_METADATA = (\{[\s\S]*?\n\});/);
if (!metadataMatch) throw new Error('Could not read AVATAR_METADATA from js/avatars-data.js');
const avatarMetadata = JSON.parse(metadataMatch[1]);
const headers = { 'User-Agent': 'GaC-Srtool build guide data sync' };
const treeResponse = await fetch('https://huggingface.co/api/datasets/LvlUrArti/MocDataProcessed/tree/main?recursive=true', { headers });
if (!treeResponse.ok) throw new Error(`Dataset file listing failed: HTTP ${treeResponse.status}`);
const files = await treeResponse.json();
const versions = [...new Set(files
.filter(file => file.type === 'directory' && /^\d+\.\d+\.\d+$/.test(file.path))
.map(file => file.path))]
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
const version = versions.at(-1);
if (!version) throw new Error('No versioned build snapshot was found in the dataset');
const [configResponse, buildsResponse] = await Promise.all([
fetch('https://huggingface.co/datasets/LvlUrArti/MocDataProcessed/resolve/main/config.json', { headers }),
fetch(`https://huggingface.co/datasets/LvlUrArti/MocDataProcessed/resolve/main/${version}/builds.json`, { headers })
]);
if (!configResponse.ok || !buildsResponse.ok) {
throw new Error(`Could not download snapshot ${version} (config ${configResponse.status}, builds ${buildsResponse.status})`);
}
const [config, sourceBuilds] = await Promise.all([configResponse.json(), buildsResponse.json()]);
const snapshot = config[version];
if (!snapshot?.collect_date || !Array.isArray(sourceBuilds)) {
throw new Error(`Snapshot ${version} is missing its date or build rows`);
}
const slugOverrides = {
'1001': 'march-7th',
'1112': 'topaz',
'1213': 'imbibitor-lunae',
'1225': 'tingyun-fugue',
'1224': 'march-7th-swordmaster',
'1413': 'march-7th-evernight',
'1414': 'dan-heng-permansor-terrae',
'1506': 'silver-wolf-lv-999',
'1507': 'blade-mortenax',
'1510': 'himeko-nova',
'1511': 'aeon-aha',
'1512': 'robin-summeretto',
'1513': 'aventurine-waveflair',
'8001': 'trailblazer-destruction',
'8002': 'trailblazer-destruction',
'8003': 'trailblazer-preservation',
'8004': 'trailblazer-preservation',
'8005': 'trailblazer-harmony',
'8006': 'trailblazer-harmony',
'8007': 'trailblazer-remembrance',
'8008': 'trailblazer-remembrance',
'8009': 'trailblazer-elation',
'8010': 'trailblazer-elation'
};
function slugify(name = '') {
return name.toLowerCase()
.replace(/•/g, ' ')
.replace(/&/g, ' ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function rankedValues(row, namePrefix, usagePrefix, count = 4) {
const values = [];
for (let index = 1; index <= count; index += 1) {
const name = String(row[`${namePrefix}${index}`] || '').trim();
const usage = Number(row[`${usagePrefix}${index}_app`] ?? row[`${namePrefix}${index}_app`] ?? 0);
if (name && usage > 0) values.push({ name, usage: Math.round(usage * 100) / 100 });
}
return values.sort((left, right) => right.usage - left.usage);
}
function rankedStats(row, statPrefix) {
const stats = [];
for (let index = 1; index <= 3; index += 1) {
const name = String(row[`${statPrefix}_stats_${index}`] || '').trim();
const usage = Number(row[`${statPrefix}_stats_${index}_app`] || 0);
if (name && usage > 0) stats.push({ name, usage: Math.round(usage * 100) / 100 });
}
return stats.sort((left, right) => right.usage - left.usage);
}
const endgameModes = [
['moc', 'Memory of Chaos'],
['pf', 'Pure Fiction'],
['as', 'Apocalyptic Shadow'],
['aa', 'Anomaly Arbitration']
];
const getLargestSample = row => endgameModes.reduce((best, [key, label]) => {
const count = Number(row[`sample_size_players_${key}`] || 0);
return count > best.count ? { count, mode: label } : best;
}, { count: 0, mode: '' });
// The source file includes current and comparison rows with duplicate slugs; keep
// the row with the largest current endgame sample instead of letting the last
// comparison row silently replace it.
const sourceBySlug = new Map();
for (const row of sourceBuilds) {
const previous = sourceBySlug.get(row.char);
if (!previous || getLargestSample(row).count > getLargestSample(previous).count) {
sourceBySlug.set(row.char, row);
}
}
const profiles = {};
const missingSlugs = new Set();
for (const [avatarId, metadata] of Object.entries(avatarMetadata)) {
const slug = slugOverrides[avatarId] || slugify(metadata.names?.en);
const row = sourceBySlug.get(slug);
if (!row) {
missingSlugs.add(slug);
continue;
}
profiles[avatarId] = {
slug,
...getLargestSample(row),
lightCones: rankedValues(row, 'weapons_', 'weapons_', 5),
relicSets: rankedValues(row, 'artifacts_', 'artifacts_', 5),
planarSets: rankedValues(row, 'planars_', 'planars_', 5),
mainStats: {
body: rankedStats(row, 'body'),
feet: rankedStats(row, 'feet'),
sphere: rankedStats(row, 'sphere'),
rope: rankedStats(row, 'rope')
}
};
}
const output = {
source: {
dataset: 'LvlUrArti/MocDataProcessed',
license: 'MIT',
credit: 'LvlUrArti',
version,
collectedAt: snapshot.collect_date,
note: 'Prydwen endgame build usage sample; not a curated or official ranking.'
},
profiles
};
const outputPath = resolve(repoRoot, 'js/build-guides-data.js');
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `// Generated by scripts/sync-build-guides.mjs; see README for source and attribution.\nexport const PRYDWEN_BUILD_GUIDES = ${JSON.stringify(output, null, 2)};\n`, 'utf8');
console.log(`Wrote ${Object.keys(profiles).length} avatar profiles from snapshot ${version} (${snapshot.collect_date}).`);
if (missingSlugs.size) console.log(`No dataset row for: ${[...missingSlugs].sort().join(', ')}`);