04-chien-dau-dan.md · Cập nhật lần cuối: 2026-09-05 08:39 +07 · sha256 nguồn e9c62981614604 — Chiến đấu & đạn (một class Bullet cho 7 loại súng)
Nguồn framework:
docs/framework/02-combat-projectiles.mdtoà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.* 0× |
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ống ⇒ Cat.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 = 4 và MISSILE = 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)·forward — thiếu pitch, nên CamForward.y = 0 ⇒ chia 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.
BulletShooternghe như trái tim của game nhưng chạy 0 lần;Bulletchạy 14 199 lần. Runtime ranking là oracle (doc 02 §2/§4, G125). - Object pool mang theo state cũ.
_hitIds,_pierceCount,_firstHitDonephả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.
OnTriggerEntergọi khi đạn đã xuyên vào collider; không tua ngược một bước vật lý thìhitPosnằm giữa thân quái (doc 02 §3.4). - Thiếu guard
is_gamingtrongEnemy.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àoOnTriggerEnter— 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.Updatehoàn lại cooldown,Bullet.SpawnFXbỏ 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_Smarttừ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ủaGetInstanceID(), dùng chung cho cảOnTriggerEntervàOverlapSphere. - [ ]
ResolveHitPoint= tua ngược 1 bước vật lý +ClosestPoint(missile dùngLastPos). - [ ] 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_Oncecho 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ảiifché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ị, hookDamage_Collider+_pierceCount. LASER,EXPLOSIVE,MISSILE,BLAST_SNIPER,SHOTGUN— Chư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_Infotừng khẩu (Damage, AtkSpeed, ExplosionRadius…) — UNKNOWN; schema đã biết, dữ liệu nằm trong scene. Lưu ý:Gun_manager.Balance_Reload→Request_Sheet(Google Sheet 65×9) là dead code theo CONVERGENCE U4 / G144 (HYPOTHESIS cần probe xác nhận). enemyLayer/OreLayercụ thể — UNKNOWN (LayerMask serialize trênCat, 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(RVA0x2B09320) — 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ớiObject_Pollingvẫn chưa đọc (doc 03 §5).Catđã hoàn tất (G369);Bounce_Position.Bounce_Starthoá ra không cosmetic — coroutine bên trong từng là stub,Attackgọ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):
CanSpawnTextdự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
../framework/02-combat-projectiles.md— toàn bộ bằng chứng,Bullet.cs22 method../framework/03-particles-vfx.md—FXThrottle,Bullet_FX, pool GameObject../framework/06-juice-game-feel.md§3.3–3.4 — text sát thương, knockback../framework/05-characters-pets-enemies.md§2.3 — mục tiêu nhận đạngap list (nội bộ)— G019, G124, G125, G130, G132b, G133, G144