1880 lines
73 KiB
JavaScript
1880 lines
73 KiB
JavaScript
/* ============================================================
|
||
TH1 Dashboard - Game Balance Analysis (gamebalance.js)
|
||
============================================================ */
|
||
|
||
// ── Enum data (from C# source) ──
|
||
|
||
const GB_UNIT_TYPES = {
|
||
0: { en: 'None', cn: '-' },
|
||
1: { en: 'Warrior', cn: '步兵' },
|
||
2: { en: 'Rider', cn: '骑兵' },
|
||
3: { en: 'Archer', cn: '弓箭手' },
|
||
4: { en: 'Defender', cn: '盾兵' },
|
||
5: { en: 'Knights', cn: '重骑兵' },
|
||
6: { en: 'Catapult', cn: '炮手' },
|
||
7: { en: 'Swordsman', cn: '剑士' },
|
||
8: { en: 'Cloak', cn: '间谍' },
|
||
9: { en: 'Minder', cn: '脑控' },
|
||
10: { en: 'Boat', cn: '小船' },
|
||
11: { en: 'Ship', cn: '远战船' },
|
||
12: { en: 'RammerShip', cn: '近战船' },
|
||
13: { en: 'BomberShip', cn: '战舰' },
|
||
14: { en: 'Giant', cn: '英雄' },
|
||
15: { en: 'BigGuy', cn: '巨汉' },
|
||
16: { en: 'Phantom', cn: '幻影' },
|
||
17: { en: 'Dabber', cn: 'Dabber' },
|
||
18: { en: 'Juggernaut', cn: '巨人战船' },
|
||
19: { en: 'GiantJuggernaut', cn: '英雄战船' },
|
||
20: { en: 'KaguyaFrenchAnimalWarrior', cn: '竹林兽兵' },
|
||
21: { en: 'KaguyaFrenchWarrior', cn: '竹林步兵' },
|
||
22: { en: 'KaguyaFrenchCatapult', cn: '竹林炮手' },
|
||
23: { en: 'KaguyaFrenchMokouEgg', cn: '妹红蛋' },
|
||
24: { en: 'KaguyaFrenchReisenIllusion', cn: '月兔幻象' },
|
||
25: { en: 'KaguyaFrenchWolf', cn: '竹林狼' },
|
||
26: { en: 'RemiliaEgyptianKoakuma', cn: '小恶魔' },
|
||
27: { en: 'RemiliaEgyptianKoakumaLion', cn: '小恶魔狮' },
|
||
28: { en: 'MoriyaRider', cn: '守矢骑兵' },
|
||
29: { en: 'MoriyaKnight', cn: '守矢重骑' },
|
||
30: { en: 'MoriyaHebi', cn: '守矢蛇' },
|
||
31: { en: 'WolfJuggernaut', cn: '狼战船' },
|
||
32: { en: 'BonePile', cn: '骨堆' },
|
||
33: { en: 'KomeijiIndianBigGuy', cn: '古明地巨汉' },
|
||
34: { en: 'KomeijiIndianRider', cn: '古明地骑兵' },
|
||
35: { en: 'KomeijiIndianKnight', cn: '古明地重骑' },
|
||
36: { en: 'KomeijiIndianArcher', cn: '古明地弓手' },
|
||
37: { en: 'KomeijiIndianCatapult', cn: '古明地炮手' },
|
||
38: { en: 'KomeijiIndianShip', cn: '古明地战船' },
|
||
39: { en: 'KomeijiIndianBomberShip', cn: '古明地远船' },
|
||
40: { en: 'KomeijiIndianJuggernaut', cn: '古明地巨人船' },
|
||
};
|
||
|
||
const GB_GIANT_TYPES = {
|
||
0: { en: 'None', cn: '-' },
|
||
1: { en: 'Flandre', cn: '芙兰朵露' },
|
||
2: { en: 'Remilia', cn: '蕾米莉亚' },
|
||
3: { en: 'Sakuya', cn: '十六夜咲夜' },
|
||
4: { en: 'Meiling', cn: '红美铃' },
|
||
5: { en: 'Patchouli', cn: '帕秋莉' },
|
||
6: { en: 'Kaguya', cn: '辉夜' },
|
||
7: { en: 'Reisen', cn: '铃仙' },
|
||
8: { en: 'Tewi', cn: '因幡帝' },
|
||
9: { en: 'Eirin', cn: '永琳' },
|
||
10: { en: 'Mokou', cn: '妹红' },
|
||
11: { en: 'Kanako', cn: '神奈子' },
|
||
12: { en: 'Suwako', cn: '诹访子' },
|
||
13: { en: 'Sanae', cn: '早苗' },
|
||
14: { en: 'Aya', cn: '文' },
|
||
15: { en: 'Momiji', cn: '椛' },
|
||
16: { en: 'Satori', cn: '觉' },
|
||
17: { en: 'Koishi', cn: '恋' },
|
||
18: { en: 'Utsuho', cn: '空' },
|
||
19: { en: 'Yuugi', cn: '勇仪' },
|
||
20: { en: 'Rin', cn: '燐' },
|
||
21: { en: 'Reimu', cn: '灵梦' },
|
||
22: { en: 'Sumireko', cn: '堇子' },
|
||
23: { en: 'Kasen', cn: '华扇' },
|
||
24: { en: 'Aunn', cn: '阿吽' },
|
||
25: { en: 'Suika', cn: '萃香' },
|
||
26: { en: 'Byakuren', cn: '白莲' },
|
||
31: { en: 'Miko', cn: '神子' },
|
||
36: { en: 'Zanmu', cn: '斬梦' },
|
||
};
|
||
|
||
const GB_FORCE_NAMES = {
|
||
0: 'Common', 1: 'Remilia', 2: 'Kaguya', 3: 'Kanako', 4: 'Satori',
|
||
5: 'Reimu', 6: 'Byakuren', 7: 'Miko', 8: 'Zanmu',
|
||
9: 'Yuyuko', 10: 'Hecatia', 11: 'Megumu', 12: 'Cirno',
|
||
13: 'Yorihime', 14: 'Tenshi', 15: 'Ubame', 16: 'Seija', 17: 'Marisa',
|
||
};
|
||
|
||
const GB_CIV_NAMES = {
|
||
0: 'Common', 1: 'Egyptian', 2: 'French', 3: 'Germany', 4: 'Indian',
|
||
5: 'Norway', 6: 'Britain', 7: 'Persian', 8: 'Byzantine',
|
||
9: 'Sumerian', 10: 'Mayan', 11: 'Malian', 12: 'Greek',
|
||
13: 'Khmer', 14: 'Aztec', 15: 'Incan', 16: 'Mongolian', 17: 'Arabian',
|
||
};
|
||
|
||
// GiantType → portrait image filename mapping
|
||
const GB_HERO_PORTRAITS = {
|
||
1: 'EgyptianFlandre.png',
|
||
2: 'EgyptianRemilia.png',
|
||
3: 'EgyptianSakuya.png',
|
||
4: 'EgyptianMeiling.png',
|
||
5: 'EgyptianPatchouli.png',
|
||
6: 'FrenchKaguya.png',
|
||
7: 'FrenchReisen.png',
|
||
8: 'FrenchTewi.png',
|
||
9: 'FrenchEirin.png',
|
||
10: 'FrenchMokou.png',
|
||
11: 'GermanyKanako.png',
|
||
12: 'GermanySuwako.png',
|
||
13: 'GermanySanae.png',
|
||
14: 'GermanyAya.png',
|
||
15: 'GermanyMomiji.png',
|
||
16: 'IndianSatori.png',
|
||
17: 'IndianKoishi.png',
|
||
18: 'IndianUtsuho.png',
|
||
19: 'IndianYuugi.png',
|
||
20: 'IndianRin.png',
|
||
21: 'NorwayReimu.png',
|
||
26: 'BritishByakuren.png',
|
||
31: 'PersianMiko.png',
|
||
36: 'ByzantineZanmu.png',
|
||
};
|
||
|
||
const GB_TIER_LABELS = ['夯', '顶级', '人上人', 'NPC', '拉'];
|
||
const GB_TIER_COLORS = ['#ef4444', '#f59e0b', '#10b981', '#6b7280', '#3b82f6'];
|
||
|
||
// ── State ──
|
||
|
||
let gbVersions = []; // [{version, matchCount, selected}]
|
||
let gbCurrentPage = 1;
|
||
let gbPageSize = 20;
|
||
let gbTotalMatches = 0;
|
||
let gbLastMatches = [];
|
||
let gbInitialized = false;
|
||
|
||
// Hero balance state
|
||
let gbhHeroData = null; // API response
|
||
let gbhMetric = 'appearRate';
|
||
let gbhpPricingData = null; // Latest balance modeling output
|
||
let gbhpSort = 'finalDesc';
|
||
let gbhdData = null; // Static hero data from Dashboard export
|
||
let gbhdRendered = false;
|
||
let gbhuData = null; // Hero upgrade turn estimation
|
||
let gbhuRendered = false;
|
||
|
||
const GBHD_CLASS_ORDER = [1, 2, 3, 4, 5];
|
||
const GBHD_CLASS_NAMES = {
|
||
1: '王',
|
||
2: '后',
|
||
3: '相',
|
||
4: '马',
|
||
5: '车',
|
||
};
|
||
const GBHD_FORCE_ORDER = [1, 2, 3, 4, 5];
|
||
const GBHD_ROW_SPECS = [
|
||
{ key: 'task', label: '任务', type: 'task' },
|
||
{ key: 'hp', label: '生命', type: 'stat' },
|
||
{ key: 'attack', label: '攻击', type: 'stat' },
|
||
{ key: 'defense', label: '防御', type: 'stat' },
|
||
{ key: 'move', label: '移动', type: 'stat' },
|
||
{ key: 'range', label: '射程', type: 'stat' },
|
||
{ key: 'sight', label: '视野', type: 'stat' },
|
||
{ key: 'skills', label: '技能', type: 'skills' },
|
||
];
|
||
|
||
const GBHU_TASK_TYPE_NAMES = {
|
||
0: '累计承受伤害',
|
||
1: '累计造成伤害',
|
||
2: '累计击杀',
|
||
3: '累计治疗',
|
||
4: '开启遗迹',
|
||
5: '探索迷雾',
|
||
6: '占领城市',
|
||
7: '增加技能层数',
|
||
8: '技能生效',
|
||
9: '帝金币收益',
|
||
10: '技能掉层',
|
||
11: '遇见玩家',
|
||
12: '添加特殊地格',
|
||
13: '帝国金币收益',
|
||
14: '研发科技',
|
||
15: '生产/拥有单位',
|
||
16: '指定单位击杀',
|
||
17: '御神签极端结果',
|
||
18: '溅射伤害',
|
||
19: '指定技能单位死亡',
|
||
20: '指定单位探索',
|
||
21: '帝国成员开遗迹',
|
||
22: '占村/占城',
|
||
23: '造成或承受伤害',
|
||
24: '设置灵异珠',
|
||
25: '阿吽石化承伤',
|
||
};
|
||
|
||
const GBHU_SPEED_LABELS = {
|
||
fast: '偏快',
|
||
normal: '常规',
|
||
slow: '偏慢',
|
||
gated: '门槛',
|
||
};
|
||
|
||
// ── Init (lazy, called when panel activates) ──
|
||
|
||
function gbInit() {
|
||
if (gbInitialized) return;
|
||
gbInitialized = true;
|
||
|
||
// Sub-tab switching
|
||
const subTabs = document.getElementById('gb-sub-tabs');
|
||
if (subTabs) {
|
||
subTabs.addEventListener('click', e => {
|
||
const btn = e.target.closest('.sub-tab');
|
||
if (!btn) return;
|
||
subTabs.querySelectorAll('.sub-tab').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const sub = btn.dataset.sub;
|
||
document.querySelectorAll('#panel-gamebalance .sub-panel').forEach(p => {
|
||
p.classList.toggle('active', p.id === 'sub-' + sub);
|
||
});
|
||
if (sub === 'gb-hero-data') {
|
||
gbhdLoadData();
|
||
}
|
||
if (sub === 'gb-hero-upgrade') {
|
||
gbhuLoadData();
|
||
}
|
||
if (sub === 'gb-hero' && !gbhpPricingData) {
|
||
gbhpLoadPricing();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Populate dropdowns
|
||
populateUnitTypeDropdown();
|
||
populateGiantTypeDropdown();
|
||
populateLevelDropdown();
|
||
|
||
// Wire checkbox ↔ dropdown enable
|
||
wireFilterCheckbox('gb-filter-unittype-on', 'gb-filter-unittype');
|
||
wireFilterCheckbox('gb-filter-gianttype-on', 'gb-filter-gianttype');
|
||
wireFilterCheckbox('gb-filter-level-on', 'gb-filter-level');
|
||
|
||
// Load versions (shared between tabs)
|
||
loadVersions();
|
||
|
||
// Hero balance: metric radio switch
|
||
const metricBar = document.getElementById('gbh-metric-bar');
|
||
if (metricBar) {
|
||
metricBar.addEventListener('change', e => {
|
||
if (e.target.name === 'gbh-metric') {
|
||
gbhMetric = e.target.value;
|
||
if (gbhHeroData) gbhRenderTierGrid();
|
||
}
|
||
});
|
||
}
|
||
|
||
const pricingSort = document.getElementById('gbhp-sort');
|
||
if (pricingSort) {
|
||
pricingSort.addEventListener('change', e => {
|
||
gbhpSort = e.target.value;
|
||
gbhpRenderPricingTable();
|
||
});
|
||
}
|
||
|
||
if (document.getElementById('sub-gb-hero')?.classList.contains('active')) {
|
||
gbhpLoadPricing();
|
||
}
|
||
if (document.getElementById('sub-gb-hero-data')?.classList.contains('active')) {
|
||
gbhdLoadData();
|
||
}
|
||
if (document.getElementById('sub-gb-hero-upgrade')?.classList.contains('active')) {
|
||
gbhuLoadData();
|
||
}
|
||
}
|
||
|
||
function wireFilterCheckbox(checkId, selectId) {
|
||
const check = document.getElementById(checkId);
|
||
const sel = document.getElementById(selectId);
|
||
if (!check || !sel) return;
|
||
check.addEventListener('change', () => {
|
||
sel.disabled = !check.checked;
|
||
if (!check.checked) sel.value = '';
|
||
});
|
||
}
|
||
|
||
function populateUnitTypeDropdown() {
|
||
const sel = document.getElementById('gb-filter-unittype');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="">全部</option>';
|
||
for (const [id, info] of Object.entries(GB_UNIT_TYPES)) {
|
||
if (parseInt(id) === 0) continue;
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = `${info.cn} (${info.en})`;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
function populateGiantTypeDropdown() {
|
||
const sel = document.getElementById('gb-filter-gianttype');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="">全部</option>';
|
||
for (const [id, info] of Object.entries(GB_GIANT_TYPES)) {
|
||
if (parseInt(id) === 0) continue;
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = `${info.cn} (${info.en})`;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
function populateLevelDropdown() {
|
||
const sel = document.getElementById('gb-filter-level');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="">全部</option>';
|
||
for (let i = 0; i <= 5; i++) {
|
||
const opt = document.createElement('option');
|
||
opt.value = i;
|
||
opt.textContent = 'Lv.' + i;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
// ── Version loading ──
|
||
|
||
async function loadVersions() {
|
||
try {
|
||
const resp = await fetch('/api/oss/versions?t=' + Date.now());
|
||
if (!resp.ok) throw new Error('API error');
|
||
const data = await resp.json();
|
||
gbVersions = data.map(v => ({ ...v, selected: true }));
|
||
renderVersionList();
|
||
gbhRenderVersionList();
|
||
} catch (e) {
|
||
document.getElementById('gb-version-list').innerHTML =
|
||
'<span class="text-muted">无法加载版本数据,请先运行 "更新数据"</span>';
|
||
document.getElementById('gb-version-badge').textContent = '0 版本';
|
||
}
|
||
}
|
||
|
||
function renderVersionList() {
|
||
const container = document.getElementById('gb-version-list');
|
||
const badge = document.getElementById('gb-version-badge');
|
||
if (!container) return;
|
||
|
||
if (gbVersions.length === 0) {
|
||
container.innerHTML = '<span class="text-muted">未找到版本数据。请在 Unity 中运行 "Tools/OSS 导出 JSON (Dashboard)",然后点击 "更新数据"。</span>';
|
||
badge.textContent = '0 版本';
|
||
return;
|
||
}
|
||
|
||
let totalMatches = 0;
|
||
container.innerHTML = gbVersions.map((v, i) => {
|
||
totalMatches += v.matchCount;
|
||
return `<label class="gb-version-item">
|
||
<input type="checkbox" ${v.selected ? 'checked' : ''} onchange="gbToggleVersion(${i}, this.checked)">
|
||
<span class="gb-version-name">${v.version}</span>
|
||
<span class="gb-version-count">${v.matchCount} 局</span>
|
||
</label>`;
|
||
}).join('');
|
||
|
||
const selCount = gbVersions.filter(v => v.selected).length;
|
||
badge.textContent = `${selCount}/${gbVersions.length} 版本 (${totalMatches} 局)`;
|
||
}
|
||
|
||
function gbToggleVersion(index, checked) {
|
||
gbVersions[index].selected = checked;
|
||
_updateVersionBadges();
|
||
}
|
||
|
||
function gbSelectAllVersions(selectAll) {
|
||
gbVersions.forEach(v => v.selected = selectAll);
|
||
renderVersionList();
|
||
gbhRenderVersionList();
|
||
}
|
||
|
||
function _updateVersionBadges() {
|
||
const selCount = gbVersions.filter(v => v.selected).length;
|
||
const total = gbVersions.reduce((s, v) => s + (v.selected ? v.matchCount : 0), 0);
|
||
const txt = `${selCount}/${gbVersions.length} 版本 (${total} 局)`;
|
||
const b1 = document.getElementById('gb-version-badge');
|
||
const b2 = document.getElementById('gbh-version-badge');
|
||
if (b1) b1.textContent = txt;
|
||
if (b2) b2.textContent = txt;
|
||
}
|
||
|
||
// ── Search ──
|
||
|
||
async function gbSearch(page) {
|
||
if (page !== undefined) gbCurrentPage = page;
|
||
else gbCurrentPage = 1;
|
||
|
||
const selectedVersions = gbVersions.filter(v => v.selected).map(v => v.version);
|
||
if (selectedVersions.length === 0) {
|
||
showToast('请至少选择一个版本');
|
||
return;
|
||
}
|
||
|
||
// Build query params
|
||
const params = new URLSearchParams();
|
||
params.set('versions', selectedVersions.join(','));
|
||
params.set('page', gbCurrentPage);
|
||
params.set('pageSize', gbPageSize);
|
||
|
||
// Filters
|
||
const utOn = document.getElementById('gb-filter-unittype-on');
|
||
const utSel = document.getElementById('gb-filter-unittype');
|
||
if (utOn && utOn.checked && utSel && utSel.value) {
|
||
params.set('unitType', utSel.value);
|
||
}
|
||
|
||
const gtOn = document.getElementById('gb-filter-gianttype-on');
|
||
const gtSel = document.getElementById('gb-filter-gianttype');
|
||
if (gtOn && gtOn.checked && gtSel && gtSel.value) {
|
||
params.set('giantType', gtSel.value);
|
||
}
|
||
|
||
const lvOn = document.getElementById('gb-filter-level-on');
|
||
const lvSel = document.getElementById('gb-filter-level');
|
||
if (lvOn && lvOn.checked && lvSel && lvSel.value) {
|
||
params.set('level', lvSel.value);
|
||
}
|
||
|
||
// Show loading
|
||
const tbody = document.getElementById('gb-data-tbody');
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="8" class="gb-loading">查询中...</td></tr>';
|
||
|
||
try {
|
||
const resp = await fetch('/api/oss/matches?' + params.toString());
|
||
if (!resp.ok) throw new Error('API error');
|
||
const result = await resp.json();
|
||
|
||
gbTotalMatches = result.total;
|
||
gbLastMatches = result.matches;
|
||
gbCurrentPage = result.page;
|
||
gbPageSize = result.pageSize;
|
||
|
||
renderMatchTable();
|
||
renderPagination();
|
||
renderStats();
|
||
|
||
// Update badge
|
||
document.getElementById('gb-data-badge').textContent = `${gbTotalMatches} 局`;
|
||
document.getElementById('gb-stats-badge').textContent = `${gbTotalMatches} 局`;
|
||
|
||
} catch (e) {
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="8" class="gb-loading">查询失败: ' + e.message + '</td></tr>';
|
||
}
|
||
}
|
||
|
||
function gbResetFilters() {
|
||
['gb-filter-unittype-on', 'gb-filter-gianttype-on', 'gb-filter-level-on'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.checked = false;
|
||
});
|
||
['gb-filter-unittype', 'gb-filter-gianttype', 'gb-filter-level'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) { el.value = ''; el.disabled = true; }
|
||
});
|
||
}
|
||
|
||
// ── Rendering ──
|
||
|
||
function renderMatchTable() {
|
||
const tbody = document.getElementById('gb-data-tbody');
|
||
if (!tbody) return;
|
||
|
||
if (gbLastMatches.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="8" class="gb-loading">未找到符合条件的对局</td></tr>';
|
||
return;
|
||
}
|
||
|
||
const startIdx = (gbCurrentPage - 1) * gbPageSize;
|
||
tbody.innerHTML = gbLastMatches.map((m, i) => {
|
||
const ts = m.timestamp ? formatTimestamp(m.timestamp) : '--';
|
||
return `<tr class="gb-row" onclick="gbShowDetail('${m.file}')">
|
||
<td>${startIdx + i + 1}</td>
|
||
<td><span class="gb-tag gb-tag-version">${m.version}</span></td>
|
||
<td title="${m.steamId}">${m.steamId ? m.steamId.substring(0, 8) + '...' : '--'}</td>
|
||
<td>${m.totalTurns}</td>
|
||
<td>${m.damageCount}</td>
|
||
<td>${m.unitCount}</td>
|
||
<td>${m.playerCount}</td>
|
||
<td>${ts}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderPagination() {
|
||
const container = document.getElementById('gb-pagination');
|
||
if (!container) return;
|
||
|
||
const totalPages = Math.ceil(gbTotalMatches / gbPageSize);
|
||
if (totalPages <= 1) {
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
|
||
// Prev
|
||
html += `<button class="gb-page-btn" ${gbCurrentPage <= 1 ? 'disabled' : ''} onclick="gbSearch(${gbCurrentPage - 1})">«</button>`;
|
||
|
||
// Page numbers (show max 7)
|
||
const range = getPageRange(gbCurrentPage, totalPages, 7);
|
||
for (const p of range) {
|
||
if (p === '...') {
|
||
html += '<span class="gb-page-dots">...</span>';
|
||
} else {
|
||
html += `<button class="gb-page-btn ${p === gbCurrentPage ? 'active' : ''}" onclick="gbSearch(${p})">${p}</button>`;
|
||
}
|
||
}
|
||
|
||
// Next
|
||
html += `<button class="gb-page-btn" ${gbCurrentPage >= totalPages ? 'disabled' : ''} onclick="gbSearch(${gbCurrentPage + 1})">»</button>`;
|
||
|
||
html += `<span class="gb-page-info">${gbCurrentPage}/${totalPages} 页, 共 ${gbTotalMatches} 条</span>`;
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function getPageRange(current, total, maxVisible) {
|
||
if (total <= maxVisible) {
|
||
return Array.from({ length: total }, (_, i) => i + 1);
|
||
}
|
||
const half = Math.floor(maxVisible / 2);
|
||
let start = Math.max(1, current - half);
|
||
let end = Math.min(total, start + maxVisible - 1);
|
||
if (end - start < maxVisible - 1) {
|
||
start = Math.max(1, end - maxVisible + 1);
|
||
}
|
||
|
||
const pages = [];
|
||
if (start > 1) { pages.push(1); if (start > 2) pages.push('...'); }
|
||
for (let i = start; i <= end; i++) pages.push(i);
|
||
if (end < total) { if (end < total - 1) pages.push('...'); pages.push(total); }
|
||
return pages;
|
||
}
|
||
|
||
function renderStats() {
|
||
const container = document.getElementById('gb-stats-content');
|
||
if (!container) return;
|
||
|
||
if (gbTotalMatches === 0) {
|
||
container.innerHTML = '<div class="gb-stats-empty">无匹配数据</div>';
|
||
return;
|
||
}
|
||
|
||
// Compute aggregate stats from current page matches (basic overview)
|
||
let totalDmg = 0, totalUnits = 0, totalTurns = 0, totalPlayers = 0;
|
||
for (const m of gbLastMatches) {
|
||
totalDmg += m.damageCount;
|
||
totalUnits += m.unitCount;
|
||
totalTurns += m.totalTurns;
|
||
totalPlayers += m.playerCount;
|
||
}
|
||
const count = gbLastMatches.length || 1;
|
||
|
||
container.innerHTML = `
|
||
<div class="gb-stats-grid">
|
||
<div class="gb-stat-card">
|
||
<div class="gb-stat-value">${gbTotalMatches}</div>
|
||
<div class="gb-stat-label">匹配对局</div>
|
||
</div>
|
||
<div class="gb-stat-card">
|
||
<div class="gb-stat-value">${(totalTurns / count).toFixed(1)}</div>
|
||
<div class="gb-stat-label">平均回合</div>
|
||
</div>
|
||
<div class="gb-stat-card">
|
||
<div class="gb-stat-value">${(totalDmg / count).toFixed(1)}</div>
|
||
<div class="gb-stat-label">平均伤害事件</div>
|
||
</div>
|
||
<div class="gb-stat-card">
|
||
<div class="gb-stat-value">${(totalUnits / count).toFixed(1)}</div>
|
||
<div class="gb-stat-label">平均造兵数</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Detail modal ──
|
||
|
||
async function gbShowDetail(file) {
|
||
try {
|
||
const resp = await fetch('/api/oss/match?file=' + encodeURIComponent(file));
|
||
if (!resp.ok) throw new Error('加载失败');
|
||
const data = await resp.json();
|
||
showMatchDetailModal(data, file);
|
||
} catch (e) {
|
||
showToast('加载对局详情失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function showMatchDetailModal(data, file) {
|
||
// Remove existing modal
|
||
let existing = document.getElementById('gb-detail-modal');
|
||
if (existing) existing.remove();
|
||
|
||
const parts = file.split('/');
|
||
const version = parts[0] || '';
|
||
const steamId = parts[1] || '';
|
||
|
||
const modal = document.createElement('div');
|
||
modal.id = 'gb-detail-modal';
|
||
modal.className = 'gb-modal-overlay';
|
||
modal.onclick = e => { if (e.target === modal) modal.remove(); };
|
||
|
||
const damages = data.damages || [];
|
||
const addUnits = data.addUnits || [];
|
||
const learnTechs = data.learnTechs || [];
|
||
const turnStarts = data.onTurnStarts || [];
|
||
const gameEnds = data.matchGameEnds || [];
|
||
const playerEnds = data.playerGameEnds || [];
|
||
|
||
modal.innerHTML = `
|
||
<div class="gb-modal">
|
||
<div class="gb-modal-header">
|
||
<span class="gb-modal-title">对局详情 - ${version}</span>
|
||
<button class="gb-modal-close" onclick="this.closest('.gb-modal-overlay').remove()">×</button>
|
||
</div>
|
||
<div class="gb-modal-body">
|
||
<div class="gb-detail-meta">
|
||
<span><strong>版本:</strong> ${version}</span>
|
||
<span><strong>玩家:</strong> ${steamId}</span>
|
||
<span><strong>MemberId:</strong> ${data.memberId || '--'}</span>
|
||
</div>
|
||
<div class="gb-detail-stats">
|
||
<span>伤害: ${damages.length}</span>
|
||
<span>造兵: ${addUnits.length}</span>
|
||
<span>科技: ${learnTechs.length}</span>
|
||
<span>回合数据: ${turnStarts.length}</span>
|
||
<span>玩家: ${playerEnds.length}</span>
|
||
</div>
|
||
|
||
<h4>伤害事件 (前50条)</h4>
|
||
<div class="gb-detail-table-wrap">
|
||
<table class="gb-table gb-table-sm">
|
||
<thead><tr><th>回合</th><th>击杀</th><th>攻方单位</th><th>伤害</th><th>受方单位</th><th>承伤</th></tr></thead>
|
||
<tbody>
|
||
${damages.slice(0, 50).map(d => `<tr>
|
||
<td>${d.turn}</td>
|
||
<td>${d.isKill ? '<span class="gb-tag gb-tag-kill">击杀</span>' : ''}</td>
|
||
<td>${unitLabel(d.originUnitType, d.originGiantType, d.originLevel)}</td>
|
||
<td>${d.originDamage}</td>
|
||
<td>${unitLabel(d.targetUnitType, d.targetGiantType, d.targetLevel)}</td>
|
||
<td>${d.targetDamage}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<h4>造兵记录 (前50条)</h4>
|
||
<div class="gb-detail-table-wrap">
|
||
<table class="gb-table gb-table-sm">
|
||
<thead><tr><th>回合</th><th>单位</th><th>势力</th></tr></thead>
|
||
<tbody>
|
||
${addUnits.slice(0, 50).map(a => `<tr>
|
||
<td>${a.turn}</td>
|
||
<td>${unitLabel(a.unitType, a.giantType, a.level)}</td>
|
||
<td>${forceLabel(a.force)}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
// ── Label helpers ──
|
||
|
||
function unitLabel(unitType, giantType, level) {
|
||
const ut = GB_UNIT_TYPES[unitType];
|
||
const gt = GB_GIANT_TYPES[giantType];
|
||
let label = ut ? ut.cn : `UnitType(${unitType})`;
|
||
if (giantType && gt && gt.cn !== '-') {
|
||
label = gt.cn;
|
||
}
|
||
if (level > 0) label += ' Lv.' + level;
|
||
return label;
|
||
}
|
||
|
||
function forceLabel(forceId) {
|
||
return GB_FORCE_NAMES[forceId] || `Force(${forceId})`;
|
||
}
|
||
|
||
function formatTimestamp(ts) {
|
||
// timestamp is a unix-ms string from filename
|
||
const num = parseInt(ts);
|
||
if (isNaN(num)) return ts;
|
||
const d = new Date(num);
|
||
const pad = n => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
function showToast(msg) {
|
||
// Reuse app.js toast if available
|
||
const toast = document.getElementById('toast');
|
||
if (toast) {
|
||
toast.textContent = msg;
|
||
toast.style.display = 'block';
|
||
toast.style.opacity = '1';
|
||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.style.display = 'none', 300); }, 2000);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Hero Balance Sub-module
|
||
// ════════════════════════════════════════════
|
||
|
||
function gbhRenderVersionList() {
|
||
const container = document.getElementById('gbh-version-list');
|
||
const badge = document.getElementById('gbh-version-badge');
|
||
if (!container) return;
|
||
|
||
if (gbVersions.length === 0) {
|
||
container.innerHTML = '<span class="text-muted">未找到版本数据</span>';
|
||
if (badge) badge.textContent = '0 版本';
|
||
return;
|
||
}
|
||
|
||
let totalMatches = 0;
|
||
container.innerHTML = gbVersions.map((v, i) => {
|
||
totalMatches += v.matchCount;
|
||
return `<label class="gb-version-item">
|
||
<input type="checkbox" ${v.selected ? 'checked' : ''} onchange="gbhToggleVersion(${i}, this.checked)">
|
||
<span class="gb-version-name">${v.version}</span>
|
||
<span class="gb-version-count">${v.matchCount} 局</span>
|
||
</label>`;
|
||
}).join('');
|
||
|
||
const selCount = gbVersions.filter(v => v.selected).length;
|
||
if (badge) badge.textContent = `${selCount}/${gbVersions.length} 版本 (${totalMatches} 局)`;
|
||
}
|
||
|
||
function gbhToggleVersion(index, checked) {
|
||
gbVersions[index].selected = checked;
|
||
// Update both badges
|
||
const selCount = gbVersions.filter(v => v.selected).length;
|
||
const total = gbVersions.reduce((s, v) => s + (v.selected ? v.matchCount : 0), 0);
|
||
const txt = `${selCount}/${gbVersions.length} 版本 (${total} 局)`;
|
||
const b1 = document.getElementById('gb-version-badge');
|
||
const b2 = document.getElementById('gbh-version-badge');
|
||
if (b1) b1.textContent = txt;
|
||
if (b2) b2.textContent = txt;
|
||
}
|
||
|
||
function gbhSelectAllVersions(selectAll) {
|
||
gbVersions.forEach(v => v.selected = selectAll);
|
||
renderVersionList();
|
||
gbhRenderVersionList();
|
||
}
|
||
|
||
async function gbhCalculate() {
|
||
const selectedVersions = gbVersions.filter(v => v.selected).map(v => v.version);
|
||
if (selectedVersions.length === 0) {
|
||
showToast('请至少选择一个版本');
|
||
return;
|
||
}
|
||
|
||
const grid = document.getElementById('gbh-tier-grid');
|
||
if (grid) grid.innerHTML = '<div class="gb-loading">正在统计...</div>';
|
||
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set('versions', selectedVersions.join(','));
|
||
const resp = await fetch('/api/oss/hero-stats?' + params.toString());
|
||
if (!resp.ok) throw new Error('API error');
|
||
gbhHeroData = await resp.json();
|
||
|
||
const badge = document.getElementById('gbh-total-badge');
|
||
if (badge) badge.textContent = `${gbhHeroData.totalMatches} 局`;
|
||
|
||
gbhRenderTierGrid();
|
||
} catch (e) {
|
||
if (grid) grid.innerHTML = '<div class="gb-stats-empty">统计失败: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
function gbhRenderTierGrid() {
|
||
const grid = document.getElementById('gbh-tier-grid');
|
||
if (!grid || !gbhHeroData) return;
|
||
|
||
const heroes = gbhHeroData.heroes;
|
||
if (!heroes || heroes.length === 0) {
|
||
grid.innerHTML = '<div class="gb-stats-empty">无英雄数据</div>';
|
||
return;
|
||
}
|
||
|
||
// Get metric values and sort descending
|
||
const metric = gbhMetric;
|
||
const sorted = [...heroes]
|
||
.filter(h => h.matchCount > 0)
|
||
.sort((a, b) => b[metric] - a[metric]);
|
||
|
||
if (sorted.length === 0) {
|
||
grid.innerHTML = '<div class="gb-stats-empty">所选版本无英雄出场数据</div>';
|
||
return;
|
||
}
|
||
|
||
// Divide into 5 tiers using quintiles
|
||
const tiers = [[], [], [], [], []]; // 夯, 顶级, 人上人, NPC, 拉
|
||
const tierSize = Math.ceil(sorted.length / 5);
|
||
sorted.forEach((h, i) => {
|
||
const tierIdx = Math.min(Math.floor(i / tierSize), 4);
|
||
tiers[tierIdx].push(h);
|
||
});
|
||
|
||
// Build metric label and format
|
||
const metricLabels = {
|
||
appearRate: { label: '出场率', suffix: '%', decimals: 1 },
|
||
avgDamage: { label: '平均伤害', suffix: '', decimals: 0 },
|
||
avgDeaths: { label: '平均死亡', suffix: '', decimals: 2 },
|
||
};
|
||
const mInfo = metricLabels[metric] || metricLabels.appearRate;
|
||
|
||
let html = '';
|
||
for (let t = 0; t < 5; t++) {
|
||
const tierHeroes = tiers[t];
|
||
if (tierHeroes.length === 0) continue;
|
||
|
||
html += `<div class="gbh-tier-row">
|
||
<div class="gbh-tier-label" style="background:${GB_TIER_COLORS[t]}">
|
||
${GB_TIER_LABELS[t]}
|
||
</div>
|
||
<div class="gbh-tier-heroes">`;
|
||
|
||
for (const h of tierHeroes) {
|
||
const gt = h.giantType;
|
||
const info = GB_GIANT_TYPES[gt] || { en: '?', cn: '?' };
|
||
const portrait = GB_HERO_PORTRAITS[gt] || '';
|
||
const val = typeof h[metric] === 'number'
|
||
? h[metric].toFixed(mInfo.decimals) + mInfo.suffix
|
||
: '--';
|
||
|
||
html += `<div class="gbh-hero-card" title="${info.cn} (${info.en}) - ${mInfo.label}: ${val}">
|
||
<div class="gbh-hero-portrait">
|
||
${portrait
|
||
? `<img src="assets/heroes/${portrait}" alt="${info.en}" loading="lazy">`
|
||
: `<div class="gbh-hero-placeholder">${info.cn[0]}</div>`}
|
||
</div>
|
||
<div class="gbh-hero-name">${info.cn}</div>
|
||
<div class="gbh-hero-value">${val}</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
grid.innerHTML = html;
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Hero Data Sub-module
|
||
// ════════════════════════════════════════════
|
||
|
||
async function gbhdLoadData(forceReload = false) {
|
||
const table = document.getElementById('gbhd-hero-table');
|
||
const badge = document.getElementById('gbhd-total-badge');
|
||
if (!table) return;
|
||
|
||
if (!forceReload && gbhdData) {
|
||
gbhdRender();
|
||
return;
|
||
}
|
||
|
||
table.innerHTML = '<div class="gb-loading">正在读取英雄属性与技能...</div>';
|
||
if (badge) badge.textContent = '加载中';
|
||
|
||
try {
|
||
const payload = await gbhdFetchHeroPayload();
|
||
const units = payload.units || [];
|
||
const skills = payload.skills || [];
|
||
const heroTasks = payload.heroes || [];
|
||
gbhdData = gbhdBuildData(units, skills, heroTasks);
|
||
gbhdInitFilters();
|
||
gbhdRender();
|
||
} catch (e) {
|
||
if (badge) badge.textContent = '读取失败';
|
||
table.innerHTML = `<div class="gb-stats-empty">英雄数据读取失败: ${gbEscapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function gbhdFetchHeroPayload() {
|
||
try {
|
||
const apiResp = await fetch('/api/gamebalance/hero-data?t=' + Date.now());
|
||
if (apiResp.ok) return apiResp.json();
|
||
} catch (_) {
|
||
// Static file usage falls back to exported JSON below.
|
||
}
|
||
|
||
const [unitsResp, skillsResp, heroesResp] = await Promise.all([
|
||
fetch('data/units.json?t=' + Date.now()),
|
||
fetch('data/skills.json?t=' + Date.now()),
|
||
fetch('data/heroes.json?t=' + Date.now()),
|
||
]);
|
||
if (!unitsResp.ok) throw new Error('units.json 读取失败');
|
||
if (!skillsResp.ok) throw new Error('skills.json 读取失败');
|
||
|
||
return {
|
||
source: 'data/*.json',
|
||
units: await unitsResp.json(),
|
||
skills: await skillsResp.json(),
|
||
heroes: heroesResp.ok ? await heroesResp.json() : [],
|
||
};
|
||
}
|
||
|
||
function gbhdBuildData(units, skills, heroTasks) {
|
||
const skillMap = new Map((skills || []).map(skill => [Number(skill.skillType), skill]));
|
||
const taskMap = new Map((heroTasks || []).map(hero => [Number(hero.giantType), hero]));
|
||
const grouped = new Map();
|
||
|
||
(units || [])
|
||
.filter(unit => Number(unit.unitType) === 14 || Number(unit.giantType) > 0)
|
||
.filter(unit => Number(unit.giantType) > 0 && Number(unit.unitLevel) >= 1 && Number(unit.unitLevel) <= 4)
|
||
.filter(unit => GBHD_FORCE_ORDER.includes(Number(unit.empire?.force) || 0))
|
||
.forEach(unit => {
|
||
const giantType = Number(unit.giantType);
|
||
if (!grouped.has(giantType)) {
|
||
grouped.set(giantType, {
|
||
giantType,
|
||
giantName: unit.giantName || '',
|
||
heroName: gbhdCleanHeroName(unit.name || unit.giantName || ''),
|
||
chessType: Number(unit.chessType) || 0,
|
||
className: GBHD_CLASS_NAMES[Number(unit.chessType)] || `职阶${unit.chessType || '-'}`,
|
||
forceId: Number(unit.empire?.force) || 0,
|
||
forceName: unit.empire?.forceName || '',
|
||
forceNameLocal: unit.empire?.forceNameLocal || '',
|
||
civId: Number(unit.empire?.civ) || 0,
|
||
civName: unit.empire?.civName || '',
|
||
civNameLocal: unit.empire?.civNameLocal || '',
|
||
levels: {},
|
||
tasks: taskMap.get(giantType)?.tasks || [],
|
||
});
|
||
}
|
||
|
||
const hero = grouped.get(giantType);
|
||
const level = Number(unit.unitLevel);
|
||
const skillIds = (unit.skillIds || []).map(id => Number(id)).filter(id => id > 0);
|
||
const rowSkills = skillIds.map(id => gbhdSkillInfo(id, skillMap)).filter(Boolean);
|
||
hero.levels[level] = {
|
||
level,
|
||
name: unit.name || '',
|
||
stats: {
|
||
hp: unit.maxHealth,
|
||
attack: unit.attack,
|
||
defense: unit.defense,
|
||
move: unit.moveRange,
|
||
range: unit.attackRange,
|
||
sight: unit.sightRange,
|
||
cost: unit.cost,
|
||
},
|
||
skillIds: rowSkills.map(skill => skill.id),
|
||
skills: rowSkills,
|
||
};
|
||
});
|
||
|
||
const heroes = [...grouped.values()].map(hero => {
|
||
const firstLevel = hero.levels[1] || Object.values(hero.levels)[0];
|
||
if (firstLevel?.name) hero.heroName = gbhdCleanHeroName(firstLevel.name);
|
||
hero.searchText = gbhdHeroSearchText(hero);
|
||
return hero;
|
||
});
|
||
|
||
heroes.sort((a, b) => {
|
||
const classCmp = GBHD_CLASS_ORDER.indexOf(a.chessType) - GBHD_CLASS_ORDER.indexOf(b.chessType);
|
||
if (classCmp !== 0) return classCmp;
|
||
const forceCmp = GBHD_FORCE_ORDER.indexOf(a.forceId) - GBHD_FORCE_ORDER.indexOf(b.forceId);
|
||
if (forceCmp !== 0) return forceCmp;
|
||
return (a.heroName || '').localeCompare(b.heroName || '', 'zh-Hans-CN');
|
||
});
|
||
|
||
return { heroes };
|
||
}
|
||
|
||
function gbhdInitFilters() {
|
||
if (!gbhdData || gbhdRendered) return;
|
||
gbhdRendered = true;
|
||
|
||
const classSelect = document.getElementById('gbhd-class-filter');
|
||
const forceSelect = document.getElementById('gbhd-force-filter');
|
||
if (classSelect) {
|
||
const classes = GBHD_CLASS_ORDER
|
||
.map(chessType => ({ chessType, name: GBHD_CLASS_NAMES[chessType] }))
|
||
.filter(cls => gbhdData.heroes.some(hero => hero.chessType === cls.chessType));
|
||
classSelect.insertAdjacentHTML('beforeend', classes
|
||
.map(cls => `<option value="${cls.chessType}">${gbEscapeHtml(cls.name)}</option>`)
|
||
.join(''));
|
||
classSelect.addEventListener('change', gbhdRender);
|
||
}
|
||
|
||
if (forceSelect) {
|
||
const forces = [...new Map(gbhdData.heroes.map(hero => [
|
||
hero.forceName,
|
||
{ forceName: hero.forceName, label: gbhdForceLabel(hero) },
|
||
])).values()]
|
||
.filter(force => force.forceName)
|
||
.sort((a, b) => a.label.localeCompare(b.label, 'zh-Hans-CN'));
|
||
forceSelect.insertAdjacentHTML('beforeend', forces
|
||
.map(force => `<option value="${gbEscapeAttr(force.forceName)}">${gbEscapeHtml(force.label)}</option>`)
|
||
.join(''));
|
||
forceSelect.addEventListener('change', gbhdRender);
|
||
}
|
||
|
||
const search = document.getElementById('gbhd-search');
|
||
if (search) search.addEventListener('input', gbhdRender);
|
||
}
|
||
|
||
function gbhdRender() {
|
||
const table = document.getElementById('gbhd-hero-table');
|
||
const badge = document.getElementById('gbhd-total-badge');
|
||
if (!table || !gbhdData) return;
|
||
|
||
const classFilter = document.getElementById('gbhd-class-filter')?.value || '';
|
||
const forceFilter = document.getElementById('gbhd-force-filter')?.value || '';
|
||
const query = (document.getElementById('gbhd-search')?.value || '').trim().toLowerCase();
|
||
const heroes = gbhdData.heroes.filter(hero => {
|
||
if (classFilter && String(hero.chessType) !== classFilter) return false;
|
||
if (forceFilter && hero.forceName !== forceFilter) return false;
|
||
if (query && !hero.searchText.toLowerCase().includes(query)) return false;
|
||
return true;
|
||
});
|
||
|
||
if (badge) badge.textContent = `${heroes.length}/${gbhdData.heroes.length} 英雄`;
|
||
if (heroes.length === 0) {
|
||
table.innerHTML = '<div class="gb-stats-empty">当前筛选条件下没有英雄</div>';
|
||
return;
|
||
}
|
||
|
||
const groups = GBHD_CLASS_ORDER
|
||
.map(chessType => ({
|
||
chessType,
|
||
className: GBHD_CLASS_NAMES[chessType],
|
||
heroes: heroes
|
||
.filter(hero => hero.chessType === chessType)
|
||
.sort((a, b) => GBHD_FORCE_ORDER.indexOf(a.forceId) - GBHD_FORCE_ORDER.indexOf(b.forceId)),
|
||
}))
|
||
.filter(group => group.heroes.length > 0);
|
||
|
||
table.innerHTML = groups.map(group => gbhdRenderClassTable(group)).join('');
|
||
}
|
||
|
||
function gbhdRenderClassTable(group) {
|
||
return `<section class="gbhd-class-block">
|
||
<div class="gbhd-class-title">
|
||
<span class="gbhd-class-pill">${gbEscapeHtml(group.className)}</span>
|
||
<span>${group.heroes.length} 名英雄</span>
|
||
</div>
|
||
<div class="gbhd-table-wrap">
|
||
<table class="gbhd-compare-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="gbhd-level-head">等级</th>
|
||
<th class="gbhd-metric-head">项</th>
|
||
${group.heroes.map(hero => `<th class="gbhd-hero-head">
|
||
<div class="gbhd-hero-head-main">${gbEscapeHtml(gbhdForceLabel(hero))}</div>
|
||
<div class="gbhd-hero-head-sub">${gbEscapeHtml(hero.heroName)} · ${gbEscapeHtml(hero.giantName)}</div>
|
||
</th>`).join('')}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${[1, 2, 3, 4].map(level => gbhdRenderLevelRows(group.heroes, level)).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function gbhdRenderLevelRows(heroes, level) {
|
||
return GBHD_ROW_SPECS.map((spec, index) => {
|
||
const rowClass = [
|
||
`gbhd-row-${spec.type}`,
|
||
level % 2 === 0 ? 'gbhd-level-band-alt' : '',
|
||
].filter(Boolean).join(' ');
|
||
return `<tr class="${rowClass}">
|
||
${index === 0 ? `<th class="gbhd-level-cell" rowspan="${GBHD_ROW_SPECS.length}">Lv${level}</th>` : ''}
|
||
<th class="gbhd-metric-cell">${gbEscapeHtml(spec.label)}</th>
|
||
${heroes.map(hero => gbhdRenderMetricCell(hero, level, spec)).join('')}
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function gbhdRenderMetricCell(hero, level, spec) {
|
||
const row = hero.levels[level];
|
||
if (!row) return '<td class="gbhd-value-cell gbhd-missing">-</td>';
|
||
|
||
if (spec.type === 'task') {
|
||
return `<td class="gbhd-value-cell gbhd-task-value">${gbEscapeHtml(gbhdTaskText(hero, level))}</td>`;
|
||
}
|
||
if (spec.type === 'skills') {
|
||
return `<td class="gbhd-value-cell gbhd-skill-value">${gbhdRenderSkillsCell(hero, row, level)}</td>`;
|
||
}
|
||
|
||
const value = gbhdFormatNum((row.stats || {})[spec.key]);
|
||
return `<td class="gbhd-value-cell gbhd-stat-value">${gbEscapeHtml(value)}</td>`;
|
||
}
|
||
|
||
function gbhdRenderSkillsCell(hero, row, level) {
|
||
const prev = hero.levels[level - 1];
|
||
const skills = gbhdSkillDelta(row, prev)
|
||
.map(skill => `<span class="gbhd-skill-chip ${skill.isNew ? 'is-new' : ''} ${skill.hidden ? 'is-hidden' : ''}" title="${gbEscapeAttr(skill.desc || '')}">${gbEscapeHtml(skill.label)}</span>`)
|
||
.join('');
|
||
return `<div class="gbhd-skill-line">${skills || '<span class="gbhd-empty">无技能</span>'}</div>`;
|
||
}
|
||
|
||
function gbhdSkillDelta(row, prev) {
|
||
const prevIds = new Set(prev?.skillIds || []);
|
||
return (row.skills || []).map(skill => ({
|
||
...skill,
|
||
isNew: !prevIds.has(skill.id),
|
||
}));
|
||
}
|
||
|
||
function gbhdSkillInfo(id, skillMap) {
|
||
const skill = skillMap.get(id);
|
||
if (!skill && id > 100000) return null;
|
||
const name = (skill?.name || '').trim();
|
||
const desc = gbhdCleanMarkup(skill?.desc || '');
|
||
const fallbackFromDesc = gbhdExtractSkillTitle(desc);
|
||
return {
|
||
id,
|
||
label: name || fallbackFromDesc || `#${id}`,
|
||
desc: desc || (!skill ? '未在技能表中找到' : ''),
|
||
viewType: skill?.viewType || '',
|
||
hidden: Boolean(skill?.notShow || (!name && !fallbackFromDesc)),
|
||
};
|
||
}
|
||
|
||
function gbhdTaskText(hero, level) {
|
||
if (level <= 1) return '-';
|
||
const task = hero.tasks?.[level - 2];
|
||
if (!task) return '-';
|
||
const pieces = [];
|
||
if (task.param !== undefined && task.param !== null && task.param !== 0) pieces.push(String(task.param));
|
||
if (task.skillName) pieces.push(task.skillName);
|
||
const desc = gbhdCleanMarkup(task.desc || '');
|
||
if (desc) pieces.push(desc);
|
||
return pieces.join(' ');
|
||
}
|
||
|
||
function gbhdHeroSearchText(hero) {
|
||
const parts = [
|
||
hero.heroName,
|
||
hero.giantName,
|
||
hero.className,
|
||
hero.forceName,
|
||
hero.forceNameLocal,
|
||
hero.civName,
|
||
hero.civNameLocal,
|
||
gbhdForceLabel(hero),
|
||
];
|
||
Object.values(hero.levels || {}).forEach(row => {
|
||
parts.push(row.name);
|
||
(row.skills || []).forEach(skill => parts.push(skill.label, skill.desc));
|
||
});
|
||
(hero.tasks || []).forEach(task => parts.push(task.desc, task.skillName));
|
||
return parts.filter(Boolean).join(' ');
|
||
}
|
||
|
||
function gbhdCleanHeroName(name) {
|
||
return String(name || '')
|
||
.replace(/\s*Lv\.?\s*\d+\s*$/i, '')
|
||
.replace(/\s+$/g, '');
|
||
}
|
||
|
||
function gbhdCleanMarkup(text) {
|
||
return String(text || '')
|
||
.replace(/\*\*<([^>]+)>\*\*/g, '$1')
|
||
.replace(/\*\*/g, '')
|
||
.replace(/\{param\}/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function gbhdExtractSkillTitle(desc) {
|
||
const match = String(desc || '').match(/^([^::]{1,16})[::]/);
|
||
return match ? match[1].trim() : '';
|
||
}
|
||
|
||
function gbhdForceLabel(hero) {
|
||
const force = hero.forceNameLocal || GB_FORCE_NAMES[hero.forceId] || hero.forceName || '';
|
||
const civ = hero.civNameLocal || GB_CIV_NAMES[hero.civId] || hero.civName || '';
|
||
if (force && civ) return `${force} / ${civ}`;
|
||
return force || civ || '-';
|
||
}
|
||
|
||
function gbhdFormatNum(value) {
|
||
if (typeof value !== 'number' || !isFinite(value)) return '-';
|
||
return String(value).replace(/\.0$/, '');
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Hero Upgrade Turn Estimation Sub-module
|
||
// ════════════════════════════════════════════
|
||
|
||
async function gbhuLoadData(forceReload = false) {
|
||
const table = document.getElementById('gbhu-upgrade-table');
|
||
const badge = document.getElementById('gbhu-total-badge');
|
||
if (!table) return;
|
||
|
||
if (!forceReload && gbhuData) {
|
||
gbhuRender();
|
||
return;
|
||
}
|
||
|
||
table.innerHTML = '<div class="gb-loading">正在读取英雄升级任务...</div>';
|
||
if (badge) badge.textContent = '加载中';
|
||
|
||
try {
|
||
const payload = await gbhdFetchHeroPayload();
|
||
const units = payload.units || [];
|
||
const skills = payload.skills || [];
|
||
const heroTasks = payload.heroes || [];
|
||
const baseData = gbhdBuildData(units, skills, heroTasks);
|
||
gbhuData = gbhuBuildData(baseData.heroes, skills);
|
||
gbhuInitFilters();
|
||
gbhuRender();
|
||
} catch (e) {
|
||
if (badge) badge.textContent = '读取失败';
|
||
table.innerHTML = `<div class="gb-stats-empty">英雄升级估算读取失败: ${gbEscapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function gbhuBuildData(heroes, skills) {
|
||
const skillMap = new Map((skills || []).map(skill => [Number(skill.skillType), skill]));
|
||
const rows = [];
|
||
(heroes || []).forEach(hero => {
|
||
const estimates = [1, 2, 3].map((taskIndex, idx) => {
|
||
const fromLevel = idx + 1;
|
||
const task = hero.tasks?.[idx];
|
||
return gbhuEstimateTask(hero, fromLevel, fromLevel + 1, task, skillMap);
|
||
});
|
||
const searchText = [
|
||
hero.heroName,
|
||
hero.giantName,
|
||
hero.forceName,
|
||
hero.forceNameLocal,
|
||
hero.civName,
|
||
hero.civNameLocal,
|
||
gbhdForceLabel(hero),
|
||
...estimates.flatMap(est => [est.taskName, est.taskText, est.logic, est.turnText, est.speedLabel]),
|
||
].filter(Boolean).join(' ');
|
||
rows.push({ ...hero, estimates, upgradeSearchText: searchText });
|
||
});
|
||
rows.sort((a, b) => {
|
||
const forceCmp = GBHD_FORCE_ORDER.indexOf(a.forceId) - GBHD_FORCE_ORDER.indexOf(b.forceId);
|
||
if (forceCmp !== 0) return forceCmp;
|
||
const classCmp = GBHD_CLASS_ORDER.indexOf(a.chessType) - GBHD_CLASS_ORDER.indexOf(b.chessType);
|
||
if (classCmp !== 0) return classCmp;
|
||
return (a.heroName || '').localeCompare(b.heroName || '', 'zh-Hans-CN');
|
||
});
|
||
return { heroes: rows };
|
||
}
|
||
|
||
function gbhuInitFilters() {
|
||
if (!gbhuData || gbhuRendered) return;
|
||
gbhuRendered = true;
|
||
|
||
const forceSelect = document.getElementById('gbhu-force-filter');
|
||
if (forceSelect) {
|
||
const forces = [...new Map(gbhuData.heroes.map(hero => [
|
||
hero.forceName,
|
||
{ forceName: hero.forceName, label: gbhdForceLabel(hero) },
|
||
])).values()]
|
||
.filter(force => force.forceName)
|
||
.sort((a, b) => a.label.localeCompare(b.label, 'zh-Hans-CN'));
|
||
forceSelect.insertAdjacentHTML('beforeend', forces
|
||
.map(force => `<option value="${gbEscapeAttr(force.forceName)}">${gbEscapeHtml(force.label)}</option>`)
|
||
.join(''));
|
||
forceSelect.addEventListener('change', gbhuRender);
|
||
}
|
||
|
||
const speedSelect = document.getElementById('gbhu-speed-filter');
|
||
if (speedSelect) speedSelect.addEventListener('change', gbhuRender);
|
||
|
||
const search = document.getElementById('gbhu-search');
|
||
if (search) search.addEventListener('input', gbhuRender);
|
||
}
|
||
|
||
function gbhuRender() {
|
||
const table = document.getElementById('gbhu-upgrade-table');
|
||
const badge = document.getElementById('gbhu-total-badge');
|
||
if (!table || !gbhuData) return;
|
||
|
||
const forceFilter = document.getElementById('gbhu-force-filter')?.value || '';
|
||
const speedFilter = document.getElementById('gbhu-speed-filter')?.value || '';
|
||
const query = (document.getElementById('gbhu-search')?.value || '').trim().toLowerCase();
|
||
|
||
const heroes = gbhuData.heroes.filter(hero => {
|
||
if (forceFilter && hero.forceName !== forceFilter) return false;
|
||
if (speedFilter && !hero.estimates.some(est => est.speed === speedFilter)) return false;
|
||
if (query && !hero.upgradeSearchText.toLowerCase().includes(query)) return false;
|
||
return true;
|
||
});
|
||
|
||
if (badge) badge.textContent = `${heroes.length}/${gbhuData.heroes.length} 英雄`;
|
||
if (heroes.length === 0) {
|
||
table.innerHTML = '<div class="gb-stats-empty">当前筛选条件下没有英雄升级估算</div>';
|
||
return;
|
||
}
|
||
|
||
table.innerHTML = `<div class="gbhu-table-wrap">
|
||
<table class="gb-table gbhu-table">
|
||
<thead>
|
||
<tr>
|
||
<th>帝国</th>
|
||
<th>英雄</th>
|
||
<th>Lv1 → Lv2</th>
|
||
<th>Lv2 → Lv3</th>
|
||
<th>Lv3 → Lv4</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${heroes.map(hero => gbhuRenderHeroRow(hero)).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function gbhuRenderHeroRow(hero) {
|
||
return `<tr>
|
||
<td>
|
||
<div class="gbhu-force">${gbEscapeHtml(gbhdForceLabel(hero))}</div>
|
||
<div class="gbhu-class">${gbEscapeHtml(hero.className || '-')}</div>
|
||
</td>
|
||
<td>
|
||
<div class="gbhp-hero-main">
|
||
<span class="gbhp-hero-name">${gbEscapeHtml(hero.heroName || hero.giantName || '-')}</span>
|
||
<span class="gbhp-hero-key">${gbEscapeHtml(hero.giantName || '')}</span>
|
||
</div>
|
||
</td>
|
||
${hero.estimates.map(est => gbhuRenderEstimateCell(est)).join('')}
|
||
</tr>`;
|
||
}
|
||
|
||
function gbhuRenderEstimateCell(est) {
|
||
return `<td class="gbhu-est-cell">
|
||
<div class="gbhu-est-head">
|
||
<span class="gbhu-turn">${gbEscapeHtml(est.turnText)}</span>
|
||
<span class="gbhu-speed gbhu-speed-${gbEscapeAttr(est.speed)}">${gbEscapeHtml(est.speedLabel)}</span>
|
||
</div>
|
||
<div class="gbhu-task">${gbEscapeHtml(est.taskText)}</div>
|
||
<div class="gbhu-logic">${gbEscapeHtml(est.logic)}</div>
|
||
</td>`;
|
||
}
|
||
|
||
function gbhuEstimateTask(hero, fromLevel, toLevel, task, skillMap) {
|
||
if (!task) {
|
||
return gbhuEstimateResult(null, fromLevel, toLevel, 0, 0, 'gated', '没有配置任务,无法升级。', '无任务');
|
||
}
|
||
|
||
const taskType = Number(task.taskContentType);
|
||
const param = Number(task.param) || 0;
|
||
const taskName = GBHU_TASK_TYPE_NAMES[taskType] || `任务类型 ${taskType}`;
|
||
const taskText = gbhuTaskText(task, taskName, skillMap);
|
||
const special = gbhuSpecialEstimate(hero, fromLevel, toLevel, task, skillMap, taskName, taskText);
|
||
if (special) return special;
|
||
|
||
const model = gbhuBaseModel(taskType, param);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, model.min, model.max, model.speed, model.logic, taskText, taskName);
|
||
}
|
||
|
||
function gbhuSpecialEstimate(hero, fromLevel, toLevel, task, skillMap, taskName, taskText) {
|
||
const gt = Number(hero.giantType);
|
||
const taskType = Number(task.taskContentType);
|
||
const param = Number(task.param) || 0;
|
||
|
||
if (gt === 6 && taskType === 7) {
|
||
const perTurn = fromLevel >= 3 ? 6 : 3;
|
||
const min = Math.ceil(param / perTurn);
|
||
const max = Math.ceil(param / Math.max(1, perTurn - 1));
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
`按辉夜每回合用永夜返给周围友军叠满月估算;Lv${fromLevel}常见每次影响${perTurn - 1}-${perTurn}个目标。`,
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 7 && taskType === 8) {
|
||
const min = Math.ceil(param / 1.5);
|
||
const max = Math.ceil(param / 1);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
'按铃仙每回合主动参战并靠幻视调率约1-1.5次有效创造幻象估算。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 7 && taskType === 7) {
|
||
const min = Math.ceil(param / 4);
|
||
const max = Math.ceil(param / 3);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
|
||
'按战地协同标的每回合约施加3-4层估算,需要持续围绕同一目标打配合。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 8 && taskType === 9) {
|
||
const min = Math.ceil(param / 4);
|
||
const max = Math.ceil(param / 3);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
'按帝每回合通过攻击、击杀或死亡收益约3-4金币估算;真实速度取决于能否持续找战斗。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 10 && taskType === 23) {
|
||
const min = Math.ceil(param / 16);
|
||
const max = Math.ceil(param / 12);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
'妹红既计造成也计承受伤害,按主动换血/自爆相关玩法每回合约12-16点累计估算。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 5 && taskType === 10) {
|
||
const perTurn = fromLevel >= 3 ? 6 : 4;
|
||
const min = Math.ceil(param / perTurn);
|
||
const max = Math.ceil(param / Math.max(1, perTurn - 1));
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
`按帕秋莉每回合消耗${perTurn - 1}-${perTurn}层魔力石估算,取决于能否连续攻击或治疗。`,
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 11 && taskType === 14) {
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, 5, 7, 'gated',
|
||
'按前期完成5项科技估算:通常受科技节奏限制,约第5-7回合可达成。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 12 && taskType === 15) {
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, 0, 1, 'gated',
|
||
'需要已有御射宫司大人Lv4。若神奈子已达Lv4基本可立即完成,否则完全受神奈子升级节奏锁定。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 12 && taskType === 16) {
|
||
const min = Math.ceil(param / 2);
|
||
const max = Math.ceil(param / 1);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
|
||
'按御射宫司大人每回合1-2次击杀估算;需要先有高等级蛇且持续参战。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 13 && taskType === 7) {
|
||
const perTurn = fromLevel >= 3 ? 6 : 4;
|
||
const min = Math.ceil(param / perTurn);
|
||
const max = Math.ceil(param / Math.max(1, perTurn - 2));
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
`按早苗每回合通过神风/治疗给友军施加乘风${perTurn - 2}-${perTurn}层估算。`,
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 13 && taskType === 17) {
|
||
const expectedActions = Math.ceil(param / 0.25);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, expectedActions, expectedActions + 3, 'slow',
|
||
'普通御神签大吉或大凶合计20%,且3次非极端后下次强制极端;按平均约4次行动出1次极端估算。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 14 && taskType === 18) {
|
||
const min = Math.ceil(param / 18);
|
||
const max = Math.ceil(param / 12);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
'按文的路径/踩踏溅射每回合约12-18点有效溅射伤害估算,敌方站位密集时偏快。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 16 && taskType === 7 && Number(task.skillParam) === 210) {
|
||
const min = Math.ceil(param / 5);
|
||
const max = Math.ceil(param / 3);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
'按觉每回合通过攻击/窥视等来源施加3-5层恐惧估算。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 16 && taskType === 7 && Number(task.skillParam) === 213) {
|
||
const min = Math.ceil(param / 2);
|
||
const max = Math.ceil(param / 1);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
|
||
'心理创伤来源更窄,按每回合1-2次有效施加估算。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 20 && taskType === 7) {
|
||
const min = Math.ceil(param / 5);
|
||
const max = Math.ceil(param / 3);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
'按燐通过收集尸骸并转化粗钝身/金刚身,每回合约3-5层估算。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 22 && taskType === 24) {
|
||
const min = Math.ceil(param / 1);
|
||
const max = Math.ceil(param / 0.8);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'normal',
|
||
'按堇子设置灵异珠每回合约1个估算;若需要走位找空地则取上限。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
if (gt === 24 && taskType === 25) {
|
||
const min = Math.ceil(param / 3);
|
||
const max = Math.ceil(param / 2);
|
||
return gbhuEstimateResult(task, fromLevel, toLevel, min, max, 'slow',
|
||
'按阿吽进入吽形石守后每回合承受2-3次有效伤害估算;需要主动让石化体吃火力。',
|
||
taskText, taskName);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function gbhuBaseModel(taskType, param) {
|
||
switch (Number(taskType)) {
|
||
case 0:
|
||
return gbhuModelFromRate(param, 10, 14, 'normal', '按英雄主动吃伤害,每回合约10-14点承伤估算。');
|
||
case 1:
|
||
return gbhuModelFromRate(param, 10, 14, 'normal', '按英雄每回合一次有效攻击,约10-14点造成伤害估算。');
|
||
case 2:
|
||
return gbhuModelFromRate(param, 1, 1.5, 'slow', '按英雄持续参战,每回合约1-1.5次击杀估算。');
|
||
case 3:
|
||
return gbhuModelFromRate(param, 8, 12, 'normal', '按英雄每回合一次治疗或辅助治疗,约8-12点回复量估算。');
|
||
case 4:
|
||
return gbhuModelFromRate(param, 0.4, 0.6, 'slow', '按英雄本人开遗迹,约每2回合1处估算。');
|
||
case 5:
|
||
return gbhuModelFromRate(param, 7, 9, 'normal', '按探索型英雄每回合推进并揭开约7-9块迷雾估算。');
|
||
case 7:
|
||
case 8:
|
||
return gbhuModelFromRate(param, 2, 4, 'normal', '按对应技能每回合约触发2-4层/次估算,实际取决于目标密度和行动点。');
|
||
case 9:
|
||
case 13:
|
||
return gbhuModelFromRate(param, 5, 7, 'fast', '按帝国自然经济与战斗收益合计每回合约5-7金币估算。');
|
||
case 10:
|
||
return gbhuModelFromRate(param, 3, 5, 'normal', '按每回合消耗或掉落3-5层目标技能估算。');
|
||
case 12:
|
||
return gbhuModelFromRate(param, 1, 2, 'normal', '按每回合创建1-2个目标特殊地格估算。');
|
||
case 14:
|
||
return gbhuModelFromRate(param, 0.8, 1.2, 'gated', '按科技节奏约每回合0.8-1.2项估算,受研究线限制。');
|
||
case 15:
|
||
return gbhuModelFromRate(param, 0.8, 1.2, 'gated', '按目标单位生产/转化约每回合0.8-1.2个估算,受资源与前置单位限制。');
|
||
case 16:
|
||
return gbhuModelFromRate(param, 1, 1.5, 'slow', '按指定单位每回合约1-1.5次击杀估算。');
|
||
case 17:
|
||
return gbhuModelFromRate(param, 0.2, 0.25, 'slow', '按大吉/大凶约20%-25%出现率估算。');
|
||
case 18:
|
||
return gbhuModelFromRate(param, 10, 14, 'normal', '按每回合约10-14点溅射伤害估算。');
|
||
case 20:
|
||
return gbhuModelFromRate(param, 8, 10, 'normal', '按指定探索单位每回合揭开约8-10块迷雾估算。');
|
||
case 21:
|
||
return gbhuModelFromRate(param, 0.5, 0.8, 'normal', '按帝国成员合计开遗迹,约每1-2回合1处估算。');
|
||
case 22:
|
||
return gbhuModelFromRate(param, 0.6, 1, 'gated', '按占1城=2进度、占1村=1进度;前期通常约2-4回合完成。');
|
||
case 23:
|
||
return gbhuModelFromRate(param, 12, 16, 'normal', '按英雄主动交战,造成或承受伤害合计每回合约12-16点估算。');
|
||
case 24:
|
||
return gbhuModelFromRate(param, 0.8, 1, 'normal', '按每回合约设置0.8-1个目标物估算。');
|
||
case 25:
|
||
return gbhuModelFromRate(param, 2, 3, 'slow', '按状态满足后每回合承受2-3次有效伤害估算。');
|
||
default:
|
||
return gbhuModelFromRate(param, 1, 1, 'gated', '未知任务类型,暂按每回合1点进度粗估。');
|
||
}
|
||
}
|
||
|
||
function gbhuModelFromRate(param, lowRate, highRate, speed, logic) {
|
||
const min = Math.max(0, Math.ceil((Number(param) || 0) / Math.max(highRate, 0.01)));
|
||
const max = Math.max(min, Math.ceil((Number(param) || 0) / Math.max(lowRate, 0.01)));
|
||
return { min, max, speed, logic };
|
||
}
|
||
|
||
function gbhuEstimateResult(task, fromLevel, toLevel, min, max, speed, logic, taskText, taskName = '') {
|
||
const safeMin = Math.max(0, Math.ceil(min || 0));
|
||
const safeMax = Math.max(safeMin, Math.ceil(max || safeMin));
|
||
const turnText = safeMin === safeMax ? `${safeMin} 回合` : `${safeMin}-${safeMax} 回合`;
|
||
return {
|
||
fromLevel,
|
||
toLevel,
|
||
task,
|
||
taskName,
|
||
taskText,
|
||
minTurns: safeMin,
|
||
maxTurns: safeMax,
|
||
turnText,
|
||
speed,
|
||
speedLabel: GBHU_SPEED_LABELS[speed] || speed || '-',
|
||
logic,
|
||
};
|
||
}
|
||
|
||
function gbhuTaskText(task, taskName, skillMap) {
|
||
const desc = gbhdCleanMarkup(task?.desc || '');
|
||
const param = Number(task?.param) || 0;
|
||
const skill = skillMap.get(Number(task?.skillParam));
|
||
const suffix = skill?.name ? ` · ${skill.name}` : '';
|
||
return `${taskName}${param ? ` ${param}` : ''}${suffix}${desc ? `|${desc}` : ''}`;
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Hero Pricing Model Sub-module
|
||
// ════════════════════════════════════════════
|
||
|
||
async function gbhpLoadPricing(forceRefresh = false) {
|
||
const table = document.getElementById('gbhp-pricing-table');
|
||
const badge = document.getElementById('gbhp-pricing-badge');
|
||
if (!table) return;
|
||
|
||
if (!forceRefresh && gbhpPricingData) {
|
||
gbhpRenderPricingTable();
|
||
return;
|
||
}
|
||
|
||
table.innerHTML = '<div class="gb-loading">正在读取最新定价模型...</div>';
|
||
if (badge) badge.textContent = '加载中';
|
||
|
||
try {
|
||
const resp = await fetch('/api/balance-modeling/hero-pricing/latest?t=' + Date.now());
|
||
const data = await resp.json();
|
||
if (!resp.ok) throw new Error(data.error || 'API error');
|
||
|
||
gbhpPricingData = data;
|
||
gbhpRenderPricingMeta();
|
||
gbhpRenderPricingTable();
|
||
} catch (e) {
|
||
if (badge) badge.textContent = '读取失败';
|
||
table.innerHTML = `<div class="gb-stats-empty">定价模型读取失败: ${gbEscapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function gbhpRenderPricingMeta() {
|
||
if (!gbhpPricingData) return;
|
||
const badge = document.getElementById('gbhp-pricing-badge');
|
||
const versionInfo = document.getElementById('gbhp-version-info');
|
||
const sourceInfo = document.getElementById('gbhp-source-info');
|
||
const summary = gbhpPricingData.summary || {};
|
||
|
||
if (badge) {
|
||
badge.textContent = `${gbhpPricingData.version || '--'} · ${summary.heroCount || 0} 英雄`;
|
||
}
|
||
if (versionInfo) {
|
||
const generated = gbhpPricingData.generatedOn ? ` · ${gbhpPricingData.generatedOn}` : '';
|
||
versionInfo.textContent = `${gbhpPricingData.version || '--'}${generated} · 总价 ${gbFormatNumber(summary.minTotal)}-${gbFormatNumber(summary.maxTotal)}`;
|
||
}
|
||
if (sourceInfo) {
|
||
sourceInfo.textContent = gbhpPricingData.source || '';
|
||
}
|
||
}
|
||
|
||
function gbhpRenderPricingTable() {
|
||
const table = document.getElementById('gbhp-pricing-table');
|
||
if (!table || !gbhpPricingData) return;
|
||
|
||
const heroes = gbhpSortedHeroes(gbhpPricingData.heroes || []);
|
||
if (heroes.length === 0) {
|
||
table.innerHTML = '<div class="gb-stats-empty">最新定价模型没有英雄数据</div>';
|
||
return;
|
||
}
|
||
|
||
const summary = gbhpPricingData.summary || {};
|
||
const maxTotal = summary.maxTotal || Math.max(...heroes.map(h => h.maxTotal || 0), 1);
|
||
|
||
table.innerHTML = `<div class="gbhp-table-wrap">
|
||
<table class="gb-table gbhp-table">
|
||
<thead>
|
||
<tr>
|
||
<th>英雄</th>
|
||
<th>职阶</th>
|
||
<th>Lv.1</th>
|
||
<th>Lv.2</th>
|
||
<th>Lv.3</th>
|
||
<th>Lv.4</th>
|
||
<th>峰值</th>
|
||
<th>技能摘要</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${heroes.map(hero => gbhpRenderHeroRow(hero, maxTotal)).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
gbhpWirePricingTable(table);
|
||
}
|
||
|
||
function gbhpRenderHeroRow(hero, maxTotal) {
|
||
const levels = {};
|
||
(hero.levels || []).forEach(row => {
|
||
levels[row.level] = row;
|
||
});
|
||
|
||
return `<tr class="gbhp-hero-row" data-hero-key="${gbEscapeAttr(hero.giantTypeKey)}">
|
||
<td>
|
||
<div class="gbhp-hero-main">
|
||
<span class="gbhp-hero-name">${gbEscapeHtml(hero.hero || hero.giantTypeKey)}</span>
|
||
<span class="gbhp-hero-key">${gbEscapeHtml(hero.giantTypeKey || '')}</span>
|
||
</div>
|
||
</td>
|
||
<td><span class="gbhp-class-pill">${gbEscapeHtml(hero.chessType || '--')}</span></td>
|
||
${[1, 2, 3, 4].map(level => gbhpRenderLevelCell(hero.giantTypeKey, levels[level], maxTotal)).join('')}
|
||
<td><strong>${gbFormatNumber(hero.maxTotal)}</strong></td>
|
||
<td class="gbhp-skill-summary">${gbEscapeHtml(gbhpSkillSummary(hero))}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function gbhpRenderLevelCell(heroKey, row, maxTotal) {
|
||
if (!row) return '<td class="gbhp-empty-cell">--</td>';
|
||
const total = row.totalPrice || 0;
|
||
const width = Math.max(4, Math.min(100, maxTotal ? total / maxTotal * 100 : 0));
|
||
const title = `Lv.${row.level} 总价 ${gbFormatNumber(row.totalPrice)}`;
|
||
|
||
return `<td>
|
||
<button class="gbhp-price-cell" title="${gbEscapeAttr(title)}" data-hero-key="${gbEscapeAttr(heroKey)}" data-level="${row.level}">
|
||
<span class="gbhp-bar" style="width:${width.toFixed(1)}%"></span>
|
||
<span class="gbhp-value">${gbFormatNumber(row.totalPrice)}</span>
|
||
<span class="gbhp-breakdown">属 ${gbFormatNumber(row.attributePremium)} / 技 ${gbFormatNumber(row.skillPremium)}</span>
|
||
</button>
|
||
</td>`;
|
||
}
|
||
|
||
function gbhpWirePricingTable(container) {
|
||
container.querySelectorAll('.gbhp-hero-row').forEach(row => {
|
||
row.addEventListener('click', () => {
|
||
gbhpShowHeroDetail(row.dataset.heroKey || '');
|
||
});
|
||
});
|
||
container.querySelectorAll('.gbhp-price-cell').forEach(button => {
|
||
button.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const level = parseInt(button.dataset.level || '', 10);
|
||
gbhpShowHeroDetail(button.dataset.heroKey || '', Number.isNaN(level) ? null : level);
|
||
});
|
||
});
|
||
}
|
||
|
||
function gbhpShowHeroDetail(heroKey, selectedLevel = null) {
|
||
if (!gbhpPricingData) return;
|
||
const hero = (gbhpPricingData.heroes || []).find(h => h.giantTypeKey === heroKey);
|
||
if (!hero) return;
|
||
|
||
const rows = selectedLevel
|
||
? (hero.levels || []).filter(row => row.level === selectedLevel)
|
||
: (hero.levels || []);
|
||
const titleLevel = selectedLevel ? ` Lv.${selectedLevel}` : '';
|
||
const modal = document.createElement('div');
|
||
modal.className = 'gb-modal-overlay';
|
||
|
||
modal.innerHTML = `
|
||
<div class="gb-modal gbhp-modal">
|
||
<div class="gb-modal-header">
|
||
<span class="gb-modal-title">${gbEscapeHtml(hero.hero || heroKey)}${titleLevel} · ${gbEscapeHtml(gbhpPricingData.version || '')}</span>
|
||
<button class="gb-modal-close" onclick="this.closest('.gb-modal-overlay').remove()">×</button>
|
||
</div>
|
||
<div class="gb-modal-body">
|
||
<div class="gb-detail-meta">
|
||
<span><strong>模型:</strong> ${gbEscapeHtml(gbhpPricingData.version || '--')}</span>
|
||
<span><strong>职阶:</strong> ${gbEscapeHtml(hero.chessType || '--')}</span>
|
||
<span><strong>Key:</strong> ${gbEscapeHtml(hero.giantTypeKey || '--')}</span>
|
||
</div>
|
||
<div class="gb-detail-stats">
|
||
<span>基础价: ${gbFormatNumber(gbhpPricingData.baseUnitPrice)}</span>
|
||
<span>英雄数: ${(gbhpPricingData.summary || {}).heroCount || 0}</span>
|
||
<span>数据源: ${gbEscapeHtml(gbhpPricingData.source || '--')}</span>
|
||
</div>
|
||
|
||
<h4>等级定价拆分</h4>
|
||
<div class="gb-detail-table-wrap gbhp-detail-table-wrap">
|
||
<table class="gb-table gb-table-sm gbhp-detail-table">
|
||
<thead>
|
||
<tr>
|
||
<th>等级</th>
|
||
<th>属性</th>
|
||
<th>属性基准</th>
|
||
<th>基础价</th>
|
||
<th>属性溢价</th>
|
||
<th>技能原价</th>
|
||
<th>技能基准</th>
|
||
<th>技能溢价</th>
|
||
<th>总价</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows.map(row => gbhpRenderDetailRow(row)).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<h4>技能计价</h4>
|
||
<div class="gbhp-skill-list">
|
||
${rows.map(row => gbhpRenderSkillBlock(row)).join('')}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
function gbhpRenderDetailRow(row) {
|
||
return `<tr>
|
||
<td>Lv.${row.level}</td>
|
||
<td>${gbhpStatsText(row.stats)}</td>
|
||
<td>${gbhpStatsText(row.baselines)}</td>
|
||
<td>${gbFormatNumber(row.baseUnitPrice)}</td>
|
||
<td>${gbFormatNumber(row.attributePremium)}</td>
|
||
<td>${gbFormatNumber(row.rawSkillPrice)}</td>
|
||
<td>${gbFormatNumber(row.skillPackageBaseline)}</td>
|
||
<td>${gbFormatNumber(row.skillPremium)}</td>
|
||
<td><strong>${gbFormatNumber(row.totalPrice)}</strong></td>
|
||
</tr>`;
|
||
}
|
||
|
||
function gbhpRenderSkillBlock(row) {
|
||
const skills = row.pricedSkills || [];
|
||
const skillHtml = skills.length
|
||
? skills.map(skill => `<span class="gbhp-skill-chip">${gbEscapeHtml(skill.label)}${skill.price !== null ? ` <strong>${gbFormatNumber(skill.price)}</strong>` : ''}</span>`).join('')
|
||
: '<span class="gbhp-skill-empty">无计价技能</span>';
|
||
|
||
return `<div class="gbhp-skill-block">
|
||
<div class="gbhp-skill-level">Lv.${row.level}</div>
|
||
<div class="gbhp-skill-chips">${skillHtml}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function gbhpSortedHeroes(heroes) {
|
||
const sorted = [...heroes];
|
||
const finalTotal = hero => typeof hero.finalTotal === 'number' ? hero.finalTotal : -Infinity;
|
||
const maxTotal = hero => typeof hero.maxTotal === 'number' ? hero.maxTotal : -Infinity;
|
||
const heroName = hero => hero.hero || hero.giantTypeKey || '';
|
||
|
||
sorted.sort((a, b) => {
|
||
if (gbhpSort === 'finalAsc') return finalTotal(a) - finalTotal(b);
|
||
if (gbhpSort === 'maxDesc') return maxTotal(b) - maxTotal(a);
|
||
if (gbhpSort === 'className') return `${a.chessType || ''}${heroName(a)}`.localeCompare(`${b.chessType || ''}${heroName(b)}`, 'zh-Hans-CN');
|
||
if (gbhpSort === 'heroName') return heroName(a).localeCompare(heroName(b), 'zh-Hans-CN');
|
||
return finalTotal(b) - finalTotal(a);
|
||
});
|
||
return sorted;
|
||
}
|
||
|
||
function gbhpSkillSummary(hero) {
|
||
const levels = hero.levels || [];
|
||
const finalRow = levels[levels.length - 1];
|
||
if (!finalRow || !finalRow.pricedSkills || finalRow.pricedSkills.length === 0) return '无计价技能';
|
||
return finalRow.pricedSkills.map(skill => skill.label).slice(0, 3).join(' / ');
|
||
}
|
||
|
||
function gbhpStatsText(stats) {
|
||
if (!stats) return '--';
|
||
return `血${gbFormatNumber(stats.hp)} 攻${gbFormatNumber(stats.attack)} 防${gbFormatNumber(stats.defense)} 移${gbFormatNumber(stats.move)} 射${gbFormatNumber(stats.range)}`;
|
||
}
|
||
|
||
function gbFormatNumber(value, decimals = 2) {
|
||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||
const fixed = value.toFixed(decimals);
|
||
return fixed.replace(/\.?0+$/, '');
|
||
}
|
||
|
||
function gbEscapeHtml(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, ch => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[ch]));
|
||
}
|
||
|
||
function gbEscapeAttr(value) {
|
||
return gbEscapeHtml(value).replace(/`/g, '`');
|
||
}
|
||
|
||
// ── Lazy init via MutationObserver (same pattern as other modules) ──
|
||
|
||
(function () {
|
||
const panel = document.getElementById('panel-gamebalance');
|
||
if (!panel) return;
|
||
|
||
const observer = new MutationObserver(() => {
|
||
if (panel.classList.contains('active')) {
|
||
gbInit();
|
||
observer.disconnect();
|
||
}
|
||
});
|
||
observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
|
||
|
||
// Also init if already active
|
||
if (panel.classList.contains('active')) {
|
||
gbInit();
|
||
observer.disconnect();
|
||
}
|
||
})();
|