CatGunner CookbookBardCat 1.1.59 · L1 ANALYSIS · tài liệu học
Trang chủTài liệu framework › 04 — Âm thanh (Sound)
Nguồn: 04-sound.md · Cập nhật lần cuối: 2026-09-05 08:45 +07 · sha256 nguồn 7148609a3890

04 — Âm thanh (Sound)

Nguồn: chưng cất từ restore SOT com.Chodun.CatGunner 1.1.59 (BardCat). Mọi C# ở đây là L1 ANALYSIS, không phải source gốc.


1. Câu hỏi tài liệu này trả lời

Một idle-shooter bắn ~5 phát/giây và có hàng trăm nút bấm. Nếu mỗi sự kiện phát một AudioSource.PlayOneShot thì âm thanh sẽ thành tiếng ồn trắng và CPU audio-mixer sẽ tăng vọt. CatGunner giải quyết bằng một singleton, một enum, hai AudioSource, và ba tầng chống trùng.


2. Stable truths

Claim Mức bằng chứng Nguồn / provenance
Sound_manager : MonoBehaviour (TypeDefIndex 8080), 8 method, singleton qua property public static Sound_manager instance { get; set; } với backing field static @0x0 metadata-proven dump.cs:590265
API phát SFX duy nhất: public void PlaySfx(SfxType id, Nullable<float> volumeOverride, float pitch = 1) metadata-proven RVA 0x2ACF7D4; dump.cs:590265
Chỉ có hai AudioSource: [SerializeField] AudioSource sfxSource @0x20 và AudioSource bgmSource @0x28 metadata-proven dump.cs:590265
Thư viện âm thanh là [SerializeField] SfxEntry[] sfxEntries @0x30, được lập chỉ mục thành Dictionary<SfxType, SfxEntry> sfxMap @0x40 lúc Awake metadata-proven dump.cs:590265, Awake RVA 0x2ACFB34
Hai cơ chế chống trùng, bật/tắt độc lập từ inspector: [Header("Duplicate Guard")] bool blockSameFrameDuplicate @0x38 và [Header("Cooldown Guard")] bool blockCooldownDuplicate @0x39 + float cooldownSeconds @0x3C metadata-proven dump.cs:590265
Trạng thái chống trùng lưu theo từng SfxType: Dictionary<SfxType,int> lastPlayedFrame @0x48 và Dictionary<SfxType,float> lastPlayedTime @0x50 metadata-proven dump.cs:590265
Chỉ hai clip nhạc nền được tham chiếu trực tiếp: public AudioClip BGM_main @0x58 và public AudioClip BGM_contents @0x60 metadata-proven dump.cs:590265
Chuyển nhạc bằng hai method không tham số: BGM_Change_Main() (RVA 0x2ACFEC4) và BGM_Change_SpeedRun() (RVA 0x2ACFE14) metadata-proven dump.cs:590265
Tắt tiếng nhạc: ApplyBGMMute(bool mute) (RVA 0x2ACD5B0) — runtime chạy trong cửa sổ 40 s metadata-proven + runtime-observed dump.cs:590265; [runtime trace: runtime_ranking.json]
enum SfxType (TypeDefIndex 8078) có 15 giá trị: Pop=0, Button=1, Spawn=2, Fusion=3, Upgrade=4, Dont=5, Rebirth=6, Purchase=7, Shoot=8, Select=9, Enter=10, Win=11, Fail=12, UnSelect=13, Tick=14 metadata-proven dump.cs:590227
SFX_BtnScript (322 instance trong scene) nối SFX vào nút ở Start: GetComponent<Button>().onClick.AddListener(Sfx); Sfx() gọi Sound_manager.instance.PlaySfx(_sfx, null, 1f) disasm-proven + scene-census RVA 0x2ACF6C8 / 0x2ACF774; scene census SFX_BtnScript = 322
SFX_BtnScript._sfx mặc định = (SfxType)1 tức Button disasm-proven .ctor RVA 0x2ACFA74
Mute_Panel (19 instance) bật/tắt Cat_manager.is_Shoot_Mute (static bool @0x0) theo OnEnable/OnDisable disasm-proven + scene-census RVA 0x2AF00DC / 0x2AF0128; dump.cs:593380
Runtime: Sound_manager.PlaySfx chạy 208× trong ~40 s, xấp xỉ bằng 207 phát bắn runtime-observed [runtime trace: runtime_ranking.json]
Game ship đúng 16 AudioClip, tất cả Vorbis (m_CompressionFormat = 1), export thành .ogg asset-census [report: asset_lane.md] §5
Hai clip dài là nhạc nền: Relaxed_7_BlossomDrift (121.26 s, stereo 44.1 kHz) và Cute_5_MilkShake (105.00 s) asset-census như trên
14 clip còn lại ≤ 3.80 s — đều là SFX asset-census như trên
Không có bundle âm thanh riêng; toàn bộ 16 clip nằm trong data.unity3d asset-census (bằng chứng phủ định) [report: asset_lane.md] §5

