feat: add dashboard audio inventory
This commit is contained in:
parent
1723dea5b5
commit
1bed2b709e
@ -720,6 +720,101 @@ body::after {
|
||||
.filter-select { cursor: pointer; }
|
||||
.filter-select option { background: var(--bg-card); color: var(--text-primary); }
|
||||
|
||||
/* ========== Audio Inventory ========== */
|
||||
.audio-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.audio-meta,
|
||||
.audio-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audio-refresh-btn {
|
||||
min-height: 32px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||
border-radius: 6px;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
color: var(--accent-blue);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audio-refresh-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.audio-refresh-btn.running {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audio-summary-grid {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.audio-system-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.audio-system-note {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-card-hover);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audio-system-summary {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.audio-source {
|
||||
color: var(--text-muted);
|
||||
font-family: Consolas, 'SFMono-Regular', monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.audio-note {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audio-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.audio-table td.wrap {
|
||||
white-space: normal;
|
||||
min-width: 180px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.audio-code-cell {
|
||||
font-family: Consolas, 'SFMono-Regular', monospace;
|
||||
font-size: 11px !important;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========== Mechanics Documents ========== */
|
||||
.mechanics-layout {
|
||||
display: grid;
|
||||
|
||||
5963
Tools/Dashboard/data/audio_inventory.json
Normal file
5963
Tools/Dashboard/data/audio_inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@
|
||||
"tab:codex",
|
||||
"tab:form-helper",
|
||||
"tab:art-dev",
|
||||
"tab:audio",
|
||||
"tab:sns",
|
||||
"tab:sentiment",
|
||||
"tab:gamebalance",
|
||||
@ -23,5 +24,5 @@
|
||||
"tab:marketing",
|
||||
"tab:ai-logic"
|
||||
],
|
||||
"updated_at": "2026-06-26T18:28:58+08:00"
|
||||
}
|
||||
"updated_at": "2026-06-30T00:31:17+08:00"
|
||||
}
|
||||
|
||||
@ -101,6 +101,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4Z"/><path d="M15 5l4 4"/></svg>
|
||||
美术开发
|
||||
</button>
|
||||
<button class="sidebar-tab" data-tab="audio">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
音频音效
|
||||
</button>
|
||||
<button class="sidebar-tab" data-tab="sentiment">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg>
|
||||
舆情分析
|
||||
@ -611,6 +615,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Audio Inventory Panel ===== -->
|
||||
<div id="panel-audio" class="tab-panel">
|
||||
<div class="module-card audio-module audio-overview">
|
||||
<div class="module-header">
|
||||
<span class="module-title">音频音效盘点</span>
|
||||
<span class="module-badge" id="audio-inventory-badge">未加载</span>
|
||||
</div>
|
||||
<div class="module-body">
|
||||
<div class="audio-toolbar">
|
||||
<div class="audio-meta" id="audio-generated-at">数据生成: --</div>
|
||||
<button type="button" class="audio-refresh-btn" id="audio-refresh-btn">重新扫描</button>
|
||||
</div>
|
||||
<div id="audio-summary" class="summary-grid audio-summary-grid"></div>
|
||||
<div id="audio-system-notes" class="audio-system-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sub-tabs" id="audio-sub-tabs">
|
||||
<button class="sub-tab active" data-audio-view="game">游戏内音效</button>
|
||||
<button class="sub-tab" data-audio-view="ui">UI音效</button>
|
||||
<button class="sub-tab" data-audio-view="music">音乐</button>
|
||||
</div>
|
||||
|
||||
<div id="sub-audio-game" class="sub-panel active">
|
||||
<div id="audio-game-content">
|
||||
<div class="loading-inline">正在加载游戏内音效数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sub-audio-ui" class="sub-panel">
|
||||
<div id="audio-ui-content">
|
||||
<div class="loading-inline">正在加载 UI 音效数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sub-audio-music" class="sub-panel">
|
||||
<div id="audio-music-content">
|
||||
<div class="loading-inline">正在加载音乐数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel-sentiment" class="tab-panel">
|
||||
<div class="sub-tabs" id="sentiment-sub-tabs">
|
||||
<button class="sub-tab active" data-sent-view="records">舆情记录</button>
|
||||
@ -1420,6 +1464,7 @@
|
||||
<script src="js/video_helper.js"></script>
|
||||
<script src="js/form_helper.js"></script>
|
||||
<script src="js/art_dev.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/email_processing.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -870,6 +870,9 @@ function activateDashboardTab(tab) {
|
||||
if (tab.dataset.tab === 'art-dev' && typeof artDevLoad === 'function') {
|
||||
artDevLoad();
|
||||
}
|
||||
if (tab.dataset.tab === 'audio' && typeof audioInventoryLoad === 'function') {
|
||||
audioInventoryLoad();
|
||||
}
|
||||
if (tab.dataset.tab === 'email-processing' && typeof emailProcessingLoad === 'function') {
|
||||
emailProcessingLoad();
|
||||
}
|
||||
|
||||
407
Tools/Dashboard/js/audio.js
Normal file
407
Tools/Dashboard/js/audio.js
Normal file
@ -0,0 +1,407 @@
|
||||
/**
|
||||
* TH1 Dashboard - Audio inventory.
|
||||
*/
|
||||
|
||||
let audioInventoryData = null;
|
||||
let audioInventoryLoaded = false;
|
||||
let audioCurrentView = 'game';
|
||||
|
||||
function audioEsc(value) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value == null ? '' : String(value);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function audioBadge(text, color = 'blue') {
|
||||
return `<span class="badge badge-${color}">${audioEsc(text)}</span>`;
|
||||
}
|
||||
|
||||
function audioFileRef(item) {
|
||||
if (!item) return '<span class="audio-muted">-</span>';
|
||||
const line = item.line ? `:${item.line}` : '';
|
||||
const context = item.context ? ` · ${audioEsc(item.context)}` : '';
|
||||
return `<span class="audio-source">${audioEsc(item.file || item.source || '')}${line}${context}</span>`;
|
||||
}
|
||||
|
||||
function audioAssetRef(asset) {
|
||||
if (!asset) return '<span class="audio-muted">未找到资源</span>';
|
||||
return `${audioEsc(asset.name)} <span class="audio-muted">${asset.sizeMB} MB</span>`;
|
||||
}
|
||||
|
||||
function audioStatusIcon(ok) {
|
||||
return ok ? audioBadge('已接入', 'green') : audioBadge('未接入', 'gray');
|
||||
}
|
||||
|
||||
function audioGroupBy(items, getter) {
|
||||
const map = new Map();
|
||||
items.forEach(item => {
|
||||
const key = getter(item) || '其他';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(item);
|
||||
});
|
||||
return Array.from(map.entries());
|
||||
}
|
||||
|
||||
function audioRenderTable(headers, rows, emptyText = '暂无数据') {
|
||||
if (!rows || rows.length === 0) {
|
||||
return `<div class="loading-inline">${audioEsc(emptyText)}</div>`;
|
||||
}
|
||||
let html = '<div class="table-wrap"><table class="dtable audio-table"><thead><tr>';
|
||||
headers.forEach(header => {
|
||||
html += `<th>${audioEsc(header)}</th>`;
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
rows.forEach(row => {
|
||||
html += '<tr>';
|
||||
row.forEach(cell => {
|
||||
const isHtml = cell && typeof cell === 'object' && cell.html != null;
|
||||
const className = cell && typeof cell === 'object' && cell.className ? ` class="${cell.className}"` : '';
|
||||
html += `<td${className}>${isHtml ? cell.html : audioEsc(cell)}</td>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function audioRenderSummary() {
|
||||
const container = document.getElementById('audio-summary');
|
||||
const badge = document.getElementById('audio-inventory-badge');
|
||||
if (!container || !audioInventoryData) return;
|
||||
const s = audioInventoryData.summary || {};
|
||||
if (badge) {
|
||||
badge.textContent = `扫描 ${s.audioFiles || 0} 个音频文件`;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="summary-card"><div class="label">音频文件</div><div class="value blue">${s.audioFiles || 0}</div></div>
|
||||
<div class="summary-card"><div class="label">游戏内 SFX</div><div class="value orange">${s.gameSfxKeys || 0}</div></div>
|
||||
<div class="summary-card"><div class="label">UI SFX</div><div class="value purple">${s.uiSfxKeys || 0}</div></div>
|
||||
<div class="summary-card"><div class="label">音乐曲目</div><div class="value green">${s.musicTracks || 0}</div></div>
|
||||
<div class="summary-card"><div class="label">投射物 SFX</div><div class="value yellow">${s.projectileSfxTypes || 0}</div></div>
|
||||
<div class="summary-card"><div class="label">未注册资源</div><div class="value red">${s.unregisteredAudioFiles || 0}</div></div>
|
||||
`;
|
||||
const generated = document.getElementById('audio-generated-at');
|
||||
if (generated) {
|
||||
const dt = audioInventoryData.generatedAt ? new Date(audioInventoryData.generatedAt) : null;
|
||||
generated.textContent = dt ? `数据生成: ${dt.toLocaleString()}` : '数据生成: --';
|
||||
}
|
||||
}
|
||||
|
||||
function audioRenderSfxRows(items) {
|
||||
return items.map(item => [
|
||||
{ html: `<strong>${audioEsc(item.key)}</strong><div class="audio-muted">${audioEsc(item.categoryLabel || '')}</div>`, className: 'wrap' },
|
||||
{ html: audioStatusIcon(item.registered) },
|
||||
{ html: item.asset ? audioAssetRef(item.asset) : audioEsc(item.resourcePath || '-') },
|
||||
item.directUseCount || 0,
|
||||
item.projectileCount || 0,
|
||||
{ html: item.used ? audioBadge('有触发', 'green') : audioBadge('未发现触发', 'gray') },
|
||||
]);
|
||||
}
|
||||
|
||||
function audioRenderUsageRows(usages) {
|
||||
return usages.map(usage => [
|
||||
{ html: `<strong>${audioEsc(usage.key)}</strong><div class="audio-muted">${audioEsc(usage.categoryLabel || '')}</div>`, className: 'wrap' },
|
||||
usage.module || '',
|
||||
{ html: audioFileRef(usage), className: 'wrap' },
|
||||
{ html: audioEsc(usage.lineText || ''), className: 'wrap audio-code-cell' },
|
||||
]);
|
||||
}
|
||||
|
||||
function audioRenderProjectileRows(projectiles) {
|
||||
return projectiles.map(projectile => {
|
||||
const refs = projectile.references || [];
|
||||
const skills = Array.from(new Set(refs.flatMap(ref => ref.skills || []))).slice(0, 6);
|
||||
const refPreview = refs.slice(0, 3).map(ref => {
|
||||
const skillText = (ref.skills || []).length ? ` · ${ref.skills.join(', ')}` : '';
|
||||
return `${ref.file}:${ref.line}${skillText}`;
|
||||
}).join('<br>');
|
||||
return [
|
||||
{ html: `<strong>${audioEsc(projectile.name)}</strong><div class="audio-muted">Move: ${audioEsc(projectile.moveType)}</div>`, className: 'wrap' },
|
||||
{ html: projectile.hasSfx ? audioBadge(projectile.sfxKey || '-', 'green') : audioBadge('无', 'gray') },
|
||||
`${projectile.animTime}s`,
|
||||
projectile.hasDelay ? '延迟播放' : '立即播放',
|
||||
{ html: skills.length ? skills.map(skill => audioBadge(skill, 'cyan')).join(' ') : '<span class="audio-muted">未扫到 SkillType</span>', className: 'wrap' },
|
||||
{ html: refPreview || '<span class="audio-muted">无代码引用</span>', className: 'wrap' },
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function audioRenderUnregisteredAssets(kind) {
|
||||
const assets = (audioInventoryData.assets || []).filter(asset => !asset.isRegistered && (!kind || asset.kind === kind));
|
||||
return assets.map(asset => [
|
||||
asset.name,
|
||||
asset.kind === 'music' ? '音乐' : '音效',
|
||||
asset.resourcePath,
|
||||
`${asset.sizeMB} MB`,
|
||||
]);
|
||||
}
|
||||
|
||||
function audioRenderGameView() {
|
||||
const container = document.getElementById('audio-game-content');
|
||||
if (!container || !audioInventoryData) return;
|
||||
|
||||
const gameSfx = (audioInventoryData.sfx || []).filter(item => !item.isUi);
|
||||
const gameUsages = (audioInventoryData.playAudioUsages || []).filter(item => item.category !== 'ui');
|
||||
const animationUsages = gameUsages.filter(item => item.module === '动画表现' || item.module === '渲染/投射物');
|
||||
const moduleUsages = gameUsages.filter(item => item.module !== '动画表现' && item.module !== '渲染/投射物');
|
||||
const projectileSfx = (audioInventoryData.projectiles || []).filter(item => item.hasSfx);
|
||||
const skillRows = (audioInventoryData.skillProjectilePoints || []).filter(item => item.hasSfx);
|
||||
|
||||
let html = '';
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">技能 / 投射物音效</span><span class="module-badge">ProjectileTypeDataAssets</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['投射物', 'SFX Key', '时长', '播放时机', '关联技能', '代码引用'],
|
||||
audioRenderProjectileRows(projectileSfx),
|
||||
'当前没有配置 HasSFX 的投射物'
|
||||
);
|
||||
if (skillRows.length > 0) {
|
||||
html += `<div class="audio-note">已扫描到 ${skillRows.length} 处技能/动画代码引用了带音效的投射物类型。</div>`;
|
||||
}
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">动画表现触发点</span><span class="module-badge">TH1_Anim / TH1_Renderer</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['SFX Key', '模块', '位置', '调用'],
|
||||
audioRenderUsageRows(animationUsages),
|
||||
'当前没有动画表现直接触发音效'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">关键模块触发点</span><span class="module-badge">Action / Player / Map / Audio</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['SFX Key', '模块', '位置', '调用'],
|
||||
audioRenderUsageRows(moduleUsages),
|
||||
'当前没有关键模块直接触发音效'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">游戏内 SFX Key 总览</span><span class="module-badge">AudioManager 注册 + 代码触发</span></div><div class="module-body">';
|
||||
const grouped = audioGroupBy(gameSfx, item => item.categoryLabel);
|
||||
grouped.forEach(([label, items]) => {
|
||||
html += `<div class="section-title">${audioEsc(label)}</div>`;
|
||||
html += audioRenderTable(
|
||||
['Key', '注册', '资源', '直接调用', '投射物', '状态'],
|
||||
audioRenderSfxRows(items),
|
||||
'暂无音效'
|
||||
);
|
||||
});
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">未注册音效资源</span><span class="module-badge">Audio/SFX</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['文件', '类型', 'ResourcePath', '大小'],
|
||||
audioRenderUnregisteredAssets('sfx'),
|
||||
'没有未注册 SFX 资源'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function audioRenderUiView() {
|
||||
const container = document.getElementById('audio-ui-content');
|
||||
if (!container || !audioInventoryData) return;
|
||||
|
||||
const uiSfx = (audioInventoryData.sfx || []).filter(item => item.isUi);
|
||||
const uiUsages = (audioInventoryData.playAudioUsages || []).filter(item => item.category === 'ui' || item.module === 'UI');
|
||||
const uiAssets = (audioInventoryData.assets || []).filter(asset => asset.kind === 'sfx' && (
|
||||
asset.name.startsWith('UI_') || asset.name === 'start.mp3'
|
||||
));
|
||||
|
||||
let html = '';
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">UI 音效触发点</span><span class="module-badge">UI / Button / Chat</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['SFX Key', '模块', '位置', '调用'],
|
||||
audioRenderUsageRows(uiUsages),
|
||||
'当前没有 UI 音效触发点'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">UI SFX Key 总览</span><span class="module-badge">AudioManager</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['Key', '注册', '资源', '直接调用', '投射物', '状态'],
|
||||
audioRenderSfxRows(uiSfx),
|
||||
'当前没有 UI SFX Key'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">UI 音频资源池</span><span class="module-badge">Audio/SFX/UI_*</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['文件', '注册 Key', 'ResourcePath', '大小'],
|
||||
uiAssets.map(asset => [
|
||||
asset.name,
|
||||
{ html: asset.registeredKeys.length ? asset.registeredKeys.map(key => audioBadge(key, 'green')).join(' ') : audioBadge('未注册', 'gray') },
|
||||
asset.resourcePath,
|
||||
`${asset.sizeMB} MB`,
|
||||
]),
|
||||
'当前没有 UI 音频资源'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function audioRenderMusicView() {
|
||||
const container = document.getElementById('audio-music-content');
|
||||
if (!container || !audioInventoryData) return;
|
||||
|
||||
const tracks = audioInventoryData.music?.tracks || [];
|
||||
const assignments = audioInventoryData.music?.playerAssignments || [];
|
||||
const usages = audioInventoryData.music?.usages || [];
|
||||
|
||||
let html = '';
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">音乐曲目</span><span class="module-badge">MusicDataAssets + AudioManager</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['MusicName', '曲目信息', '资源', '阵营绑定', '调用', '状态'],
|
||||
tracks.map(track => {
|
||||
const meta = track.metadata || {};
|
||||
const title = meta.title || '-';
|
||||
const original = meta.originalTitle ? `<div class="audio-muted">${audioEsc(meta.originalTitle)}</div>` : '';
|
||||
const credits = [meta.composer, meta.arranger].filter(Boolean).join(' / ');
|
||||
return [
|
||||
{ html: `<strong>${audioEsc(track.musicName)}</strong>`, className: 'wrap' },
|
||||
{ html: `${audioEsc(title)}${original}<div class="audio-muted">${audioEsc(credits)}</div>`, className: 'wrap' },
|
||||
{ html: track.asset ? audioAssetRef(track.asset) : audioEsc(track.resourcePath || '-') },
|
||||
track.assignments?.length || 0,
|
||||
track.usageCount || 0,
|
||||
{ html: audioStatusIcon(track.registered) },
|
||||
];
|
||||
}),
|
||||
'当前没有音乐曲目'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">阵营音乐绑定</span><span class="module-badge">PlayerDataAssets.MusicName</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['Force/Civ', '阵营', '领袖', 'MusicName', '状态'],
|
||||
assignments.map(item => [
|
||||
`${item.forceId}/${item.civId}`,
|
||||
{ html: `${audioEsc(item.forceName || '-')}<div class="audio-muted">${audioEsc(item.civName || '')}</div>`, className: 'wrap' },
|
||||
item.leaderName || '-',
|
||||
item.musicName || '-',
|
||||
{ html: item.hasMusic ? audioBadge('已绑定', 'green') : audioBadge('未绑定', 'gray') },
|
||||
]),
|
||||
'当前没有阵营音乐绑定'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">音乐播放入口</span><span class="module-badge">PlayMusic</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['表达式 / Key', '模块', '位置', '调用'],
|
||||
usages.map(usage => [
|
||||
{ html: (usage.keys || []).length ? usage.keys.map(key => audioBadge(key, 'green')).join(' ') : `<span class="audio-muted">${audioEsc(usage.expression || '-')}</span>`, className: 'wrap' },
|
||||
usage.module || '',
|
||||
{ html: audioFileRef(usage), className: 'wrap' },
|
||||
{ html: audioEsc(usage.lineText || ''), className: 'wrap audio-code-cell' },
|
||||
]),
|
||||
'当前没有音乐播放入口'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="module-card audio-module"><div class="module-header"><span class="module-title">未注册音乐资源</span><span class="module-badge">Audio/*.wav</span></div><div class="module-body">';
|
||||
html += audioRenderTable(
|
||||
['文件', '类型', 'ResourcePath', '大小'],
|
||||
audioRenderUnregisteredAssets('music'),
|
||||
'没有未注册音乐资源'
|
||||
);
|
||||
html += '</div></div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function audioRenderSystemNotes() {
|
||||
const container = document.getElementById('audio-system-notes');
|
||||
if (!container || !audioInventoryData) return;
|
||||
const notes = audioInventoryData.systemNotes || [];
|
||||
container.innerHTML = notes.map(note => `
|
||||
<div class="audio-system-note">
|
||||
<div>
|
||||
<strong>${audioEsc(note.name)}</strong>
|
||||
<span class="audio-muted">${audioEsc(note.type)}</span>
|
||||
</div>
|
||||
<div class="audio-system-summary">${audioEsc(note.summary)}</div>
|
||||
<div class="audio-source">${audioEsc(note.file)}${note.line ? ':' + note.line : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function audioRenderCurrentView() {
|
||||
if (!audioInventoryData) return;
|
||||
audioRenderSummary();
|
||||
audioRenderSystemNotes();
|
||||
if (audioCurrentView === 'ui') audioRenderUiView();
|
||||
else if (audioCurrentView === 'music') audioRenderMusicView();
|
||||
else audioRenderGameView();
|
||||
}
|
||||
|
||||
async function audioInventoryLoad(force = false) {
|
||||
if (audioInventoryLoaded && !force) {
|
||||
audioRenderCurrentView();
|
||||
return;
|
||||
}
|
||||
const activeContainer = document.getElementById(`audio-${audioCurrentView}-content`);
|
||||
if (activeContainer) activeContainer.innerHTML = '<div class="loading-inline">正在读取音频盘点数据...</div>';
|
||||
try {
|
||||
const resp = await fetch(`data/audio_inventory.json?t=${Date.now()}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
audioInventoryData = await resp.json();
|
||||
audioInventoryLoaded = true;
|
||||
audioRenderCurrentView();
|
||||
} catch (err) {
|
||||
const panel = document.getElementById('audio-game-content') || activeContainer;
|
||||
if (panel) {
|
||||
panel.innerHTML = `<div class="loading-inline">音频盘点数据读取失败: ${audioEsc(err.message || err)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function audioRefreshInventory() {
|
||||
const btn = document.getElementById('audio-refresh-btn');
|
||||
if (!btn || btn.classList.contains('running')) return;
|
||||
btn.classList.add('running');
|
||||
btn.textContent = '扫描中...';
|
||||
try {
|
||||
const resp = await fetch('/api/audio-inventory/refresh', { method: 'POST' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || data.success === false) {
|
||||
throw new Error(data.error || data.stderr || `HTTP ${resp.status}`);
|
||||
}
|
||||
audioInventoryLoaded = false;
|
||||
await audioInventoryLoad(true);
|
||||
if (typeof showToast === 'function') showToast('音频盘点已更新', 'success');
|
||||
} catch (err) {
|
||||
if (typeof showToast === 'function') showToast('音频盘点更新失败: ' + (err.message || err), 'error');
|
||||
} finally {
|
||||
btn.classList.remove('running');
|
||||
btn.textContent = '重新扫描';
|
||||
}
|
||||
}
|
||||
|
||||
function audioActivateSubTab(view) {
|
||||
audioCurrentView = view || 'game';
|
||||
document.querySelectorAll('#audio-sub-tabs .sub-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.audioView === audioCurrentView);
|
||||
});
|
||||
document.querySelectorAll('#panel-audio .sub-panel').forEach(panel => {
|
||||
panel.classList.toggle('active', panel.id === `sub-audio-${audioCurrentView}`);
|
||||
});
|
||||
audioInventoryLoad();
|
||||
}
|
||||
|
||||
function initAudioInventory() {
|
||||
document.querySelectorAll('#audio-sub-tabs .sub-tab').forEach(tab => {
|
||||
if (tab.dataset.audioBound) return;
|
||||
tab.dataset.audioBound = '1';
|
||||
tab.addEventListener('click', () => audioActivateSubTab(tab.dataset.audioView));
|
||||
});
|
||||
const btn = document.getElementById('audio-refresh-btn');
|
||||
if (btn && !btn.dataset.audioBound) {
|
||||
btn.dataset.audioBound = '1';
|
||||
btn.addEventListener('click', audioRefreshInventory);
|
||||
}
|
||||
if (document.getElementById('panel-audio')?.classList.contains('active')) {
|
||||
audioInventoryLoad();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initAudioInventory);
|
||||
696
Tools/Dashboard/scripts/export_audio_inventory.py
Normal file
696
Tools/Dashboard/scripts/export_audio_inventory.py
Normal file
@ -0,0 +1,696 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export the current TH1 audio inventory for the Dashboard.
|
||||
|
||||
The scan is source-driven: it reads AudioManager registrations, audio files,
|
||||
PlayAudio/PlayMusic call sites, projectile SFX config, projectile references,
|
||||
MusicDataAssets, and PlayerDataAssets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
DASHBOARD_DIR = SCRIPT_DIR.parent
|
||||
TOOLS_DIR = DASHBOARD_DIR.parent
|
||||
PROJECT_ROOT = TOOLS_DIR.parent
|
||||
UNITY_DIR = PROJECT_ROOT / "Unity"
|
||||
SCRIPTS_DIR = UNITY_DIR / "Assets" / "Scripts"
|
||||
BUNDLE_DIR = UNITY_DIR / "Assets" / "BundleResources"
|
||||
AUDIO_DIR = BUNDLE_DIR / "Audio"
|
||||
DATA_ASSETS_DIR = BUNDLE_DIR / "DataAssets"
|
||||
OUTPUT_PATH = DASHBOARD_DIR / "data" / "audio_inventory.json"
|
||||
|
||||
AUDIO_MANAGER = SCRIPTS_DIR / "TH1_Audio" / "AudioManager.cs"
|
||||
PROJECTILE_MANAGER = SCRIPTS_DIR / "TH1_Renderer" / "ProjectileManager.cs"
|
||||
PROJECTILE_ASSET = DATA_ASSETS_DIR / "ProjectileTypeDataAssets.asset"
|
||||
MUSIC_ASSET = DATA_ASSETS_DIR / "MusicDataAssets.asset"
|
||||
PLAYER_ASSET = DATA_ASSETS_DIR / "PlayerDataAssets.asset"
|
||||
|
||||
AUDIO_EXTENSIONS = {".wav", ".mp3", ".ogg", ".aif", ".aiff"}
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
|
||||
|
||||
def rel(path: Path) -> str:
|
||||
try:
|
||||
return path.relative_to(PROJECT_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def decode_yaml_scalar(raw: str) -> str:
|
||||
value = raw.strip()
|
||||
if not value:
|
||||
return ""
|
||||
if value.startswith('"'):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return value.strip('"')
|
||||
return value
|
||||
|
||||
|
||||
def parse_yaml_list_blocks(text: str, marker: str) -> list[list[str]]:
|
||||
marker_line = None
|
||||
lines = text.splitlines()
|
||||
for idx, line in enumerate(lines):
|
||||
if line.strip() == marker:
|
||||
marker_line = idx
|
||||
break
|
||||
if marker_line is None:
|
||||
return []
|
||||
|
||||
blocks: list[list[str]] = []
|
||||
current: list[str] | None = None
|
||||
for line in lines[marker_line + 1 :]:
|
||||
if line.startswith(" - "):
|
||||
if current:
|
||||
blocks.append(current)
|
||||
current = [line]
|
||||
continue
|
||||
if current is not None and (line.startswith(" ") or line.startswith(" ") or not line.strip()):
|
||||
current.append(line)
|
||||
continue
|
||||
if current is not None and line.startswith(" ") and not line.startswith(" - "):
|
||||
break
|
||||
if current:
|
||||
blocks.append(current)
|
||||
return blocks
|
||||
|
||||
|
||||
def field(block: list[str], key: str) -> str:
|
||||
prefix = f"{key}:"
|
||||
for idx, line in enumerate(block):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- "):
|
||||
stripped = stripped[2:].strip()
|
||||
if not stripped.startswith(prefix):
|
||||
continue
|
||||
raw = stripped[len(prefix) :].strip()
|
||||
if raw.startswith('"') and not raw.endswith('"'):
|
||||
parts = [raw]
|
||||
for next_line in block[idx + 1 :]:
|
||||
next_stripped = next_line.strip()
|
||||
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*:", next_stripped):
|
||||
break
|
||||
parts.append(next_stripped)
|
||||
if next_stripped.endswith('"'):
|
||||
break
|
||||
raw = " ".join(parts)
|
||||
return decode_yaml_scalar(raw)
|
||||
return ""
|
||||
|
||||
|
||||
def field_int(block: list[str], key: str, default: int = 0) -> int:
|
||||
value = field(block, key)
|
||||
if value == "":
|
||||
return default
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def field_float(block: list[str], key: str, default: float = 0.0) -> float:
|
||||
value = field(block, key)
|
||||
if value == "":
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def get_line_number(lines: list[str], pattern: str) -> int | None:
|
||||
for index, line in enumerate(lines, start=1):
|
||||
if pattern in line:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def extract_enum_map(path: Path, enum_name: str) -> dict[int, str]:
|
||||
text = read_text(path)
|
||||
match = re.search(rf"enum\s+{re.escape(enum_name)}\s*\{{(?P<body>.*?)\}}", text, re.S)
|
||||
if not match:
|
||||
return {}
|
||||
body = re.sub(r"//.*", "", match.group("body"))
|
||||
result: dict[int, str] = {}
|
||||
next_value = 0
|
||||
for raw_part in body.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "=" in part:
|
||||
name, raw_value = [piece.strip() for piece in part.split("=", 1)]
|
||||
try:
|
||||
next_value = int(raw_value)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
name = part
|
||||
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
||||
result[next_value] = name
|
||||
next_value += 1
|
||||
return result
|
||||
|
||||
|
||||
def load_audio_registry() -> dict[str, dict]:
|
||||
text = read_text(AUDIO_MANAGER)
|
||||
registry: dict[str, dict] = {}
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
match = re.search(r'path\["([^"]+)"\]\s*=\s*"([^"]+)"', line)
|
||||
if not match:
|
||||
continue
|
||||
key, resource_path = match.groups()
|
||||
registry[key] = {
|
||||
"key": key,
|
||||
"kind": "sfx" if key.startswith("SFX/") else "music",
|
||||
"resourcePath": resource_path,
|
||||
"source": rel(AUDIO_MANAGER),
|
||||
"line": line_no,
|
||||
}
|
||||
return registry
|
||||
|
||||
|
||||
def load_audio_assets(registry: dict[str, dict]) -> list[dict]:
|
||||
registered_by_resource: dict[str, list[str]] = {}
|
||||
for key, item in registry.items():
|
||||
registered_by_resource.setdefault(item["resourcePath"].lower(), []).append(key)
|
||||
|
||||
assets: list[dict] = []
|
||||
for path in sorted(AUDIO_DIR.rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||
continue
|
||||
resource_path = path.relative_to(BUNDLE_DIR).with_suffix("").as_posix()
|
||||
registered_keys = sorted(registered_by_resource.get(resource_path.lower(), []))
|
||||
assets.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"path": rel(path),
|
||||
"resourcePath": resource_path,
|
||||
"extension": path.suffix.lower().lstrip("."),
|
||||
"sizeBytes": path.stat().st_size,
|
||||
"sizeMB": round(path.stat().st_size / (1024 * 1024), 2),
|
||||
"kind": "sfx" if "/SFX/" in resource_path else "music",
|
||||
"registeredKeys": registered_keys,
|
||||
"isRegistered": bool(registered_keys),
|
||||
}
|
||||
)
|
||||
return assets
|
||||
|
||||
|
||||
def module_label(path: Path) -> str:
|
||||
rel_path = rel(path).replace("\\", "/")
|
||||
if "/TH1_Anim/" in rel_path:
|
||||
return "动画表现"
|
||||
if "/TH1_Renderer/" in rel_path:
|
||||
return "渲染/投射物"
|
||||
if "/TH1_UI/" in rel_path:
|
||||
return "UI"
|
||||
if "/TH1_Audio/" in rel_path:
|
||||
return "音频系统"
|
||||
if "/TH1_Logic/Skill/" in rel_path:
|
||||
return "技能逻辑"
|
||||
if "/TH1_Logic/Action/" in rel_path:
|
||||
return "行动逻辑"
|
||||
if "/TH1_Logic/Map/" in rel_path:
|
||||
return "地图交互"
|
||||
if "/TH1_Logic/Player/" in rel_path:
|
||||
return "玩家逻辑"
|
||||
if "/TH1_Logic/Core/" in rel_path:
|
||||
return "核心流程"
|
||||
if "/TH1_Data/" in rel_path:
|
||||
return "运行时数据"
|
||||
return "其他"
|
||||
|
||||
|
||||
def classify_sfx_key(key: str) -> str:
|
||||
if key.startswith("SFX/UI_") or key == "SFX/start":
|
||||
return "ui"
|
||||
if key.startswith("SFX/UNIT_"):
|
||||
return "unit_combat"
|
||||
if key.startswith("SFX/CITY_"):
|
||||
return "city"
|
||||
if key.startswith("SFX/GRID_"):
|
||||
return "grid"
|
||||
if key.startswith("SFX/PLAYER_"):
|
||||
return "economy"
|
||||
if key.startswith("SFX/MATCH_"):
|
||||
return "match"
|
||||
if key.startswith("SFX/ENV_"):
|
||||
return "ambient"
|
||||
return "game"
|
||||
|
||||
|
||||
def classify_sfx_label(category: str) -> str:
|
||||
return {
|
||||
"ui": "UI音效",
|
||||
"unit_combat": "单位/战斗",
|
||||
"city": "城市",
|
||||
"grid": "地图/建设",
|
||||
"economy": "经济",
|
||||
"match": "结算",
|
||||
"ambient": "环境",
|
||||
"game": "游戏内",
|
||||
}.get(category, category)
|
||||
|
||||
|
||||
def find_code_context(lines: list[str], index: int) -> str:
|
||||
class_name = ""
|
||||
method_name = ""
|
||||
method_re = re.compile(
|
||||
r"^(?:public|private|protected|internal|static|override|virtual|async|sealed|partial|\s)+"
|
||||
r"[\w<>,\[\]\.?]+\s+(\w+)\s*\("
|
||||
)
|
||||
for scan in range(index, max(-1, index - 160), -1):
|
||||
line = lines[scan].strip()
|
||||
method_match = method_re.search(line)
|
||||
if method_match and not any(line.startswith(prefix) for prefix in ("if ", "for ", "while ", "switch ")):
|
||||
name = method_match.group(1)
|
||||
if name not in {"if", "for", "while", "switch", "catch"}:
|
||||
method_name = name
|
||||
break
|
||||
for scan in range(index, max(-1, index - 260), -1):
|
||||
line = lines[scan].strip()
|
||||
class_match = re.search(r"\bclass\s+(\w+)", line)
|
||||
if class_match:
|
||||
class_name = class_match.group(1)
|
||||
break
|
||||
if class_name and method_name:
|
||||
return f"{class_name}.{method_name}"
|
||||
if method_name:
|
||||
return method_name
|
||||
if class_name:
|
||||
return class_name
|
||||
return ""
|
||||
|
||||
|
||||
def scan_play_audio_sites() -> tuple[list[dict], list[dict]]:
|
||||
usages: list[dict] = []
|
||||
dynamic: list[dict] = []
|
||||
button_defaults = {
|
||||
"hoverAudioName": "SFX/UI_buttonHover",
|
||||
"clickAudioName": "SFX/UI_buttonClick",
|
||||
}
|
||||
|
||||
button_path = SCRIPTS_DIR / "TH1_Audio" / "ButtonSFX.cs"
|
||||
if button_path.exists():
|
||||
button_text = read_text(button_path)
|
||||
for variable in list(button_defaults):
|
||||
match = re.search(rf'{variable}\s*=\s*"([^"]+)"', button_text)
|
||||
if match:
|
||||
button_defaults[variable] = match.group(1)
|
||||
|
||||
for path in sorted(SCRIPTS_DIR.rglob("*.cs")):
|
||||
text = read_text(path)
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//") or "PlayAudio" not in line:
|
||||
continue
|
||||
if "public void PlayAudio" in line:
|
||||
continue
|
||||
quoted_keys = re.findall(r'"(SFX/[^"]+)"', line)
|
||||
if "hoverAudioName" in line:
|
||||
quoted_keys.append(button_defaults["hoverAudioName"])
|
||||
if "clickAudioName" in line:
|
||||
quoted_keys.append(button_defaults["clickAudioName"])
|
||||
|
||||
context = find_code_context(lines, index)
|
||||
common = {
|
||||
"file": rel(path),
|
||||
"line": index + 1,
|
||||
"context": context,
|
||||
"module": module_label(path),
|
||||
"lineText": stripped,
|
||||
}
|
||||
if quoted_keys:
|
||||
for key in sorted(set(quoted_keys)):
|
||||
category = classify_sfx_key(key)
|
||||
usages.append(
|
||||
{
|
||||
**common,
|
||||
"key": key,
|
||||
"category": category,
|
||||
"categoryLabel": classify_sfx_label(category),
|
||||
}
|
||||
)
|
||||
else:
|
||||
call = re.search(r"PlayAudio\s*\(([^)]*)", line)
|
||||
expression = call.group(1).split(",")[0].strip() if call else ""
|
||||
dynamic.append({**common, "expression": expression})
|
||||
return usages, dynamic
|
||||
|
||||
|
||||
def scan_music_sites() -> list[dict]:
|
||||
usages: list[dict] = []
|
||||
for path in sorted(SCRIPTS_DIR.rglob("*.cs")):
|
||||
text = read_text(path)
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//") or ("PlayMusic" not in line and "PlayMusicDelayed" not in line):
|
||||
continue
|
||||
if "public void PlayMusic" in line or "private void PlayNextRotationMusic" in line:
|
||||
continue
|
||||
keys = [key for key in re.findall(r'"([^"]+)"', line) if not key.startswith("SFX/")]
|
||||
call = re.search(r"PlayMusic(?:Delayed)?\s*\(([^)]*)", line)
|
||||
expression = call.group(1).split(",")[0].strip() if call else ""
|
||||
usages.append(
|
||||
{
|
||||
"keys": sorted(set(keys)),
|
||||
"expression": expression,
|
||||
"file": rel(path),
|
||||
"line": index + 1,
|
||||
"context": find_code_context(lines, index),
|
||||
"module": module_label(path),
|
||||
"lineText": stripped,
|
||||
}
|
||||
)
|
||||
return usages
|
||||
|
||||
|
||||
def load_projectiles() -> list[dict]:
|
||||
projectile_names = extract_enum_map(PROJECTILE_MANAGER, "ProjectileType")
|
||||
move_names = extract_enum_map(PROJECTILE_MANAGER, "ProjectileMoveType")
|
||||
blocks = parse_yaml_list_blocks(read_text(PROJECTILE_ASSET), "ProjectileTypeInfoList:")
|
||||
result: list[dict] = []
|
||||
for block in blocks:
|
||||
projectile_id = field_int(block, "ProjectileType")
|
||||
move_id = field_int(block, "MoveType")
|
||||
has_sfx = field_int(block, "HasSFX") == 1
|
||||
has_delay = field_int(block, "HasSFXDelay") == 1
|
||||
sfx_name = field(block, "SFXName")
|
||||
result.append(
|
||||
{
|
||||
"id": projectile_id,
|
||||
"name": projectile_names.get(projectile_id, f"#{projectile_id}"),
|
||||
"moveType": move_names.get(move_id, f"#{move_id}"),
|
||||
"animTime": field_float(block, "AnimTime"),
|
||||
"hasSfx": has_sfx,
|
||||
"hasDelay": has_delay,
|
||||
"sfxKey": sfx_name,
|
||||
"source": rel(PROJECTILE_ASSET),
|
||||
"references": [],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def scan_projectile_references(projectiles: list[dict]) -> None:
|
||||
projectile_by_name = {item["name"]: item for item in projectiles}
|
||||
ref_re = re.compile(r"ProjectileType\.([A-Za-z0-9_]+)")
|
||||
skill_re = re.compile(r"SkillType\.([A-Za-z0-9_]+)")
|
||||
for path in sorted(SCRIPTS_DIR.rglob("*.cs")):
|
||||
if path == PROJECTILE_MANAGER:
|
||||
continue
|
||||
text = read_text(path)
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
names = sorted(set(ref_re.findall(line)))
|
||||
if not names:
|
||||
continue
|
||||
skills = sorted(set(skill_re.findall(line)))
|
||||
context = find_code_context(lines, index)
|
||||
if not skills and module_label(path) == "技能逻辑" and context:
|
||||
skills = [context.split(".", 1)[0]]
|
||||
for name in names:
|
||||
projectile = projectile_by_name.get(name)
|
||||
if not projectile:
|
||||
continue
|
||||
projectile["references"].append(
|
||||
{
|
||||
"file": rel(path),
|
||||
"line": index + 1,
|
||||
"context": context,
|
||||
"module": module_label(path),
|
||||
"skills": skills,
|
||||
"lineText": line.strip(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def load_music_metadata() -> dict[str, dict]:
|
||||
result: dict[str, dict] = {}
|
||||
for block in parse_yaml_list_blocks(read_text(MUSIC_ASSET), "MusicDataList:"):
|
||||
key = field(block, "MusicName")
|
||||
if not key:
|
||||
continue
|
||||
result[key] = {
|
||||
"musicName": key,
|
||||
"title": field(block, "Title"),
|
||||
"originalTitle": field(block, "OriginalTitle"),
|
||||
"force": field_int(block, "Force"),
|
||||
"civ": field_int(block, "Civ"),
|
||||
"composer": field(block, "Composer"),
|
||||
"arranger": field(block, "Arranger"),
|
||||
"source": rel(MUSIC_ASSET),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def load_player_music_assignments() -> list[dict]:
|
||||
assignments: list[dict] = []
|
||||
for block in parse_yaml_list_blocks(read_text(PLAYER_ASSET), "PlayerDataList:"):
|
||||
music_name = field(block, "MusicName")
|
||||
assignments.append(
|
||||
{
|
||||
"forceId": field_int(block, "ForceId"),
|
||||
"civId": field_int(block, "CivId"),
|
||||
"civName": field(block, "CivName"),
|
||||
"forceName": field(block, "ForceName"),
|
||||
"leaderName": field(block, "LeaderName"),
|
||||
"musicName": music_name,
|
||||
"hasMusic": bool(music_name),
|
||||
"source": rel(PLAYER_ASSET),
|
||||
}
|
||||
)
|
||||
return assignments
|
||||
|
||||
|
||||
def build_system_notes() -> list[dict]:
|
||||
lines = read_text(AUDIO_MANAGER).splitlines()
|
||||
return [
|
||||
{
|
||||
"name": "AudioManager.Init",
|
||||
"type": "注册表",
|
||||
"file": rel(AUDIO_MANAGER),
|
||||
"line": get_line_number(lines, "public void Init()"),
|
||||
"summary": "启动时注册 BGM 与 SFX key,并通过 TH1Resource.ResourceLoader 载入 Audio 路径。",
|
||||
},
|
||||
{
|
||||
"name": "AudioManager.PlayAudio",
|
||||
"type": "短音效播放",
|
||||
"file": rel(AUDIO_MANAGER),
|
||||
"line": get_line_number(lines, "public void PlayAudio"),
|
||||
"summary": "非音乐 AudioPlayer 走 Config.AudioVolume,最多复用 16 个播放器。",
|
||||
},
|
||||
{
|
||||
"name": "AudioManager.PlayMusic",
|
||||
"type": "音乐播放",
|
||||
"file": rel(AUDIO_MANAGER),
|
||||
"line": get_line_number(lines, "public void PlayMusic"),
|
||||
"summary": "BGM 支持淡入淡出、续播时间记录、异曲 crossfade。",
|
||||
},
|
||||
{
|
||||
"name": "BGM 轮播",
|
||||
"type": "局内音乐",
|
||||
"file": rel(AUDIO_MANAGER),
|
||||
"line": get_line_number(lines, "public void UpdateActivePlayerMusics"),
|
||||
"summary": "根据已遇见玩家的 PlayerDataAssets.MusicName 生成可轮播曲目。",
|
||||
},
|
||||
{
|
||||
"name": "局内环境音",
|
||||
"type": "环境音",
|
||||
"file": rel(AUDIO_MANAGER),
|
||||
"line": get_line_number(lines, "public void CalculateAndPlayAmbient"),
|
||||
"summary": "自己回合开始按领土海洋/树林情况播放 ENV_sea 或 ENV_forest。",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_inventory() -> dict:
|
||||
registry = load_audio_registry()
|
||||
assets = load_audio_assets(registry)
|
||||
asset_by_resource = {asset["resourcePath"].lower(): asset for asset in assets}
|
||||
play_audio_usages, dynamic_audio_usages = scan_play_audio_sites()
|
||||
music_usages = scan_music_sites()
|
||||
projectiles = load_projectiles()
|
||||
scan_projectile_references(projectiles)
|
||||
music_meta = load_music_metadata()
|
||||
player_assignments = load_player_music_assignments()
|
||||
|
||||
projectile_sfx_keys = {p["sfxKey"] for p in projectiles if p["hasSfx"] and p["sfxKey"]}
|
||||
direct_sfx_keys = {usage["key"] for usage in play_audio_usages}
|
||||
registry_sfx_keys = {key for key, value in registry.items() if value["kind"] == "sfx"}
|
||||
all_sfx_keys = sorted(registry_sfx_keys | direct_sfx_keys | projectile_sfx_keys)
|
||||
|
||||
projectiles_by_sfx: dict[str, list[dict]] = {}
|
||||
for projectile in projectiles:
|
||||
if projectile["hasSfx"] and projectile["sfxKey"]:
|
||||
projectiles_by_sfx.setdefault(projectile["sfxKey"], []).append(projectile)
|
||||
|
||||
sfx_items = []
|
||||
for key in all_sfx_keys:
|
||||
reg = registry.get(key)
|
||||
resource_path = reg["resourcePath"] if reg else ""
|
||||
asset = asset_by_resource.get(resource_path.lower()) if resource_path else None
|
||||
uses = [usage for usage in play_audio_usages if usage["key"] == key]
|
||||
projectile_links = [
|
||||
{
|
||||
"id": projectile["id"],
|
||||
"name": projectile["name"],
|
||||
"moveType": projectile["moveType"],
|
||||
"animTime": projectile["animTime"],
|
||||
"hasDelay": projectile["hasDelay"],
|
||||
"referenceCount": len(projectile["references"]),
|
||||
}
|
||||
for projectile in projectiles_by_sfx.get(key, [])
|
||||
]
|
||||
category = classify_sfx_key(key)
|
||||
sfx_items.append(
|
||||
{
|
||||
"key": key,
|
||||
"category": category,
|
||||
"categoryLabel": classify_sfx_label(category),
|
||||
"isUi": category == "ui",
|
||||
"registered": bool(reg),
|
||||
"resourcePath": resource_path,
|
||||
"asset": asset,
|
||||
"directUseCount": len(uses),
|
||||
"projectileCount": len(projectile_links),
|
||||
"used": bool(uses or projectile_links),
|
||||
"uses": uses,
|
||||
"projectiles": projectile_links,
|
||||
}
|
||||
)
|
||||
|
||||
music_keys = sorted(
|
||||
{key for key, value in registry.items() if value["kind"] == "music"}
|
||||
| set(music_meta)
|
||||
| {item["musicName"] for item in player_assignments if item["musicName"]}
|
||||
| {key for usage in music_usages for key in usage["keys"]}
|
||||
)
|
||||
tracks = []
|
||||
for key in music_keys:
|
||||
reg = registry.get(key)
|
||||
resource_path = reg["resourcePath"] if reg else ""
|
||||
asset = asset_by_resource.get(resource_path.lower()) if resource_path else None
|
||||
assignments = [item for item in player_assignments if item["musicName"] == key]
|
||||
usages = [usage for usage in music_usages if key in usage["keys"]]
|
||||
tracks.append(
|
||||
{
|
||||
"musicName": key,
|
||||
"registered": bool(reg),
|
||||
"resourcePath": resource_path,
|
||||
"asset": asset,
|
||||
"metadata": music_meta.get(key, {}),
|
||||
"assignments": assignments,
|
||||
"usageCount": len(usages),
|
||||
"usages": usages,
|
||||
}
|
||||
)
|
||||
|
||||
animation_points = [
|
||||
usage
|
||||
for usage in play_audio_usages
|
||||
if usage["module"] in {"动画表现", "渲染/投射物"}
|
||||
]
|
||||
ui_points = [
|
||||
usage
|
||||
for usage in play_audio_usages
|
||||
if usage["category"] == "ui" or usage["module"] == "UI"
|
||||
]
|
||||
module_points = [
|
||||
usage
|
||||
for usage in play_audio_usages
|
||||
if usage not in animation_points and usage not in ui_points
|
||||
]
|
||||
skill_projectile_points = [
|
||||
{
|
||||
"projectile": projectile["name"],
|
||||
"sfxKey": projectile["sfxKey"],
|
||||
"hasSfx": projectile["hasSfx"],
|
||||
"hasDelay": projectile["hasDelay"],
|
||||
"animTime": projectile["animTime"],
|
||||
"reference": reference,
|
||||
}
|
||||
for projectile in projectiles
|
||||
for reference in projectile["references"]
|
||||
if reference["module"] in {"技能逻辑", "动画表现"}
|
||||
]
|
||||
|
||||
unregistered_assets = [asset for asset in assets if not asset["isRegistered"]]
|
||||
registered_assets = [asset for asset in assets if asset["isRegistered"]]
|
||||
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": utc_now_iso(),
|
||||
"sources": {
|
||||
"audioManager": rel(AUDIO_MANAGER),
|
||||
"projectileTypes": rel(PROJECTILE_ASSET),
|
||||
"musicData": rel(MUSIC_ASSET),
|
||||
"playerData": rel(PLAYER_ASSET),
|
||||
"audioDirectory": rel(AUDIO_DIR),
|
||||
},
|
||||
"summary": {
|
||||
"audioFiles": len(assets),
|
||||
"sfxFiles": len([asset for asset in assets if asset["kind"] == "sfx"]),
|
||||
"musicFiles": len([asset for asset in assets if asset["kind"] == "music"]),
|
||||
"registeredKeys": len(registry),
|
||||
"registeredSfxKeys": len(registry_sfx_keys),
|
||||
"registeredMusicKeys": len([key for key, value in registry.items() if value["kind"] == "music"]),
|
||||
"registeredAudioFiles": len(registered_assets),
|
||||
"unregisteredAudioFiles": len(unregistered_assets),
|
||||
"directPlayAudioUsages": len(play_audio_usages),
|
||||
"dynamicPlayAudioUsages": len(dynamic_audio_usages),
|
||||
"projectileTypes": len(projectiles),
|
||||
"projectileSfxTypes": len([item for item in projectiles if item["hasSfx"]]),
|
||||
"uiSfxKeys": len([item for item in sfx_items if item["isUi"]]),
|
||||
"gameSfxKeys": len([item for item in sfx_items if not item["isUi"]]),
|
||||
"musicTracks": len(tracks),
|
||||
"playerMusicAssignments": len([item for item in player_assignments if item["hasMusic"]]),
|
||||
},
|
||||
"registry": registry,
|
||||
"assets": assets,
|
||||
"sfx": sfx_items,
|
||||
"playAudioUsages": play_audio_usages,
|
||||
"dynamicPlayAudioUsages": dynamic_audio_usages,
|
||||
"animationPoints": animation_points,
|
||||
"uiPoints": ui_points,
|
||||
"modulePoints": module_points,
|
||||
"projectiles": projectiles,
|
||||
"skillProjectilePoints": skill_projectile_points,
|
||||
"music": {
|
||||
"tracks": tracks,
|
||||
"metadata": music_meta,
|
||||
"playerAssignments": player_assignments,
|
||||
"usages": music_usages,
|
||||
},
|
||||
"systemNotes": build_system_notes(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
inventory = build_inventory()
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT_PATH.write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {rel(OUTPUT_PATH)}")
|
||||
print(json.dumps(inventory["summary"], ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -22,6 +22,7 @@ Endpoints:
|
||||
GET /api/art-dev/icon-reviews/asset - Load one draft icon image
|
||||
GET /api/dashboard/preferences - Load dashboard UI preferences
|
||||
POST /api/refresh - Run export_data.py and return result
|
||||
POST /api/audio-inventory/refresh - Run scripts/export_audio_inventory.py and return result
|
||||
POST /api/form-helper/skills/save - Save one SkillDataAssets authoring row
|
||||
POST /api/art-dev/icon-reviews/save - Save icon review selection or feedback
|
||||
POST /api/dashboard/preferences - Save dashboard UI preferences
|
||||
@ -79,6 +80,7 @@ PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.normpath(os.path.join(SCRIPT_DIR, '..', '..'))
|
||||
EXPORT_SCRIPT = os.path.join(SCRIPT_DIR, 'export_data.py')
|
||||
AUDIO_INVENTORY_SCRIPT = os.path.join(SCRIPT_DIR, 'scripts', 'export_audio_inventory.py')
|
||||
SENTIMENT_DIR = os.path.join(SCRIPT_DIR, 'data', 'sentiment')
|
||||
SENTIMENT_INDEX = os.path.join(SENTIMENT_DIR, 'index.json')
|
||||
MARKETING_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, '..', '..', 'DOC', 'marketing'))
|
||||
@ -1162,6 +1164,8 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path == '/api/refresh':
|
||||
self.handle_refresh()
|
||||
elif self.path == '/api/audio-inventory/refresh':
|
||||
self.handle_audio_inventory_refresh()
|
||||
elif self.path == '/api/sentiment/create':
|
||||
self.handle_sentiment_create()
|
||||
elif self.path == '/api/sentiment/delete':
|
||||
@ -3796,6 +3800,34 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
||||
|
||||
self._send_json(response, status)
|
||||
|
||||
def handle_audio_inventory_refresh(self):
|
||||
"""Run the audio inventory exporter and return the result as JSON."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, AUDIO_INVENTORY_SCRIPT],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
response = {
|
||||
'success': result.returncode == 0,
|
||||
'stdout': result.stdout,
|
||||
'stderr': result.stderr,
|
||||
'returncode': result.returncode,
|
||||
}
|
||||
status = 200 if result.returncode == 0 else 500
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
response = {'success': False, 'error': 'Audio inventory script timed out after 30s'}
|
||||
status = 504
|
||||
except Exception as e:
|
||||
response = {'success': False, 'error': str(e)}
|
||||
status = 500
|
||||
|
||||
self._send_json(response, status)
|
||||
|
||||
def handle_sentiment_create(self):
|
||||
"""Create a new sentiment record from multipart form data."""
|
||||
try:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user