CatGunner CookbookBardCat 1.1.59 · L1 ANALYSIS · tài liệu học
Trang chủCookbook › 04 — Chiến đấu & đạn (một class Bullet cho 7 loại súng)
Nguồn: 04-chien-dau-dan.md · Cập nhật lần cuối: 2026-09-05 08:39 +07 · sha256 nguồn e9c629816146

04 — Chiến đấu & đạn (một class Bullet cho 7 loại súng)

Nguồn framework: docs/framework/02-combat-projectiles.md toàn bộ (§2–§5), docs/framework/03-particles-vfx.md §3.2–3.3 (FX khi trúng), docs/framework/06-juice-game-feel.md §3.4 (knockback). 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ột idle-shooter bắn liên tục (5 phát/giây), không cấp phát bộ nhớ, va chạm với hàng nghìn mục tiêu, và có nhiều "loại súng" (đơn, laser, xuyên, shotgun, nổ, tên lửa, sniper nổ) mà không cần cây prefab/class riêng cho từng loại — thì làm đúng như CatGunner: một class Bullet, một enum GunType, một switch, vài field trạng thái, và một pool 27 viên. Trang này cho artist biết viên đạn nào cần asset gì, và cho dev biết mỗi loại súng khác nhau ở đâu.

Game gốc làm gì