Danh mục âm thanh đầy đủ (16 clip đã ship)

Clip ch Hz Độ dài Phân loại
Relaxed_7_BlossomDrift 2 44100 121.26 s nhạc nền
Cute_5_MilkShake 2 44100 105.00 s nhạc nền
etfx_explosion_chargeup2 2 48000 3.80 s SFX
Prize Wheel Spin 2 Reward 2 44100 2.43 s SFX
espannol 2 44100 2.38 s SFX
Positive 1 44100 2.07 s SFX
Special & Powerup 45 2 44100 1.86 s SFX
Special & Powerup 43 2 44100 1.50 s SFX
Transition_In 2 44100 0.99 s SFX
etfx_explosion_sharp 2 48000 0.53 s SFX
Fail 2 44100 0.40 s SFX
Pop 6 2 44100 0.30 s SFX
ClickyButton5a 2 44100 0.27 s SFX
Popup1 2 44100 0.16 s SFX
Click 2 44100 0.10 s SFX
GenericButton14 2 44100 0.06 s SFX

Không suy ra ánh xạ. 15 giá trị SfxType và 14 clip SFX là một gần trùng khớp, nhưng ánh xạ SfxType → AudioClip nằm trong sfxEntries[] được serialize trong scene và chưa được trích. Đừng gán SfxType.Click → "Click" chỉ vì tên giống nhau.


3. Mô hình hành vi

3.1 Kiến trúc: một cửa, một enum

        bất kỳ hệ thống nào
                |
                v
   Sound_manager.instance.PlaySfx(SfxType id, float? volumeOverride, float pitch = 1)
                |
        +-------+--------+
        |                |
   sfxMap[id]       chống trùng
   (SfxEntry)       - blockSameFrameDuplicate ? lastPlayedFrame[id] == Time.frameCount -> bỏ
                    - blockCooldownDuplicate  ? Time.time - lastPlayedTime[id] < cooldownSeconds -> bỏ
                |
                v
          sfxSource  (MỘT AudioSource duy nhất cho toàn bộ SFX)

Ba quyết định thiết kế đáng học:

(a) Một AudioSource cho tất cả SFX. Không phải một source mỗi âm thanh, không phải pool source. Đây là lựa chọn có đánh đổi rõ ràng: mất khả năng điều khiển từng âm riêng (dừng riêng, 3D riêng), đổi lấy chi phí mixer cố định và không bao giờ hết voice. Với một game 2.5D nhìn từ trên xuống, âm thanh không cần định vị không gian — đây là đánh đổi đúng.

(b) Nullable<float> volumeOverride. null nghĩa là "dùng volume mặc định của SfxEntry". Không phải -1f hay 0f làm giá trị lính canh. Sạch, và không thể nhầm với "im lặng".

(c) pitch có giá trị mặc định 1. Tham số biến tấu cao độ nằm ngay trong API chính, không phải một overload riêng — nên việc thêm biến tấu ngẫu nhiên (Random.Range(0.95f, 1.05f)) cho âm bắn là một thay đổi một dòng ở call-site.

3.2 Hai tầng chống trùng, độc lập

// suy ra từ layout field (dump.cs:590265) — thân method CHƯA dựng lại
[Header("Duplicate Guard")] [SerializeField] bool  blockSameFrameDuplicate;   // @0x38
[Header("Cooldown Guard")]  [SerializeField] bool  blockCooldownDuplicate;    // @0x39
                            [SerializeField] float cooldownSeconds;           // @0x3C
