feat: add dashboard audio inventory

This commit is contained in:
daixiawu 2026-06-30 00:34:03 +08:00
parent 1723dea5b5
commit 1bed2b709e
8 changed files with 7244 additions and 2 deletions

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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"
}

View File

@ -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>

View File

@ -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
View 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);

View 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())

View File

@ -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: