CatGunner CookbookBardCat 1.1.59 · L1 ANALYSIS · tài liệu học
Trang chủCookbook › 05 — Kẻ địch & wave (một Enemy chung, hành vi riêng ở component anh em)
Nguồn: 05-ke-dich-wave.md · Cập nhật lần cuối: 2026-09-05 08:39 +07 · sha256 nguồn 2ae8db11ed1f

05 — Kẻ địch & wave (một Enemy chung, hành vi riêng ở component anh em)

Nguồn framework: docs/framework/05-characters-pets-enemies.md §2.3, §3.4–3.6, §5; docs/framework/02-combat-projectiles.md §3.1/§3.6; docs/framework/06-juice-game-feel.md §3.2/§3.4; [liveops catalog: SCREEN_CATALOG.md] §6. Cấp bằng chứng: L1 ANALYSIS — dựng lại clean-room từ disasm + metadata + quan sát runtime; không phải source gốc. Mỗi dòng bên dưới mang nhãn bằng chứng riêng.

Mục tiêu

Muốn màn hình có hàng nghìn mục tiêu (cây, quặng, quái, boss) mà vẫn 60 fps, và muốn thêm một loại mục tiêu mới không phải sửa code đạn — thì tách "phần chung" (máu, tiền, thanh máu, nảy) vào một component Enemy, và để mỗi loại có component anh em quyết định chết thì làm gì. Trang này liệt kê 6 loại kẻ địch của CatGunner, số lượng thật trong scene, và cách wave/stage vận hành.

Game gốc làm gì