readonly Dictionary<SfxType, int>   lastPlayedFrame;    // @0x48
readonly Dictionary<SfxType, float> lastPlayedTime;     // @0x50
  • Cùng frame — chặn kịch bản "10 quái chết cùng lúc, 10 tiếng nổ chồng lên nhau". Đây là loại trùng lặp gây méo tiếng nặng nhất vì các sóng cộng pha hoàn hảo.
  • Cooldown — chặn kịch bản "cùng một âm phát 20 lần trong nửa giây". Mềm hơn, giữ nhịp âm thanh.

Cả hai theo từng SfxType, không phải toàn cục. Tiếng bắn bị throttle không được làm im tiếng bấm nút. Và cả hai là cờ inspector — designer âm thanh bật/tắt được mà không cần lập trình viên.

So sánh với FXThrottle (doc 03), vốn throttle theo thời gian + khoảng cách + trần mỗi frame. Âm thanh không cần tiêu chí khoảng cách (game không dùng âm thanh 3D) nhưng cần tiêu chí cùng-frame mà FX không cần. Hai hệ thống, hai bộ tiêu chí phù hợp với từng miền.

3.3 Nhạc nền: hai clip, hai method

public AudioClip BGM_main;        // @0x58
public AudioClip BGM_contents;    // @0x60
public void BGM_Change_Main()      { … }   // RVA 0x2ACFEC4
public void BGM_Change_SpeedRun()  { … }   // RVA 0x2ACFE14
public void ApplyBGMMute(bool mute){ … }   // RVA 0x2ACD5B0

Chỉ hai trạng thái nhạc: "màn chính" và "nội dung phụ / speed-run". Tên field (BGM_contents) và tên method (BGM_Change_SpeedRun) không khớp nhau — dấu hiệu clip BGM_contents phục vụ mọi chế độ phụ, còn speed-run chỉ là cái đầu tiên cần nó.

Method không tham số — trạng thái nhạc là một phần của trạng thái game, không phải thứ call-site tự chọn. Điều này ngăn tình trạng "mỗi màn hình tự đặt nhạc của nó" và tranh chấp lẫn nhau.

ApplyBGMMute tách riêng khỏi mute SFX: người chơi tắt nhạc mà vẫn nghe hiệu ứng là nhu cầu rất phổ biến.

3.4 Nối SFX vào nút — 322 lần, không viết code

// SFX_BtnScript — RVA 0x2ACF6C8 / 0x2ACF774, 322 instance trong scene
public SfxType _sfx = (SfxType)1;                          // mặc định Button
private void Start() { GetComponent<Button>().onClick.AddListener(Sfx); }
private void Sfx()   { Sound_manager.instance.PlaySfx(_sfx, null, 1f); }

Đây là một component 9 dòng xuất hiện 322 lần trong scene. Không có UnityEvent nối tay trong inspector, không có onClick trỏ tới Sound_manager ở 322 chỗ khác nhau (vốn sẽ vỡ khi đổi tên method). Chỉ cần gắn component và chọn một enum.

Mặc định = SfxType.Button nghĩa là trường hợp phổ biến nhất không cần cấu hình gì. Gắn component lên một nút bất kỳ, nó kêu đúng ngay. Chỉ những nút đặc biệt (Purchase, Fail, Win) mới cần đổi enum.

Đây có lẽ là mẫu có tỉ lệ giá-trị/dòng-code cao nhất trong toàn bộ codebase.

3.5 Mute theo ngữ cảnh — Mute_Panel

// Mute_Panel — RVA 0x2AF00DC / 0x2AF0128, 19 instance
private void OnEnable()  { Cat_manager.is_Shoot_Mute = true;  }
private void OnDisable() { Cat_manager.is_Shoot_Mute = false; }

Một component 4 dòng giải quyết một vấn đề UX thật: khi người chơi mở một panel (shop, nhiệm vụ, gacha), con mèo vẫn bắn phía sau, và tiếng bắn át tiếng UI.