Điều game làm Con số / hằng số Mức bằng chứng Nguồn
Class đạn thật là Bullet (22 method); BulletShooter không bao giờ chạy trong gameplay bình thường Bullet.Update 14 199× / 40 s; BulletShooter.* runtime-verified (negative) doc 02 §2, [runtime trace: runtime_ranking.json]
Đạn được pool cố định, không Instantiate mỗi phát 27 instance Bullet trong scene scene-census doc 02 §2, scene_census.json
Enum GunType có đúng 7 giá trị SINGLE=0, LASER=1, PIERCE=2, SHOTGUN=3, EXPLOSIVE=4, MISSILE=5, BLAST_SNIPER=6 metadata-proven doc 02 §2, dump.cs:596519 (TypeDefIndex 8226)
Chỉ số vũ khí nằm trong Gun_manager.Gun_Info field: Name, type, InfVal Damage, AtkSpeed, ShotgunPelletCount, ShotgunSpreadAngle, ExplosionRadius, MissileExplosionRadius, MissileSpeed, MissileElevationMin/Max, BlastSniperExplosionRadius, PenetratingBulletLifetime metadata-proven doc 02 §2, dump.cs:596533
Bên bắn là Cat, không phải Gun Cat.TrySpawnBulletEx 0x2AEDFB0, TryFireShotgun 0x2AEE134, TryFireMissile 0x2AEE32C, ApplySpread 0x2AEDF20 metadata-proven doc 02 §2, dump.cs:593002
Lấy đạn từ pool Polling_manager.Object_Polling(Objects_Type.Bullet); Objects_Type { Bullet = 0, Bullet_FX = 1 } metadata-proven + runtime-observed (207×) doc 03 §2, dump.cs:589342
Dispatch va chạm trong OnTriggerEnter; 5 method Hit* bị compiler inline vào đó RVA 0x2B089C8 disasm-proven doc 02 §2, G130
Đạn thẳng bay bằng Rigidbody.linearVelocity; chỉ missile được di chuyển tay trong Update SetupLinear 0x2B07CF8, Update 0x2B0816C disasm-proven doc 02 §2
Missile bay parabol, đỉnh tại t = 0.5 y = lerp(y0,y1,t) + (1-t)·t·(arcHeight·4) disasm-proven doc 02 §2/§3.3, RVA 0x2B0816C
Độ cao cung missile _msArcHeight = khoảng cách ngang (x,z) × arcHeightMultiplier (mặc định 0.4f) disasm-proven doc 02 §2, SpawnMissile 0x2B07E80
Điểm va chạm được tua ngược một bước vật lý rồi ClosestPoint transform.position - rb.linearVelocity * Time.fixedDeltaTime disasm-proven doc 02 §2/§3.4, RVA 0x2B089C8
Chống trúng lặp HashSet<int> _hitIds chứa Collider.GetInstanceID() (field @0xD0) disasm-proven doc 02 §2
Nổ AoE Physics.OverlapSphere(center, explosionRadius, enemyLayer); bỏ collider không activeInHierarchy và collider đã trong _hitIds disasm-proven doc 02 §2, Explode 0x2B08404
Damage không đi thẳng vào Enemy Bullet.Hit* → Gamemanager.Damage_Collider → Damage_Tag → Enemy.Damaged; trace 207 / 207 / 202 disasm-proven + runtime-verified doc 02 §2, combat_damage.cs.evidence
Phân loại mục tiêu bằng tag "Enemy" nhận Damage của đạn; "Ore" nhận cố định (InfVal)1 disasm-proven doc 02 §2/§3.6, Damage_Tag 0x2A503F0
Vòng đời đạn dùng Invoke, không coroutine Invoke("Destroy", t), Invoke("Deactivate", delay) (_deactivateDelay mặc định 1f) disasm-proven doc 02 §2, doc 03 §3.4
Thời gian sống đạn thẳng min(50 / max(Speed, 0.1), lifetime); Speed mặc định 45f ⇒ ~1.11 s disasm-proven doc 02 §2, .ctor + SpawnEx 0x2B07AD0
FX khi trúng phải qua cổng throttle FXThrottle.CanSpawn(gunNum, pos): MIN_INTERVAL 0.05, MIN_DIST_SQ 2.25, MAX_PER_FRAME 3 metadata-proven (hằng) + disasm-proven (call-site) doc 03 §2, dump.cs:596485, SpawnFX 0x2B086A8
Text sát thương / chí mạng khi trúng is_Cri ? Critical_Txt_Spawn : Damage_Txt_Spawn; có CanSpawnText cooldown 0.12 s theo ô lưới 1.5 disasm-proven + metadata-proven doc 02 §3.1, doc 06 §2.4
Trong cửa sổ trace chỉ thấy súng SINGLE HitSingle 207×; các nhánh khác chưa runtime-verify runtime-observed (negative) doc 02 §2/§5
Cụm bắn của Cat đã dựng lại (batch B): Attack, UpdateHandRotation, TryFireMissile/Shotgun, UpdateSpreadState, TrySpawnBulletEx, CalcBulletDir, GetFirePosition, CalcBulletSpawnPos, ApplySpread, ApplyHandZ 12 method disasm-proven doc 02 §2 (G363)
Pool đạn hai lượt: lượt 1 lấy viên inactive đầu tiên; không có ⇒ lượt 2 cướp viên đã chết đầu tiên (huỷ hẹn Deactivate của nó); null chỉ khi cả 27 viên còn sốngCat.Update hoàn lại cooldown, không mất phát 27 disasm-proven doc 02 §2, doc 03 §5 (G363)
Spread = bloom: tăng mỗi phát (trừ shotgun), rỉ dần khi không có mục tiêu; mục tiêu mới ⇒ reset spread + cooldown disasm-proven doc 02 §2 (G363)
Missile trượt bằng tán xạ điểm chạm: điểm nổ rải trong bán kính range × tan(spread) quanh mục tiêu — không lệch hướng bay disasm-proven doc 02 §2 (G363)
Nòng súng chiếu dọc hướng camera lên mặt phẳng y = 2 ⇒ đạn luôn xuất phát cùng độ cao màn hình BulletSpawnY = 2 disasm-proven doc 02 §2 (G363)
Tiếng bắn tắt khi panel mute mở HOẶC chế độ eco (is_Shoot_Mute AND Setting.is_eco) 2 cờ disasm-proven doc 04 §5 (G363)
Chuỗi 100 sát thương của súng khởi đầu, kiểm từ số scene: Gun_list[0].Damage 100 × Power_balance[0] 100/100 × Rebirth 100/100 × Skin 100/100 × (Fish 0+100)/100 × Relic 100/100 = 100; đường TrySpawnBulletEx → Bullet.SpawnEx → OnTriggerEnter → HitSingle → Damage_Collider đầy đủ 100 disasm-proven + scene (derived) doc 02 §2 (G363)
R1 (phủ định): không nhánh weapon_02 / AD_Type nào trong cụm bắn ⇒ không gì trong đường bắn chạm IAdService 0 disasm-proven (negative) G363
Đạn thẳng đi bằng rb.velocity (SetupLinear: không kinematic, velocity = forward × Speed); missile kinematic, dời bằng transform trong Update; Enemy/Tree/Ore không có khởi tạo bị bỏ rơi; Enemy.Damaged chỉ 2 thoát sớm (is_Die, !is_gaming) disasm-proven doc 02 §2 (G391)
Ba cổng "sức mạnh" theo chế độ: Boss = Σ damage kỳ vọng × 100 / cool; Hunt = như Boss × hệ số mục tiêu theo loại súng (SINGLE 1 · LASER 7 · PIERCE 5 · SHOTGUN = số viên · EXPLOSIVE 5 · MISSILE 5 · BLAST_SNIPER 10); Play = mỗi phát (Damage × (100 − cri%) + CriDmg × cri%) / 100, cri% = round(CriPer × 100) làm tròn ngân hàng, kẹp 0..100 1/7/5/·/5/5/10 disasm-proven doc 02 §2 (G395)
Bảng hệ số khác bảng MultiTargetCount của simulator offline (Laser/Pierce 10, nổ 5, sniper 5) — hai bảng cho hai mục đích, đừng gộp 2 bảng disasm-proven doc 02 §2, doc 01 §5.8
Bắn đã chạy trên thiết bị (bản dựng lại, batch 15): súng lục bắn liên tục, chớp nòng, tracer tới mục tiêu; giật lùi súng = Bounce_Position (bản vị trí của Bounce_Custom, coroutine huỷ-lặp) build-measured doc 05 §2.1, doc 06 §2.1 (G369, G371)
Damage kỳ vọng lên boss: bỏ Power_return, trộn thường/chí mạng theo tỉ lệ crit (giá trị kỳ vọng mỗi phát) disasm-proven doc 05 §2.1 (G369)

Ngữ nghĩa từng GunType (7 loại, 5 handler, 1 class)

GunType Handler Chuyện gì xảy ra khi trúng Mức
SINGLE HitSingle Damage → FX → đạn chết. disasm-proven
SHOTGUN HitSingle Cùng handler với SINGLE. "Shotgun" nằm ở phía bắn: Cat.TryFireShotgun bắn ShotgunPelletCount viên, mỗi viên lệch góc theo ShotgunSpreadAngle qua ApplySpread. disasm-proven
LASER HitLaser Trúng enemy: thêm _hitIds, damage, SpawnFX_Once, đi tiếp vô hạn. Trúng non-enemy: FX + chết. disasm-proven (chưa quan sát chạy)
PIERCE HitPierce Trúng non-enemy ⇒ chết. Trúng enemy: _pierceCount++; tính trước kill = Damage >= e.Health_Now; damage; SpawnFX_Once; nếu _pierceCount >= 2 && !kill ⇒ chết. Tức giết được thì đi tiếp. disasm-proven, nhánh có rủi ro đảo, cần runtime-verify
EXPLOSIVE HitExplosive Explode(hitPos) → FX → chết. disasm-proven (chưa quan sát chạy)
MISSILE HitExplosive Như EXPLOSIVE, nhưng bay parabol và cũng nổ khi hết thời gian bay (t >= 1). disasm-proven (chưa quan sát chạy)
BLAST_SNIPER HitBlastSniper Lần trúng đầu: _firstHitDone = true, tính survived, Explode, SpawnFX_Once; sống sót ⇒ đạn chết, mục tiêu chết ⇒ tiếp tục như pierce. disasm-proven (chưa quan sát chạy)

Suy ra từ metadata (doc 02 §2): EXPLOSIVE = 4MISSILE = 5 khớp phép test bit ((int)gunType & ~1) == 4 trong Bullet.Destroy — hai loại "nổ" được gom bằng một phép AND.

Công thức / pseudocode

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

// Đường bắn -> pool -> setup   (doc 02 §3.1)
Polling_manager.Object_Polling(Objects_Type.Bullet)          // lấy từ pool 27 viên
Bullet.SpawnEx(...)  |  Bullet.SpawnMissile(...)             // RVA 0x2B07AD0 / 0x2B07E80
   Skin_Reload(gunNum)   // chọn ParticleSystem theo skin súng
   Mesh_Reload(gunNum)   // bật Bullet_Mesh tương ứng
   ResetState()          // xoá _hitIds, _pierceCount, _firstHitDone; bật lại collider
   SetupLinear()         // rb.linearVelocity = forward * Speed      (RVA 0x2B07CF8)
   Invoke("Destroy", min(50 / max(Speed, 0.1), lifetime))

// Missile — Bullet.Update, RVA 0x2B0816C
_msTimer += Time.deltaTime;
t = Clamp01(_msTimer / _msDuration);
x = lerp(start.x, end.x, t);  z = lerp(start.z, end.z, t);
y = lerp(start.y, end.y, t) + (1 - t) * (t * (_msArcHeight * 4));   // (1-t)·t max = 0.25 tại t=0.5 ⇒ ×4 ⇒ đỉnh = _msArcHeight
transform.position = (x, y, z);
if (move.sqrMagnitude > 0.0001) transform.forward = move.normalized;
LastPos = oldPos;
if (t >= 1) { Explode(pos); SpawnFX(pos); DieAndWait(); }

// Điểm va chạm "tua ngược" — OnTriggerEnter, RVA 0x2B089C8
basePoint = _isMissile ? LastPos : transform.position - rb.linearVelocity * Time.fixedDeltaTime;
hitPos    = other.ClosestPoint(basePoint);
if (!_hitIds.Add(other.GetInstanceID())) return;             // dedup
switch (gunType) { SINGLE/SHOTGUN -> HitSingle; LASER -> HitLaser; PIERCE -> HitPierce;
                   EXPLOSIVE/MISSILE -> HitExplosive; BLAST_SNIPER -> HitBlastSniper; }

// Pipeline sát thương — Gamemanager, RVA 0x2A50048 / 0x2A503F0
Damage_Collider(coll, Damage, hitPos, Damage_str, is_Cri)
   -> hit = Damage_Tag(coll, Damage)
        e = coll.GetComponent<EnemyColl>().enemyScript
        tag "Enemy" ? e.Damaged(Damage) : tag "Ore" ? e.Damaged((InfVal)1) : false
   -> is_Enemy = hit (static @0x168)
   -> if (hit) is_Cri ? Critical_Txt_Spawn(str, hitPos) : Damage_Txt_Spawn(str, hitPos)

// Enemy.Damaged — RVA 0x2AFEF9C
if (is_Die) return;  if (!Gamemanager.is_gaming) return;      // guard (G132b)
Health_Now -= Damage;
if (_type == Type.Monster) Monster.Apply_Knockback((float)(Damage / Health_Max));
Slider_Reload();
Health_Now <= 0 ? Die() : _bounce.Bounce_Start();
// ---- Gợi ý framework ----
IProjectile   : chỉ biết Collider + điểm va chạm; ResetState() xoá TẤT CẢ field trạng thái khi rent
IDamageRouter : Collider -> (IDamageable, lượng damage)  // thêm loại mục tiêu = sửa router, không sửa đạn
IDamageable   : Damaged(BigNum) / Die()
ResolveHitPoint(collider, previousPosition) = collider.ClosestPoint(previousPosition)
Parabola(h)   : y = lerp(y0,y1,t) + (1-t)*t*h*4    // tham số là ĐỘ CAO ĐỈNH, designer hiểu ngay

Vì sao đạn không trúng — gốc rễ của lỗi mở lâu nhất (G400)

Nòng súng không nằm ở mặt đất: game chiếu vị trí nòng dọc theo hướng nhìn của camera lên mặt phẳng cố định y = 2, để đạn luôn xuất phát ở cùng độ cao màn hình dù mèo đứng đâu (camera isometric: yaw −45°, pitch 40°):

spawn = pos + CamForward × ((2 − pos.y) / CamForward.y)        // CamForward = Euler(40°, −45°, 0)·forward = (−0.5417, −0.6428, 0.5417)

Bản dựng lại ghi CamForward = Euler(0, −45°, 0)·forwardthiếu pitch, nên CamForward.y = 0chia cho 0 ⇒ mọi viên đạn spawn ở vô cực, bay ngoài mọi collider, hết hạn theo lifetime, và OnTriggerEnter không bao giờ được gọi — khớp hoàn toàn với "0 dòng trace". Đường này chạy mỗi phát vì cả 3 mèo trong scene không gắn điểm bắn ngẫu nhiên (Shoot_Random_trans rỗng ⇒ rơi về nhánh chiếu).

Bài học: một hằng số vector sai một thành phần không ném gì, qua mọi guard, và sống sót qua 7 vòng loại trừ (ma trận va chạm, thứ tự script, ctor, cổng Damaged, rb.velocity, xuyên hầm, layer). Xác minh hằng vector từng thành phần đối chiếu binary; với mọi phép chia, hỏi mẫu số có thể bằng 0 vì hằng sai không. Trạng thái: ĐÃ ĐÓNG — build 20 xác nhận trên thiết bị: 104/104 va chạm đi trọn chuỗi OnTriggerEnter → Damage_Tag → Enemy.Damaged trong 30 s, cây đổ (29 → 26 → 22 → 14 trên save mới; 141 → 121 → 103 → 85 trên save thật), tờ tiền + hạt lá, thanh máu trên xương rồng — game chơi được (G405).

Bẫy đã gặp

  • Đừng chọn target theo tên. BulletShooter nghe như trái tim của game nhưng chạy 0 lần; Bullet chạy 14 199 lần. Runtime ranking là oracle (doc 02 §2/§4, G125).
  • Object pool mang theo state cũ. _hitIds, _pierceCount, _firstHitDone phải bị ResetState() xoá khi lấy từ pool, nếu không viên đạn "nhớ" mục tiêu của lần bắn trước và xuyên qua không gây damage (doc 02 §4.1).
  • VFX nổ trong bụng quái. OnTriggerEnter gọi khi đạn đã xuyên vào collider; không tua ngược một bước vật lý thì hitPos nằm giữa thân quái (doc 02 §3.4).
  • Thiếu guard is_gaming trong Enemy.Damaged/Die ⇒ quái ăn damage và rơi tiền khi đang mở shop (kiến trúc một-scene). Đã sửa, G132b (doc 02 §3.5).
  • 5 method Hit* bị inline vào OnTriggerEnter — decompile chỉ thấy một hàm to; cấu trúc dispatch→Hit* được dựng lại từ các nhánh (G130).
  • Pool có thể trả null — nay biết chính xác khi nào: chỉ khi mọi viên còn sống (hai lượt lấy/cướp trước đó); Cat.Update hoàn lại cooldown, Bullet.SpawnFX bỏ qua FX (doc 03 §5, G363).
  • Factory trả null / tích luỹ base-100 rỗng là lỗi im lặng (R13). Object_Polling_Smart từng là return null ⇒ không viên đạn nào spawn; Rebirth.Damage_return = 0 ⇒ tích sát thương = 0. Không ngoại lệ nào — truy setter/factory trước khi ship (G363).
  • Ore không ăn damage theo sức mạnh. Mỗi nhát trừ đúng 1 — tốc độ đào phụ thuộc tốc độ bắn, không phụ thuộc chuỗi nhân damage (doc 02 §3.6).