Điều game làm Con số / hằng số Mức bằng chứng Nguồn
Enum Enemy.Type có 6 giá trị Tree=0, Tree_SpeedRun=1, BossMonster=2, Monster=3, Ore=4, RaidBoss=5 disasm-proven doc 05 §2.3, Enemy.cs
Enemycomponent chung, hành vi riêng ở component anh em, dispatch qua Die_Reload() theo _type disasm-proven doc 05 §2.3, Enemy.Die_Reload RVA 0x2AFF354
Số instance trong scene Enemy 1 933 · EnemyColl 1 933 · Enemy_Slider 1 931 · SpriteFill 1 931 · Ore 1 000 · Tree 501 · Monster 240 · Tree_SpeedRun 190 scene-census doc 05 §2.3, scene_census.json
Toàn bộ kẻ địch đã tồn tại sẵn trong scene (không Instantiate lúc chạy) Enemy..ctor 1 933×, Ore..ctor 1 000×, Tree..ctor 501× ngay lúc load scene-census + runtime-observed doc 05 §2.3
Enemy giữ phần chung Health_Now, Health_Max, Money, is_Die, Slider (thanh máu), _bounce disasm-proven doc 05 §3.4
EnemyColl.enemyScriptliên kết ngược để collider con tìm Enemy cha không cần GetComponentInParent 3 data-holder chỉ có .ctor: EnemyColl, Enemy_Slider, Gun disasm-proven doc 05 §3.4
Thanh máu quái = kênh đỏ của SpriteRenderer.color _sprite.color = new Color(value, 1, 1, 1); Value_Reload 202× disasm-proven + runtime-verified doc 06 §2.3, RVA 0x2A52BF4
Boss không dùng Enemy_Slider: Slider == null && _type == BossMonsterBoss_manager.instance.gameSlider.Value_Reload(Health_Now, Health_Max) Boss_Slider có 2 thanh (máu + thời gian) disasm-proven + metadata-proven doc 06 §2.3, RVA 0x2AFF108, dump.cs:593885
Ore = mục tiêu đếm nhát đánh máu 3 (skin 0) hoặc 5 (skin ≠ 0); mỗi nhát trừ đúng (InfVal)1 disasm-proven doc 05 §2.3, Ore.Spawn 0x2AF70B8; Damage_Tag 0x2A503F0
Ore rơi dia bằng literal cứng "+0.1" (khi _diaValue == 1) / "+1" @0x5E81500 / 0x5E81508 disasm-proven doc 06 §2.4, Ore.Die_Reload 0x2AF76E4
Chỉ Type.Monster nhận knockback damageRatio = (float)(Damage / Health_Max)Monster.Apply_Knockback(damageRatio) disasm-proven doc 05 §2.3, Enemy.Damaged 0x2AFEF9C
Monster hằng số NormalSpeed = 1.2, BigSpeed = 0.9, BarrierRadius = 20, ReferenceSpawnDist = 65, MapCenter static metadata-proven doc 05 §2.3, dump.cs:594503
Monster animation trên GPU bằng MaterialPropertyBlock, không Animator 3 property id: ID_TimeOffset, ID_AnimSpeed, ID_ScaleAmount metadata-proven doc 05 §2.3/§3.5, dump.cs:594503
Monster.Spawn(InfVal Health, Vector3 Pos, Sprite SkinSprite, bool is_Big) + Size_Big / Size_Small metadata-proven doc 05 §2.3
Boss đi lang thang bằng coroutine và né đường đi của mèo WanderRoutine, MoveToPoint, PickWanderPoint, RandomPointInMap, PathCrossesCat(origin, target, catPos, radius); MapRadius = 60; catAvoidRadius + maxPointRetries metadata-proven doc 05 §2.3, dump.cs:593531
BossRaid_Boss Animator; Monster/Pet thì không metadata-proven (negative) doc 05 §5
Raid_Boss tối giản chỉ Spawn(Health, SkinNum), Reload(Health), Die_Reload(), Game_End() metadata-proven doc 05 §2.3, dump.cs:595047
Raid là nội dung chưa phát hành root Raid_manager m_IsActive: 0; Raid_UI 690 object; không có Raid_Btn, không có trong Contents_Lock_Reload; Raid_manager: BossCount = 5, GameTime_Max = 30, BossReward_Value = 10 VERIFIED (static, negative evidence) SCREEN_CATALOG §6, G153
Trạng thái wave nằm ở Tree_manager TreeCount_Now @0x90, TreeCount_Max @0x94, GameTime_Now @0x98, GameTime_Max @0x9C metadata-proven doc 05 §2.3, dump.cs:602135
Cây đổ giảm TreeCount_Now, cập nhật slider theo (Max - Now) / Max, Now <= 0Game_Win() — logic này nằm trong Tree_manager.Tree_Destroyed; Tree.Die_Reload thật chỉ 2 dòng (spawn hạt + gọi Tree_Destroyed). Hai hàm từng bị công cụ decompile gộp làm một Tree.Die_Reload 85× / MissionValue_Increase 86× disasm-proven (sau khi tách biên) + runtime-observed doc 05 §2.3/§3.6 (G346)
Game_Win: StageAdvance; relic Skip = Relic.Skip_Value % cơ hội tiến thêm 1 level, chỉ khi Level + 10·Stage < Stage_Value_Max (không vượt mốc cao nhất từng đạt); lập kỷ lục ⇒ Stage_Value_Max, SubmitScore [R1], Contents_Lock_Reload; _stageTimeAll cộng thời gian thực mỗi màn disasm-proven doc 05 §3.6 (G347)
Đặt cây theo đường cong Morton (Z-order), không xáo trộn ngẫu nhiên: Tree_Grid.Spawn sắp 100 ô theo Morton(i % 10, i / 10) rồi lấy TreeCount ô cách đều trên thứ tự đó ⇒ cây phủ đều lưới, tất định; ngẫu nhiên duy nhất là jitter ±randomRange từng cây. Lưới 10 cột, x bước 5, mỗi hàng x −50, z +5 10 × 10 ô disasm-proven doc 05 §2.3/§3.6 (G343)
Ore.Spawn(int skinNum): tham số dùng như boolean — khác 0 ⇒ 5 HP / 10 dia, bằng 0 ⇒ 3 HP / 1 dia; Ore không trả Money qua Enemy (đối số 0), thưởng là _diaValue cấp khi chết 3/1 · 5/10 disasm-proven doc 05 §2.3, doc 02 §3.6 (G343)
Tỉ lệ bốc bậc cây Tree_Grid.degreeRates float[5,4] — hàng = level 0..4, cột = bậc 0..3: {80,20,0,0} {50,45,5,0} {20,55,25,0} {5,35,60,0} {0,15,85,0}; cột bậc 3 luôn 0 ⇒ cây bậc cao nhất chỉ là Boss_tree 20 số, mỗi hàng 100 % metadata-proven (blob SHA-256) + disasm-proven (consumer) doc 11 §5.2 (G353)
Monster_manager: MonsterCountPerLevel = [90, 120, 160, 200, 240], SpawnIntervalPerLevel = [0.35, 0.2, 0.17, 0.12, 0.08] s 5 + 5 metadata-proven (blob SHA-256) doc 05 §2.3 (G353); chỉ số hàng chưa đọc
Bảng máu/tiền cây theo stage — hàm sinh: seed 250 (máu) / 20 (tiền), mỗi stage ×3 rồi cắt về 2 chữ số có nghĩa; mảng scene khớp (250, 750, 2200, 6600, 19000, 57000, 170000…; 20, 60, 180, 540, 1600, 4800, 14000…) ×3, 2 chữ số disasm-proven + scene-YAML doc 05 §2.3 (G395)
Chế độ Play (SpeedRun) dùng SpeeRun_Stage_Power_return = GetStageHealth(stage) × 5 (lỗi chính tả là của game gốc) ×5 disasm-proven G395
Tree_manager ctor: GameTime_Max = 180 s, CatSpawnPos = (−100, 0, 0), hai cache Dictionary<int, InfVal> — khởi tạo field bị công cụ decompile bỏ rơi; đo trên thiết bị: thanh thời gian chạy đúng 180.0 s (sai 0.06 %), đếm xuống 180 s disasm-proven + build-measured doc 05 §2.3 (G306, G351)
Camera orthographic size 40, isometric yaw −45° / pitch 35.26°, Camera_manager.Follow → Cat; nửa bề rộng nhìn thấy trên 1080×2400 = 40 × 0.45 = 18 đơn vị 18 u disasm-proven + derived doc 05 §2.4 (G354)
Vì sao cây "không hiện": khối lưới tâm (−100, 0, 50.5) trong khi mèo spawn (−100, 0, 0) ⇒ lúc spawn chỉ 5/30 cây trong khung; mèo tự đi tới cây (~1 s) và camera bám theo ⇒ 5 → 12 cây vào khung (đo trên thiết bị) 5 → 12 build-measured doc 05 §2.4 (G354, G358)
Vòng chơi đo được: round bắt đầu 8 s sau launch, kéo dài 180 s, hết giờ ⇒ tự bắt đầu lại và re-roll bố cục (chọn ô Morton + jitter + bốc bậc mới) 8 s / 180 s build-measured doc 05 §2.4 (G358, G362)
degreeRates: hàng = level wave 0..4, cột = bậc cây 0..3, bậc 3 không bao giờ bốc — dành cho cây boss; Degree_Random_return từng là return 0 (mọi cây bậc 0) chốt disasm-proven G354
Máu/tiền theo stage tính trước 5 000 stage và cache const int PrecomputedStageCount = 5000; Dictionary<int,InfVal> _healthCache / _moneyCache; static InfVal PowOfThree(int) metadata-proven doc 05 §2.3, dump.cs:602135 (GetStageHealth 0x2B4111C, GetStageMoney 0x2B431B8)
Bảng stage thật nằm trong scene Stage_Health_Balance @Main.unity:20614934, Stage_Money_Balance @:20624935; Tree_manager.Balance_Reload (0x2B447EC) 0 code-xref scene-YAML + VERIFIED (dead code) doc 09 §3.2, LIVEOPS_SURFACE §4
Hiệu ứng khi chết ở tầng hạt cây thường 5 hạt, cây boss 15, quặng 5 hạt bụi (instance_dust) disasm-proven doc 03 §2/§3.5, Tree.Die_Reload 0x2B43904, Ore.Die_Reload 0x2AF76E4