Cơ chế đáng chú ý: - Nó không tắt AudioSource. Nó đặt một cờ mà bên phát ra âm thanh (Cat_manager / Cat.Attack) đọc. Nghĩa là âm bắn không bao giờ được gửi tới mixer, thay vì được phát rồi bị chặn. - Nó dùng OnEnable/OnDisable — vòng đời tự nhiên của panel. Không cần ai nhớ gọi mute/unmute, và không thể kẹt ở trạng thái mute nếu panel bị tắt bằng đường khác. - 19 instance = 19 panel khác nhau tự động có hành vi này chỉ bằng cách gắn component.

Điểm yếu cần biết khi tái dùng: đây là một bool toàn cục, không phải bộ đếm. Nếu hai panel có Mute_Panel cùng mở và một cái đóng, cờ sẽ về false dù panel kia vẫn mở. Trong SOT điều này an toàn vì UI là ngăn xếp một-panel-một-lúc, nhưng một framework tổng quát nên dùng counter, không dùng bool.


4. Framework takeaway

  1. Một cửa duy nhất: PlaySfx(enum id, float? volumeOverride, float pitch = 1). Enum, không phải string. Nullable<float> cho "dùng mặc định", không dùng giá trị lính canh. pitch nằm trong API chính để biến tấu ngẫu nhiên là một dòng.

  2. Chống trùng theo từng id, hai tiêu chí, bật/tắt từ inspector. csharp bool blockSameFrameDuplicate; // chống cộng pha khi nhiều sự kiện cùng frame bool blockCooldownDuplicate; // chống lặp dày float cooldownSeconds; Dictionary<TId, int> lastPlayedFrame; Dictionary<TId, float> lastPlayedTime; Đây là thứ khác biệt giữa "game có âm thanh" và "game nghe được".

  3. Một AudioSource cho SFX là mặc định hợp lý cho game mobile 2D/2.5D. Framework nên cho phép nâng cấp lên pool source khi cần âm thanh 3D, nhưng không bắt trả giá đó từ đầu.

  4. Trạng thái nhạc nền là method không tham số, thuộc về trạng thái game. BGM_Change_Main() / BGM_Change_X() — không để call-site tự chọn clip.

  5. Component nối-SFX-vào-nút với giá trị mặc định đúng. Một component nhỏ + một enum có default hợp lý thay thế hàng trăm lần nối tay trong inspector. Đây là mẫu nên có sẵn trong framework.

  6. Mute theo ngữ cảnh bằng cờ mà bên phát đọc, gắn vào OnEnable/OnDisable. Nhưng dùng counter, không dùng bool, để lồng nhau an toàn.

  7. Tách mute nhạc khỏi mute SFX.

  8. Ngân sách âm thanh nhỏ đến bất ngờ là đủ. Một game thương mại đầy đủ ship với 16 clip (2 nhạc + 14 SFX, tổng ~4 phút âm thanh, định dạng Vorbis). Đừng nhầm số lượng clip với chất lượng cảm giác — cái tạo ra cảm giác là khi nào âm thanh phát và chống trùng, không phải bao nhiêu.


5. Negative evidence & UNKNOWN

Mục Trạng thái Bằng chứng khoá được nó
Thân PlaySfx (RVA 0x2ACF7D4) ĐÃ DỰNG (G410, lane feel): thứ tự cổng = is_SFX_Mute → có clip? → trùng cùng frame theo từng loại< 0.05 s theo từng loại → volume = volumeOverride ?? volume của hàng; pitch KHÔNG BAO GIỜ biến tấu; BGM mute = volume 0 (không dừng); đổi track chỉ khi clip khác
Cấu trúc SfxEntry ĐÃ ĐỌC (G410): ctor blockSameFrameDuplicate = blockCooldownDuplicate = true, cooldownSeconds = 0.05; volume theo hàng: Shoot 0.3 · Fusion 0.4 · Button 0.5 · còn lại 1.0; 15 clip + 2 BGM byte-identical SOT, không mixer
Ánh xạ SfxType → AudioClip UNKNOWN — nằm trong sfxEntries[] serialize trong Main.unity, chưa trích. Không suy từ tên clip. Trích component Sound_manager từ scene YAML
Giá trị cooldownSeconds, blockSameFrameDuplicate, blockCooldownDuplicate thực tế ĐÃ ĐÓNG (G410): 0.05 s / true / true (mặc định ctor; scene không ghi đè)
Nơi Cat_manager.is_Shoot_Mute được đọc ĐÃ ĐÓNG (G363): Cat.Attack (0x2AE9624) — tiếng bắn chỉ phát khi !is_Shoot_Mute && !Setting.is_eco (mute kép: panel đang mở hoặc chế độ tiết kiệm đều tắt tiếng bắn)
SfxType.Tick (=14), Dont (=5), Fusion (=3) dùng ở đâu UNKNOWN — enum có giá trị nhưng call-site chưa truy Cross-ref PlaySfx trong dump
Có AudioMixer / nhóm mixer không Không quan sát thấySound_manager chỉ tham chiếu hai AudioSource trần, không có AudioMixerGroup nào trong field list dump.cs:590265
Có âm thanh 3D / spatial không Không quan sát thấyPlaySfx không nhận Vector3; một sfxSource duy nhất dump.cs:590265
Vì sao PlaySfx chạy 208× còn Bullet.SpawnEx 207× UNKNOWN (chênh 1) — có thể một tiếng UI, có thể một tiếng spawn. Không suy diễn. Hook PlaySfx kèm tham số SfxType