Checklist khi tự làm

  • [ ] Một class projectile + enum loại + switch; trạng thái riêng của từng loại là vài field, không phải class con.
  • [ ] ResetState() xoá mọi field trạng thái khi lấy từ pool (hợp đồng pooling quan trọng nhất).
  • [ ] Dedup bằng HashSet<int> của GetInstanceID(), dùng chung cho cả OnTriggerEnterOverlapSphere.
  • [ ] ResolveHitPoint = tua ngược 1 bước vật lý + ClosestPoint (missile dùng LastPos).
  • [ ] Parabol tham số hoá theo độ cao đỉnh ((1-t)·t·h·4), arcHeight = khoảng cách ngang × 0.4.
  • [ ] Tách va chạm (đạn) / định tuyến (router theo tag hoặc component) / mất máu (IDamageable).
  • [ ] Vòng đời đạn tự trả về pool theo hẹn giờ (Invoke), không chờ callback.
  • [ ] Mọi FX trúng đích đi qua throttle (thời gian / khoảng cách / trần mỗi frame) và SpawnFX_Once cho laser/pierce.
  • [ ] Shotgun = nhiều viên ở phía bắn + ApplySpread, không phải một handler riêng.
  • [ ] Guard trạng thái game ở tầng framework (base class hoặc cổng IDamageable), không phải if chép tay.

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

  • Nhánh PIERCE / BLAST_SNIPER ("giết ⇒ đi tiếp, sống ⇒ dừng") — Chưa runtime-verify; _evidence/Bullet/README.md đánh dấu là rủi ro đảo nhánh tinh vi nhất. Khoá bằng: trang bị súng pierce trên thiết bị, hook Damage_Collider + _pierceCount.
  • LASER, EXPLOSIVE, MISSILE, BLAST_SNIPER, SHOTGUNChưa quan sát chạy (trace 40 s chỉ có SINGLE).
  • Bullet.cs (22 method) — Chưa compile-test, chưa runtime-verify; nằm ở _evidence/, không trong tập shipped (G124).
  • Giá trị số cụ thể của Gun_Info từng khẩu (Damage, AtkSpeed, ExplosionRadius…) — UNKNOWN; schema đã biết, dữ liệu nằm trong scene. Lưu ý: Gun_manager.Balance_ReloadRequest_Sheet (Google Sheet 65×9) là dead code theo CONVERGENCE U4 / G144 (HYPOTHESIS cần probe xác nhận).
  • enemyLayer / OreLayer cụ thể — UNKNOWN (LayerMask serialize trên Cat, chưa trích).
  • Damage_Collider_list (bản batch cho AoE) — chữ ký biết, thân chưa dựng lại.
  • Thân FXThrottle.CanSpawn (RVA 0x2B09320) — CHƯA dựng lại; hằng số thì chắc chắn.
  • Object_Polling_Smartđã dựng lại (hai lượt lấy/cướp, G363); quan hệ với Object_Polling vẫn chưa đọc (doc 03 §5).
  • Cat đã hoàn tất (G369); Bounce_Position.Bounce_Start hoá ra không cosmetic — coroutine bên trong từng là stub, Attack gọi mỗi phát; đã dựng cả hai.
  • ~~G424~~ — ĐÃ ĐÓNG (G405): xác nhận trên thiết bị (mục "Vì sao đạn không trúng"). Chuỗi sát thương giờ xác minh trên thiết bị qua số cây đổ.
  • ~~Text sát thương / tiền không spawn~~ — ĐÃ ĐÓNG (G402): CanSpawnText dựng lại (lưới 1.5, X/Y, làm tròn ngân hàng); tờ tiền bay trên build 20.
  • ~~Save mới damage = 0~~ — ĐÃ ĐÓNG (G374): Relic_manager.Start / Skin_manager.Start đặt baseline base-100 trên đường khởi tạo mới; chờ build kế.

Đọc thêm