Công thức / pseudocode

// ---- L1 ANALYSIS (có RVA) ----

// Enemy.Die_Reload — RVA 0x2AFF354 : loại chung uỷ quyền vòng đời cho loại riêng
switch (_type) {
    case Tree:          GetComponent<Tree>().Die_Reload();          gameObject.SetActive(false); break;
    case Tree_SpeedRun: GetComponent<Tree_SpeedRun>().Die_Reload(); gameObject.SetActive(false); break;
    case BossMonster:   GetComponent<Boss>().Die_Reload();          break;   // KHÔNG tắt ở đây
    case Monster:       GetComponent<Monster>().Die_Reload();       break;   // KHÔNG tắt
    case Ore:           GetComponent<Ore>().Die_Reload();           break;   // KHÔNG tắt (Ore tự SetActive(false) ở cuối)
    case RaidBoss:      GetComponent<Raid_Boss>().Die_Reload();     break;   // KHÔNG tắt
}

// Enemy.Damaged — RVA 0x2AFEF9C
if (is_Die) return;  if (!Gamemanager.is_gaming) return;         // G132b
Health_Now -= Damage;
if (_type == Type.Monster) GetComponent<Monster>()?.Apply_Knockback((float)(Damage / Health_Max));  // chia Health_MAX
Slider_Reload();                                                 // ratio = Health_Now / Health_Max -> SpriteFill kênh đỏ
if (Health_Now <= 0) Die(); else _bounce.Bounce_Start();