6. Raw evidence

  • dump.cs:590227enum SfxType (15 giá trị)
  • dump.cs:590265class Sound_manager (field + 8 method + RVA)
  • dump.cs:593380Cat_manager.is_Shoot_Mute
  • [reconstructed: SFX_BtnScript.cs] — nối SFX vào Button (disasm-proven)
  • [reconstructed: Mute_Panel.cs] — mute theo ngữ cảnh (disasm-proven)
  • [disasm: leaf_SFX_BtnScript.c], leaf_Mute_Panel.c}
  • [runtime trace: runtime_ranking.json]PlaySfx 208×, ApplyBGMMute
  • [report: asset_lane.md] §5 — danh mục 16 AudioClip đầy đủ
  • [index: scene_census.json]SFX_BtnScript 322, Mute_Panel 19

7. Đã đóng bởi FEEL lane (2026-09-05) — cập nhật cho §5

UNKNOWN cũ Kết quả (disasm-proven / scene)
Thân PlaySfx (0x2ACF7D4) Thứ tự: Setting.instance.is_SFX_MutesfxMap.TryGetValue → same-frame (Time.frameCount) → cooldown (Time.unscaledTime, không phải Time.time) → vol = volumeOverride ?? entry.volume; pitch đặt lên sfxSource, PlayOneShot(clip, vol), trả pitch cũ.
SfxEntry SfxType type; AudioClip clip; [Range(0,1)] float volume = 1f (.ctor 0x2ACFA84). Không có biến tấu pitch trong entry.
Ánh xạ SfxType → clip (scene, project == SOT) Pop=Popup1 ·1 / Button=Pop 6 ·0.5 / Spawn=Special & Powerup 45 ·1 / Fusion=Prize Wheel Spin 2 Reward ·0.4 / Upgrade=Special & Powerup 43 ·1 / Dont=ClickyButton5a ·1 / Rebirth=etfx_explosion_chargeup2 ·1 / Purchase=Prize Wheel Spin 2 Reward ·1 / Shoot=etfx_explosion_sharp ·0.3 / Select=Click ·1 / Enter=Transition_In ·1 / Win=Positive ·1 / Fail=Fail ·1 / UnSelect=Click ·1 / Tick=GenericButton14 ·1
cooldownSeconds, hai cờ guard 0.05 s, cả hai cờ bật — vừa là default .ctor 0x2ACFF74 vừa là giá trị scene.
Awake guard singleton (Destroy bản trùng) → sfxMap.Clear() → nạp mọi entry có clip → nếu sfxSource null thì AddComponent<AudioSource> (playOnAwake=false, loop=false).
ApplyBGMMute bgmSource.volume = mute ? 0 : 1 — không Stop. BGM_Change_* chỉ Play() khi clip khác.
SfxType.Tick dùng ở đâu Dia_Attraction.Dia_FX_End — mỗi hạt dia về HUD.
Tài sản âm thanh 15 GUID clip + 2 BGM đều resolve trong Assets/AudioClip/*.ogg, byte-identical với SOT export; không có AudioMixer group.
Ai gọi PlaySfx 79 call-site (danh sách: [evidence codegen: 01_callers.json]); không call-site nào truyền pitch ≠ 1.