// Enemy.Die  ->  Gamemanager.Get_Money(Money, transform.position);  Die_Reload();

// Damage_Tag — RVA 0x2A503F0 : Ore là mục tiêu đếm nhát
if (tag == "Enemy") { e.Damaged(Damage);     return true; }
if (tag == "Ore")   { e.Damaged((InfVal)1);  return true; }

// Tree.Die_Reload — RVA 0x2B43904 : chỉ HAI dòng (G346)
Particle_Polling.instance.Spawn(MoneySpawn_trans.position, is_Boss ? 15 : 5);   // không null-guard: binary dereference thẳng
Tree_manager.instance.Tree_Destroyed();

// Tree_manager.Tree_Destroyed : vòng đời một wave (trước đây bị gộp nhầm vào Die_Reload)
if (!Gamemanager.is_gaming) return;
TreeCount_Now--;
gameSlider.Value_Reload((TreeCount_Max - TreeCount_Now) / TreeCount_Max);
// [R1] SendLog_WaveRatio CHỈ khi Stage_Now == 0 && Level_Now >= Stage_Value_Max, mốc 10 %, dedup "{stage}_{level}_{percent}"
if (TreeCount_Now <= 0) Game_Win();                                    // private, cùng class
Mission_manager.instance.MissionValue_Increase(Mission_Type.TreeCount);

// Tree_manager.Game_Win (G347)
if (!is_gaming) return;  is_gaming = false;
_stageTimeAll += realtimeSinceStartup - _roundStartTime;  gameSlider.Value_Reload(1f);
StageAdvance(ref Stage_Now, ref Level_Now);
if (Random.Range(0, 100) < Relic_manager.Skip_Value && Level_Now + 10*Stage_Now < Stage_Value_Max)
    StageAdvance(ref Stage_Now, ref Level_Now);                        // relic Skip: chỉ trong vùng đã chinh phục
if (Level_Now + 10*Stage_Now >= Stage_Value_Max) {
    Stage_Value_Max = Level_Now + 10*Stage_Now;
    if (!is_ProgramDestroyed) Rank_manager.SubmitScore(false, null);   // [R1]
    Contents_Lock_Reload();  /* [R1] SendLog_WaveClear, SendLog_StageClear khi level cũ == 4 */
}
Sound_manager.PlaySfx(Win);  MissionValue_Increase(StageClear);  GameWin_UI on;  Loading_Then_Start();

// Tree_Grid.Spawn (G343): đặt cây theo đường cong Morton, lấy mẫu cách đều
order = indices 0..99 sắp theo Morton(i % 10, i / 10)      // Morton = Spread(x) | (Spread(z) << 1), Spread = đan bit
acc = 0; for k in 0..want-1 { acc += total; chọn order[acc / want]; }
localPosition = (5*j - 50*(j/10) + jitter, 0, 5*(j/10) + jitter), jitter ∈ ±randomRange

// Monster: animation trên shader, KHÔNG Animator  (dump.cs:594503)
_mpb.SetFloat(ID_TimeOffset, <lệch pha riêng từng con>);   // 240 con không đập cánh đồng loạt
_mpb.SetFloat(ID_AnimSpeed, …); _mpb.SetFloat(ID_ScaleAmount, …);
renderer.SetPropertyBlock(_mpb);
// ---- Gợi ý framework ----
Enemy (chung)  : Health_Now/Max, Money, is_Die, IProgressBar, IBounce
IEnemyKind     : OnDie() — quyết định vòng đời riêng (tắt ngay, chạy FX chết trước, đẩy wave, nhả dia…)
IDamageRouter  : tag/component -> lượng damage (Ore = hằng 1, không đi qua chuỗi nhân)
ICrowdAnim     : MaterialPropertyBlock(timeOffset, animSpeed, scaleAmount) cho đám đông; Animator chỉ cho boss
StageCurve     : bảng tính trước N stage + cache; nguồn sự thật là BẢNG đã bake, không phải hàm sinh

Bẫy đã gặp

  • SetActive(false) bất đối xứng trong Die_Reloadcó thật trong disasm: chỉ Tree/Tree_SpeedRun bị tắt tại Enemy; 4 loại kia tự quyết (Ore tự tắt ở cuối Die_Reload, Monster/Boss có thể cần FX chết). Ép mọi loại chết giống nhau sẽ làm mất hiệu ứng chết của boss (doc 05 §3.4).
  • Knockback chia Health_Max, không phải Health_Now — dùng Health_Now thì quái sắp chết bị bắn văng kỳ quặc (doc 06 §3.4).
  • Thiếu guard is_gaming trong Enemy.Damaged/Die — quái ăn damage và rơi tiền trong menu (G132b). Ore.Die_ReloadTree_manager.Tree_Destroyed đều có guard rõ ràng trong disasm.
  • Raid có trong binary và scene nhưng không có lối vào — đừng coi nó là nội dung "đã ship" khi làm inventory; nó là chế độ ẩn (SCREEN_CATALOG §6, G153).
  • Bảng stage không sinh lúc chạy. Tree_manager.Balance_Reload (0x2B447EC) là dead code như các Balance_Reload khác (CONVERGENCE, LIVEOPS_SURFACE §4); mảng thật ở Main.unity:20614934 / :20624935. Đọc PowOfThree rồi suy ra "máu tăng theo bậc 3" là đoán — doc 05 §5 ghi rõ công thức UNKNOWN.
  • Đếm wave bằng nhiều state — SOT chỉ có một TreeCount_Now, tỉ lệ tính lại từ nó; thêm bộ đếm thứ hai là mời bug lệch (doc 05 §3.6).
  • GetComponentInParent trên mỗi lần trúng đạn — SOT tránh bằng EnemyColl.enemyScript (doc 05 §3.4/§4.8).
  • Đọc comparer Morton thành "xáo trộn" rồi thay bằng Random ⇒ cây tụ đám, đúng thứ SOT cố tránh (G343).
  • Coi Ore.Spawn(int skinNum) là chỉ số skin ⇒ âm thầm phá thưởng dia (binary dùng nó như boolean; G343).
  • GameTime_Max mặc định 0 vì công cụ decompile bỏ rơi khởi tạo field ⇒ màn thua ở frame 1 mà không ném gì — tệ hơn null vì nó im lặng (G306). Đọc .ctor của mọi class trước khi dựng bất kỳ method nào.
  • Công cụ decompile gộp hai hàm làm một (Tree.Die_Reload "ôm" trọn Tree_manager.Tree_Destroyed qua tail call) ⇒ ship sai class, sai visibility, một stub rỗng và một null-guard bịa. Kiểm biên hàm trước khi tin kích thước (G346; doc 08 §3.6).

Checklist khi tự làm

  • [ ] Một component Enemy chung (máu/tiền/thanh máu/nảy) + component anh em cho từng loại; Die_Reload() dispatch theo enum.
  • [ ] Cho loại riêng tự quyết vòng đời (tắt ngay hay chạy FX chết) — không ép chung.
  • [ ] Mọi kẻ địch nằm sẵn trong scene/pool; đếm .ctor lúc load bằng số instance (không Instantiate lúc chạy).
  • [ ] Collider con có liên kết ngược tới Enemy (kiểu EnemyColl.enemyScript).
  • [ ] Thanh máu đám đông = vertex color / shader (SpriteFill); thanh boss = 9-slice + text (doc 06 §3.2).
  • [ ] Knockback tỉ lệ damage / maxHealth, chỉ cho loại "quái", lưu handle coroutine để huỷ cái cũ.
  • [ ] Đám đông animate bằng MaterialPropertyBlock + lệch pha; Animator chỉ cho boss/raid boss.
  • [ ] Mục tiêu tài nguyên phụ (quặng) nhận damage hằng số — tách khỏi chuỗi nhân damage.
  • [ ] Wave = một số nguyên; tiến độ tính lại; Game_Win khi về 0.
  • [ ] Đường cong stage: bake bảng, cache dictionary, tính trước N stage (5 000 ở SOT); bảng là nguồn sự thật.
  • [ ] Cây boss chỉ khác cây thường bằng một số count hạt (15 vs 5), không cần prefab riêng.
  • [ ] Đặt mục tiêu trên lưới tất định (Z-order + lấy mẫu cách đều) + jitter nhỏ; giữ Random cho jitter, không cho phân bố.
  • [ ] Bảng bốc bậc theo level là trọng số 100 %/hàng; bậc "boss" không nằm trong roulette.
  • [ ] Relic "skip màn" chỉ nhảy trong vùng đã chinh phục — tăng tốc cày lại, không đẩy tiến độ mới.

Điều chưa biết (UNKNOWN)

  • ~~Công thức Tree_manager.GetStageHealth / GetStageMoney~~ — ĐÃ ĐÓNG (G395): hàm sinh dùng seed 250 (máu) / 20 (tiền), ×3 mỗi stage, cắt về 2 chữ số có nghĩa — mảng scene 5 000 mục khớp 8 mục đầu (2250 → 2200, 19800 → 19000, 171000 → 170000). Hàm sinh vẫn là dead code lúc chạy — mảng scene là nguồn sự thật; công thức giải thích vì sao mảng trông như thế. Chưa đối chiếu đủ 5 000 mục.
  • Monster.Freeze()Barrier_Touched()CHƯA dựng lại; biết chữ ký và BarrierRadius = 20 (RVA 0x2AF82E8 / 0x2AF7904).
  • Monster.Knockback_CoroutineCHƯA dựng lại; đường cong/khoảng cách/thời gian UNKNOWN (RVA 0x2AF7DE8).
  • Boss_Slider.Value_Reload / Time_ReloadCHƯA dựng lại, phụ thuộc InfVal_change() (G007a).
  • Có đường mở khoá Raid nào ở runtime không — bằng chứng tĩnh: KHÔNG; cần probe xác nhận (UNLOCK_GATES §5, G153).
  • Value thực tế của WantSize cho 1 939 Bounce_Custom (nảy khi trúng) — UNKNOWN, dữ liệu inspector trong scene (doc 06 §5).
  • Boss_manager (29m), SpeedRun_manager (26m), Mine_manager (31m) — CHƯA khảo sát ngoài chữ ký, hằng số và field restore; Monster_manager / Raid_manager đã đọc được các bảng static nhưng chưa đọc spawner (doc 07 §5).
  • MonsterCountPerLevel / SpawnIntervalPerLevel — "level" là Level_Now (0..4) hay bậc gì: UNKNOWN (consumer chưa đọc; G353).
  • ~~Cây/quặng chưa hiện trên bản dựng lại~~ — ĐÃ GIẢI (G354): cây nằm ngoài khung camera orthographic cho tới khi mèo tự đi tới (batch C); quặng (Mine) vẫn cần batch riêng cho Mine_manager (21 stub).
  • ~~Cây không mất máu khi bị bắn (G424)~~ — ĐÃ ĐÓNG (G405): build 20 cây đổ 104/104 hit (xem 04).
  • Chế độ Play (SpeedRun) — đã đọc trọn (G406): số cây clamp(15·stage + 20, 20..190), bậc ngẫu nhiên 1 / 1.8 / 3 trên đường cong stage, 60 s, bố cục vòng tròn, thắng = cây cuối đổ ⇒ stage++ và 30 dia (×4 auto); quét tự động khi sức mạnh ≥ máu stage × n × 193/100/24. Xem 10.
  • Map "kẹo ngọt" ở stage 2 — không phải bug: chủ đề map = stage % 10 (10 chủ đề lặp) (G406).
  • Ánh xạ sprite/skin của quái (SkinSprite trong Monster.Spawn) tới asset cụ thể — chưa trích từ scene.

Đọc thêm