CatGunner CookbookBardCat 1.1.59 · L1 ANALYSIS · tài liệu học
Trang chủTài liệu framework › 06 — Juice & Game Feel
Nguồn: 06-juice-game-feel.md · Cập nhật lần cuối: 2026-09-05 08:45 +07 · sha256 nguồn 211692812e71

06 — Juice & Game Feel

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

"Juice" là phần khiến một game idle cảm thấy đáng chơi dù về mặt cơ học nó chỉ là số tăng lên. CatGunner đầu tư vào đây rất nặng, và bằng chứng là số liệu: 1 939 component nảy trong scene, 169 246 lời gọi cho riêng class vẽ thanh máu trong 40 giây.

Tài liệu này liệt kê từng kỹ thuật, kèm hằng số thật, và chỉ ra cái nào rẻ và cái nào đắt.


2. Stable truths

2.1 Squash & stretch (Bounce_Custom)

Claim Mức bằng chứng Nguồn / provenance
Bounce_Custom là MonoBehaviour đông nhất của game: 1 939 instance trong scene scene-census [index: scene_census.json]
_upSizeTime = 0.2fmysize = Vector3.one là giá trị mặc định trong .ctorđã xác minh runtime disasm-proven + runtime-verified .ctor RVA 0x2A695D8; field-delta oracle
AwakeSize_reload() đặt HalfTime = _upSizeTime * 0.5f một lần duy nhất (có cờ is_sizeReloaded) — runtime-verified: HalfTime 0 → 0.1, is_sizeReloaded 0 → 1 disasm-proven + runtime-verified RVA 0x2A6942C / 0x2A69454
OnEnable đặt Bouncing = falselocalScale = mysizeruntime-verified disasm-proven + runtime-verified RVA 0x2A6947C
Bounce_Start() đặt time = 0, thoát sớm nếu đang Bouncing, rồi StartCoroutine nếu activeInHierarchyruntime-verified: time → 0, Bouncing 0 → 1 disasm-proven + runtime-verified RVA 0x2A69540
Đường cong nảy: nửa đầu Lerp(mysize, WantSize, time/HalfTime), nửa sau Lerp(WantSize, mysize, (time-HalfTime)/HalfTime), quá hạn thì reset về mysize disasm-proven Coroutine_NoRepeat RVA 0x2A694AC, MoveNext 0x2A69650
Runtime: Coroutine_NoRepeat 3 997×, OnEnable 561×, Awake 501×, Bounce_Start 324×, .ctor 1 939× trong ~40 s runtime-observed [runtime trace: runtime_ranking.json]
Bounce_Start được gọi từ Enemy.Damaged khi kẻ địch trúng đòn mà chưa chết disasm-proven Enemy.Damaged RVA 0x2AFEF9C
Giá trị squash đọc trọn (G410, lane feel): kẻ địch 0.2 s, WantSize (1.1, 0.9, 1) / (0.9, 1.1, z); capsule HUD 0.9 / 0.2 s; pop-in 0.3 s; BounceBtn (679 instance) ctor WantSize 0.9, UpTime 0.1co rồi GIỮ khi đang nhấn; Bounce ctor WantSize 0.9, _upSizeTime 0.2 disasm-proven G410
Giật lùi súng: Bounce_Position ctor WantPos 0, 0.2 s; local X −0.6 mỗi phát disasm-proven G410
Dia bay về ví: Dia_Attraction — điểm hút = capsule mới bật gần nhất; khi tới: sfx Tick + squash capsule; 8 hạt, 0.3 s (ParticleImage.EmitAtWorldPosition) disasm-proven G410
Rung: VibrationHelper30 ms, biên độ 100, chỉ API ≥ 26, chỉ khi thả súng vào ô ghép (fusion) disasm-proven G410
BẰNG CHỨNG PHỦ ĐỊNH đáng nhớ: KHÔNG có camera shake, zoom, hit-stop hay timeScale nào trong game ship — không class nào tồn tại; Follow gán cứng; eco mode tắt hẳn GameObject camera. Toàn bộ "đã tay" đến từ squash/pop/số bay/hạt/âm disasm-proven (negative) G410

2.2 Pop-in (BounceStart, BounceBtn)

Claim Mức bằng chứng Nguồn / provenance
BounceStart (441 instance) mặc định: WantSize = 1.1f, _upSizeTime = 0.3f, Middle_value = 0.6f disasm-proven .ctor RVA 0x2A68EB8
Size_reload() chia cửa sổ: First_Time = _upSizeTime * Middle_value (0.18 s), Second_Time = _upSizeTime - First_Time (0.12 s) disasm-proven RVA 0x2A68DB4
Chuỗi tween: scale = one * 0.001 → yield → tăng tới WantSize trong First_Time → lắng về 1.0 trong Second_Timescale = one disasm-proven Coroutine_NoRepeat RVA 0x2A68E24, MoveNext 0x2A68EDC
BounceStart khởi động ở OnEnable, tức mọi lần panel bật lại đều có pop-in disasm-proven RVA 0x2A68DDC
BounceBtn (679 instance) là biến thể cho nút bấm: OnPointerDown / OnPointerUp / Btn_Down_Coroutine / Size_reload metadata-proven + runtime-observed [runtime trace: runtime_ranking.json]: Btn_Down_Coroutine 62×, OnPointerDown 8×, OnPointerUp 8×, .ctor 679×
Trong scene còn Bounce (5) và Bounce_Position (6) — họ component nảy có 5 biến thể scene-census [index: scene_census.json]
Bounce_Position = bản localPosition của Bounce_Custom (nảy vị trí thay vì scale), dùng cho giật lùi súng: Cat.Attack gọi Bounce_Start mỗi phát; Coroutine_NoRepeat (MoveNext 0x2A69A64) huỷ lần nảy trước rồi chạy lại. Bài học: setter nào khởi động coroutine thì thừa hưởng trạng thái stub của coroutine đó — ship Bounce_Start với coroutine return nullStartCoroutine(null) NRE mỗi phát bắn disasm-proven G369 (G542)

2.3 Thanh máu

Claim Mức bằng chứng Nguồn / provenance
SpriteFill (1 931 instance) mã hoá tỉ lệ máu vào kênh ĐỎ của màu SpriteRenderer: _sprite.color = new Color(value, 1f, 1f, 1f) disasm-proven + runtime-verified RVA 0x2A52BF4; oracle xác nhận _sprite null → SpriteRenderer*Awake
SpriteFill.Value_Reload có fallback GetComponent nếu _sprite null (an toàn khi gọi trước Awake) disasm-proven RVA 0x2A52BF4
Runtime: SpriteFill.Value_Reload 202× (≈ số lần trúng đòn), Awake 500×, .ctor 1 931× runtime-observed [runtime trace: runtime_ranking.json]
Enemy_Slider (1 931 instance) chỉ là data-holder bọc một SpriteFill Fill disasm-proven .ctor RVA 0x2B437F4
Enemy.Slider_Reload() tính ratio = (float)(Health_Now / Health_Max) rồi Slider.Fill.Value_Reload(ratio) + Slider.gameObject.SetActive(true) disasm-proven RVA 0x2AFF108
Boss không dùng Enemy_Slider: khi Slider == null_type == BossMonster, nó gọi Boss_manager.instance.gameSlider.Value_Reload(Health_Now, Health_Max) disasm-proven Enemy.Slider_Reload RVA 0x2AFF108
Boss_Sliderhai thanh (SlicedFilledImage Value_Fill + Time_Fill) và hai text: Value_Reload(InfVal Health_Now, InfVal Health_Max) (RVA 0x2AF1F40), Time_Reload(float time_now, float time_max) (RVA 0x2AF34C8) metadata-proven dump.cs:593885
SlicedFilledImage là class chạy nhiều nhất toàn game: 44 method, 169 246 lời gọi / 40 s, 93 instance runtime-observed + scene-census [runtime trace: runtime_ranking.json]
SlicedFilledImage : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter metadata-proven dump.cs:591085
Đây là component mã nguồn mở MIT (SlicedFilledImage, hiryma / Unity UI Extensions) được vendor dưới dạng source trong namespace toàn cục của Assembly-CSharpkhông phải package Asset Store user sở hữu package-source G128 trong gap list (nội bộ); uas.py list "UI Extensions" → 0

2.4 Text nổi (damage / crit / tiền / dia)

Claim Mức bằng chứng Nguồn / provenance
5 đường sinh text: Money_Txt_Spawn(string, Vector3), Dia_Txt_Spawn(int, Vector3), Dia_Devide_Txt_Spawn(string, Vector3), Damage_Txt_Spawn(string, Vector3), Critical_Txt_Spawn(string, Vector3) metadata-proven dump.cs:583820 (RVA 0x2A4EF48, 0x2A4F498, 0x2A50C9C, 0x2A50594, 0x2A506AC)
Cổng chống spam: static bool CanSpawnText(Vector3 worldPos, float cooldown) (RVA 0x2A507F4) + coroutine CleanupTextCooldowns metadata-proven + runtime-observed dump.cs:583820; CanSpawnText fired 287×
Cooldown lưu theo ô lưới không gian: static readonly Dictionary<Vector2Int, float> _textCooldowns với BUCKET_SIZE = 1.5 metadata-proven dump.cs:583820
Thân CanSpawnText (đợt 11, G402): key = Vector2Int(Math.Round(x / 1.5), Math.Round(y / 1.5))chỉ X và Y (z không bao giờ được đọc), làm tròn ngân hàng (Math.Round, không Floor); trả false nếu ô đã được đóng dấu cách đây < cooldown, ngược lại đóng dấu Time.time. Cooldown tại call-site: tiền 0.2 s, sát thương / chí mạng 0.12 s. Stub return false của nó từng tắt mọi số bay (sát thương/crit/tiền/dia) disasm-proven 0x2A507F4; G402
Hai mặc định .cctor nữa của Gamemanager: _lastForceCheckRealtimeLastSaveRealtime = float.NegativeInfinity — với 0, lần Get_Dia/Lose_Dia đầu trong 5 s sẽ âm thầm bỏ qua save (throttle 5 s) disasm-proven G402
Hằng số cooldown: DAMAGE_TEXT_COOLDOWN = 0.12, CRITICAL_TEXT_COOLDOWN = 0.12, MONEY_TEXT_COOLDOWN = 0.2 metadata-proven dump.cs:583820
Runtime: Damage_Txt_Spawn 202× cho 207 phát đạn ⇒ throttle thực sự loại bỏ một phần runtime-observed [runtime trace: runtime_ranking.json]
DamageTextAnimation có: [Range(0.01,100)] float duration, int spriteGap, bool screenSpace, [ConditionHide("screenSpace")] float offsetX/offsetY, Font font, DamageTextAnimationCurve[] curves metadata-proven dump.cs:584201
DamageTextAnimation.DistributeMovementX(float firstEnd = 400, float lastEnd = -400, float t0 = 0, float t1 = 1) — trải các chữ số ra hai bên metadata-proven RVA 0x2A5304C
DamageTextAnimationCurve giữ 4 AnimationCurve (movementX, movementY, alpha, scale) và nướng sẵn chúng thành float[]: const int BAKE_SAMPLES = 64, bool baked, bakedMovementX/Y, bakedAlpha, bakedScale, cùng Bake(), BakeCurve(AnimationCurve), static float EvalBaked(float[] baked, float t) metadata-proven dump.cs:584171
Ore rơi dia dùng literal cứng "+0.1" (khi _diaValue == 1) hoặc "+1", tại 0x5E81500 / 0x5E81508 disasm-proven Ore.Die_Reload RVA 0x2AF76E4
Text bay lên KHÔNG dùng TextMeshPro — nó là hệ mesh bitmap-font tự viết: DamageTextManager.ShowDamageText(key, content, pos)DamageTextObject.InitSprites (0x2A544C8) → DamageTextSprite.InitChar (0x2A55EB8) → DamageTextSpriteManager.GetSpriteInfo (0x2A560EC) → Font.GetCharacterInfo VERIFIED (decompile cả 4) [evidence codegen: UPPERCASE_FINDING.md]
Ký tự đi qua đường vẽ đó nguyên vẹn — không biến đổi, không ToUpper, không case-folding VERIFIED như trên
Bốn font Damage, Damage (Cri), Dia, Money_Crim_FontData độ dài 0 vì chúng là BITMAP FONT (bảng 42 ký tự + atlas PNG), không phải TTF hỏng VERIFIED G012 REINTERPRETED; bảng + . 0-9 B K M T a-z đã kiểm trong Assets/Font/Damage.asset
Đơn vị hiện chữ HOA ở text bay (3.12A) trong khi HUD hiện chữ thường (3.12a) là do ART CỦA GLYPH: codepoint là a (97), artwork vẽ thành "A" VERIFIED UPPERCASE_FINDING.md; ảnh chụp [device evidence: user_screenshot_stage5-4.png]
5 spawner text nổi, mỗi cái một style key + prefix + cooldown riêng (bảng ở §3.3) disasm-proven [evidence codegen: text_spawn.cs.evidence]
Dia_Txt_SpawnDia_Devide_Txt_Spawn KHÔNG gọi CanSpawnText — không bị throttle (đã kiểm) disasm-proven (bằng chứng phủ định) như trên
Mọi spawner đều thoát sớm nếu Camera.main == null disasm-proven như trên
Boss_Slider.Value_Reload: Value_Fill.fillAmount = Health_Now / Health_Max; Value_txt = Health_Now.InfVal_change(). Time_Reload định dạng "{0}s" disasm-proven Boss_Slider.cs (class thứ 23 đã ship); G186

2.5 Knockback và chuyển động UI

Claim Mức bằng chứng Nguồn / provenance
Knockback chỉ áp cho Enemy.Type.Monster; cường độ = damageRatio = (float)(Damage / Health_Max) disasm-proven Enemy.Damaged RVA 0x2AFEF9C
Monster.Apply_Knockback(float damageRatio)Knockback_Coroutine(damageRatio), giữ handle trong _knockbackCoroutine metadata-proven dump.cs:594503 (RVA 0x2AF7D70 / 0x2AF7DE8)
Scrolling (38 instance) cuộn RawImage.uvRect mỗi frame theo Vector2 Speed — chạy 5 104× trong 40 s disasm-proven + runtime-observed RVA 0x2AD4EB0
Hatch (44 instance) lát UV theo kích thước RectTransform: uvRect = new Rect(0, 0, rect.width / Hatch_Size, rect.height / Hatch_Size) disasm-proven RVA 0x2AD49A4
Follow (7 instance) gán cứng transform.position = Target_trans.position mỗi Updatekhông làm mượt disasm-proven RVA 0x2AC6860

3. Mô hình hành vi

3.1 Nảy — hai đường cong, một coroutine

// Bounce_Custom.Coroutine_NoRepeat — RVA 0x2A694AC / MoveNext 0x2A69650
while (Bouncing)
{
    yield return null;
    time += Time.deltaTime;
    if (time <= HalfTime)                    // HalfTime = _upSizeTime * 0.5 = 0.1 s
        transform.localScale = Vector3.Lerp(mysize, WantSize, time / HalfTime);
    else if (time <= _upSizeTime)            // _upSizeTime = 0.2 s
        transform.localScale = Vector3.Lerp(WantSize, mysize, (time - HalfTime) / HalfTime);
    else { time = 0f; Bouncing = false; transform.localScale = mysize; }
}

Toàn bộ "cảm giác trúng đòn" của game nằm trong 8 dòngmột hằng số: 0.2 giây.

Điểm thiết kế đáng học:

  • Bounce_Start thoát sớm nếu đang nảy (if (Bouncing) return; sau khi đặt time = 0). Nghĩa là bắn liên thanh vào một con quái không làm nó nảy chồng chất — nó chỉ khởi động lại đồng hồ. Đây chính xác là hành vi mong muốn, và nó miễn phí.
  • OnEnable reset về mysize. Object lấy từ pool không bao giờ mang theo scale dở dang của lần trước. Đây là hợp đồng pooling (doc 03 §3.4) áp dụng cho juice.
  • HalfTime tính một lần với cờ is_sizeReloaded, không tính lại mỗi lần nảy.
  • WantSizeVector3, không phải float — cho phép squash bất đẳng hướng (bẹp ngang, cao dọc), thứ tạo cảm giác "va đập" thật hơn là phóng to đều.

Biến thể BounceStart dùng cùng ý tưởng cho pop-in, nhưng chia cửa sổ không đều:

_upSizeTime = 0.3 s, Middle_value = 0.6
First_Time  = 0.3 * 0.6  = 0.18 s   scale 0.001 -> 1.1   (vọt lên, 60 % thời gian)
Second_Time = 0.3 - 0.18 = 0.12 s   scale 1.1   -> 1.0   (lắng xuống, 40 % thời gian)

Tỉ lệ 60/40 (không phải 50/50 như Bounce_Custom) là thứ khiến pop-in trông "bật ra" chứ không "phồng lên": pha vọt dài hơn pha lắng. Và điểm khởi đầu 0.001 (không phải 0) tránh việc Unity coi scale 0 là suy biến.

Bốn biến thể cho bốn ngữ cảnh: Bounce_Custom (phản hồi trúng đòn, lặp lại được), BounceStart (pop-in khi bật), BounceBtn (phản hồi chạm, có OnPointerDown/Up), Bounce_Position (nảy theo vị trí thay vì scale). Không có một component "làm tất cả".

3.2 Thanh máu — hai giải pháp, hai mức chi phí

Đây là bài học kinh tế-kỹ thuật rõ nhất trong toàn bộ tài liệu.

Giải pháp rẻ — SpriteFill, dùng cho 1 931 kẻ địch:

// RVA 0x2A52BF4 — runtime-verified
public void Value_Reload(float value)
{
    if (_sprite == null) _sprite = GetComponent<SpriteRenderer>();
    if (_sprite != null) _sprite.color = new Color(value, 1f, 1f, 1f);
}

Tỉ lệ máu được nhét vào kênh đỏ của vertex color. Không có mesh nào được dựng lại, không có Canvas nào bị bẩn, không có layout nào chạy lại. Một shader đọc color.r và cắt sprite tương ứng. Chi phí: một phép gán màu.

1 931 thanh máu × mỗi lần trúng đòn — chỉ khả thi vì cách này.

Giải pháp đắt — SlicedFilledImage, dùng cho 93 thanh UI:

44 method, kế thừa MaskableGraphic + 3 interface
169 246 lời gọi / 40 giây, phân bố:
    52 051  get_overrideSprite      (mỗi lần vẽ)
    52 051  get_activeSprite
    31 620  GenerateFilledSprite    <- dựng lại mesh
     9 452  get_pixelsPerUnit
     4 890  set_fillAmount
     4 739  OnPopulateMesh
     4 739  GenerateSlicedFilledSprite
     4 726  get_hasBorder / GetAdjustedBorders

Nó dựng lại tessellation 9-slice mỗi lần fillAmount đổi, tức gần như mỗi frame. Đổi lại, nó cho thanh máu có viền 9-slice không bị kéo méo — thứ mà giải pháp kênh-đỏ không làm được.

Đây là toàn bộ bài học: cùng một tính năng ("thanh tiến độ"), hai hiện thực, chênh nhau khoảng ba bậc độ lớn về chi phí. Game dùng cái rẻ cho 1 931 chỗ mà người chơi liếc qua, và cái đắt cho 93 chỗ mà người chơi thực sự nhìn. Đừng dùng một giải pháp cho cả hai.

Boss có thanh riêng hẳn (Boss_Slider, hai thanh: máu + thời gian, kèm hai text), đi qua Boss_manager.instance.gameSlider chứ không qua Enemy_Slider — vì boss chỉ có một, nên nó xứng đáng với hiện thực đắt tiền nhất:

// Boss_Slider — RVA 0x2AF1F40 / 0x2AF34C8 (đã ship, class thứ 23)
public void Value_Reload(InfVal Health_Now, InfVal Health_Max)
{
    Value_Fill.fillAmount = (float)(Health_Now / Health_Max);   // SlicedFilledImage
    Value_txt.text        = Health_Now.InfVal_change();          // cùng formatter duy nhất
}
public void Time_Reload(float time_now, float time_max)
{
    Time_Fill.fillAmount = time_now / time_max;
    Time_txt.text        = string.Format("{0}s", ...);
}

Chú ý: thanh máu boss hiển thị máu tuyệt đối (InfVal_change()), còn thanh máu quái thường chỉ hiển thị tỉ lệ (kênh đỏ). Boss là mục tiêu người chơi theo dõi bằng con số; quái thường thì không.

3.3 Text nổi — bộ vẽ riêng, nướng đường cong, và throttle theo loại

Bộ vẽ là mesh bitmap-font tự viết, KHÔNG phải TextMeshPro:

DamageTextManager.ShowDamageText(key, content, pos)
  -> DamageTextObject.InitSprites(Font, content)          0x2A544C8
       per char: String.get_Chars                          <- ký tự đi qua NGUYÊN VẸN
  -> DamageTextSprite.InitChar(fontType, font, ch)         0x2A55EB8
  -> DamageTextSpriteManager.GetSpriteInfo(fontType,...)   0x2A560EC
  -> Font.GetCharacterInfo

Cả bốn method đã decompile: không có ToUpper, không case-folding, không biến đổi ký tự nào.

Đây là lời giải cho một quan sát từng gây bối rối: cùng giá trị 3.12×10³ hiện là 3.12A ở text sát thương nhưng 3.12a trên HUD. Chuỗi giống hệt nhau (chỉ có một formatter duy nhất trong game — xem 01 §3.5.1); khác biệt nằm ở artwork của glyph: Assets/Font/Damage.asset định nghĩa 42 codepoint + . 0-9 B K M T a-z, và glyph của a (97) được vẽ thành chữ "A" hoa trong atlas.

Quy tắc framework: kiểu chữ sống trong font atlas, không sống trong code. Đừng bao giờ "sửa" hiển thị bằng .ToUpper() — nó làm sai chuỗi dữ liệu, và một khi art đúng được dùng thì nó thậm chí không tạo ra khác biệt nhìn thấy được. Layer trình bày quyết định hình dạng chữ; layer dữ liệu chỉ trả chuỗi.

Nướng AnimationCurve thành mảng — kỹ thuật quan trọng nhất ở đây:

// DamageTextAnimationCurve — dump.cs:584171
public AnimationCurve movementX, movementY, alpha, scale;   // nguồn, chỉnh trong Editor
public const int BAKE_SAMPLES = 64;
public bool    baked;
public float[] bakedMovementX, bakedMovementY, bakedAlpha, bakedScale;
public void Bake();
private static float[] BakeCurve(AnimationCurve c);
public  static float   EvalBaked(float[] baked, float t);

AnimationCurve.Evaluate() là một lời gọi vào native code, phải tìm nhị phân keyframe rồi nội suy Hermite. Với hàng chục text nổi × 4 đường cong × mỗi frame, nó xuất hiện trong profiler.

EvalBaked(float[64], t) chỉ là một phép tra bảng + nội suy tuyến tính giữa hai mẫu kề nhau — nhanh hơn nhiều bậc và không rời khỏi managed code. 64 mẫu là đủ cho một đường cong 0.5 giây.

Cờ baked cho phép nướng một lần rồi tái dùng vĩnh viễn.

Mẫu tổng quát: giữ AnimationCurve cho designer chỉnh, nướng thành float[] cho runtime chạy.

Năm spawner, mỗi cái một hợp đồng riêng (disasm-proven, text_spawn.cs.evidence):

Spawner RVA style key prefix throttle
Damage_Txt_Spawn(str, pos) 0x2A50594 "damage" không CanSpawnText(pos, **0.12s**)
Critical_Txt_Spawn(str, pos) 0x2A506AC "critical" "+" CanSpawnText(pos, **0.12s**)
Money_Txt_Spawn(str, pos) 0x2A4EF48 "money" "+" CanSpawnText(pos, **0.2s**)
Dia_Txt_Spawn(int, pos) 0x2A4F498 "dia" "+{0}" (format) KHÔNG throttle
Dia_Devide_Txt_Spawn(str, pos) 0x2A50C9C "dia" caller tự format KHÔNG throttle
// mẫu chung — cả 5 đều mở đầu như nhau
if (Camera.main == null) return;                 // không có camera thì không sinh gì
if (!CanSpawnText(Pos, cooldown)) return;        // chỉ 3/5 spawner có bước này
DamageTextManager.Instance.ShowDamageText(styleKey, prefix + str, Pos);

Ba điều đáng học từ bảng trên:

  1. Cooldown khác nhau theo loại, không phải một hằng số chung. Damage/crit 0.12 s; tiền 0.2 s (text tiền sống lâu hơn và bay xa hơn nên dễ chồng hơn).
  2. Tiền tệ hiếm KHÔNG bị throttle. Dia rơi ít, và bỏ mất một thông báo "được kim cương" là mất thông tin thật cho người chơi. Throttle chỉ áp cho thứ xảy ra hàng chục lần mỗi giây. Đây là một quyết định thiết kế có chủ đích, đọc được từ binary — không phải sự thiếu sót.
  3. Style key là chuỗi, và mỗi key trỏ tới một DamageTextAnimation riêng — mỗi cái có font bitmap riêng, đường cong riêng. Đổi cảm giác của text crit = sửa một entry dữ liệu, không sửa code.

Guard Camera.main == null ở cả năm: trong kiến trúc một-scene (doc 08), text có thể được yêu cầu sinh trong lúc chuyển màn hình khi camera chưa sẵn sàng.

Throttle theo ô lưới không gian: Trải chữ số (DistributeMovementX(firstEnd = 400, lastEnd = -400, …)): các chữ số của một con số bay tản ra hai bên (chữ đầu sang phải 400, chữ cuối sang trái −400) thay vì bay thành khối. Cộng với spriteGap, đây là cái tạo ra hiệu ứng "con số vỡ ra" quen thuộc của idle.

3.4 Knockback — cường độ tỉ lệ với % máu mất

// Enemy.Damaged — RVA 0x2AFEF9C
if (_type == Type.Monster)
{
    float damageRatio = (float)(Damage / Health_Max);      // LƯU Ý: chia cho Health_MAX
    Monster m = GetComponent<Monster>();
    if (m != null) m.Apply_Knockback(damageRatio);
}

Chi tiết quan trọng: mẫu số là Health_Max, không phải Health_Now. Nghĩa là:

  • Một đòn lấy 10 % máu tối đa luôn đẩy lùi như nhau, bất kể quái còn bao nhiêu máu.
  • Đòn kết liễu một con quái gần chết không đẩy mạnh bất thường.
  • Cường độ knockback là thước đo trực quan cho "đòn này mạnh cỡ nào so với con quái này" — đúng thông tin người chơi cần.

Nếu dùng Health_Now, quái sắp chết sẽ bị bắn văng ra xa một cách kỳ quặc.

_knockbackCoroutine được lưu lại để huỷ coroutine cũ trước khi chạy cái mới — nếu không, hai đòn liên tiếp sẽ tạo hai coroutine tranh nhau ghi vị trí.

3.5 Chuyển động UI nền — rẻ đến mức đáng ngạc nhiên

// Scrolling — RVA 0x2AD4EB0, 5 104 lời gọi / 40 s, 38 instance
Rect r = img.uvRect;
img.uvRect = new Rect(r.x + Speed.x * Time.deltaTime, r.y + Speed.y * Time.deltaTime, r.width, r.height);

// Hatch — RVA 0x2AD49A4, 44 instance
img.uvRect = new Rect(0f, 0f, rect.rect.width / Hatch_Size, rect.rect.height / Hatch_Size);

Cả hai chỉ thay đổi uvRect của một RawImage. Không mesh, không texture mới, không allocation. Scrolling cho nền chuyển động vô hạn; Hatch giữ mật độ hoa văn không đổi theo pixel dù RectTransform to nhỏ khác nhau (chia kích thước cho Hatch_Size) — chi tiết mà rất nhiều game làm sai.

Follow thì ngược lại — cố tình không làm mượt:

private void Update() { transform.position = Target_trans.position; }

Gán cứng, không lerp. Cho những thứ phải dính chặt (thanh máu trên đầu quái, chỉ báo mục tiêu), bất kỳ độ trễ nào cũng trông như lỗi.


4. Framework takeaway

  1. Squash-and-stretch là một component, không phải một hệ thống animation. float upSizeTime = 0.2f // toàn bộ cảm giác trúng đòn Vector3 wantSize // Vector3, không phải float -> squash bất đẳng hướng Bounce() -> nếu đang nảy thì CHỈ reset đồng hồ, không xếp chồng OnEnable -> reset về scale gốc (hợp đồng pooling) Bốn biến thể cho bốn ngữ cảnh (trúng đòn / pop-in / chạm nút / theo vị trí) tốt hơn một component vạn năng.

  2. Hai tầng thanh tiến độ, chọn theo số lượng và mức chú ý. | | Rẻ | Đắt | |---|---|---| | Kỹ thuật | mã hoá tỉ lệ vào vertex color, shader cắt | 9-slice + dựng lại mesh | | Chi phí | một phép gán | tessellation mỗi frame | | Dùng cho | hàng nghìn mục tiêu trong thế giới | vài chục thanh UI được nhìn kỹ |

Framework nên cung cấp cả hai với cùng một interface IProgressBar.SetValue(float).

  1. Nướng AnimationCurve thành float[N] cho runtime. Giữ curve cho Editor, dùng bảng cho chạy. N = 64 là con số đã ship. Cờ baked để nướng một lần.

  2. Throttle text theo Ô LƯỚI KHÔNG GIAN, không theo đối tượng — và KHÔNG throttle thứ hiếm. Dictionary<Vector2Int, float> + BUCKET_SIZE + cooldown riêng cho từng loại (damage/crit 0.12 s, tiền 0.2 s) + một coroutine dọn dẹp. Vấn đề là mật độ trên màn hình. Nhưng tiền tệ hiếm (dia) cố tình KHÔNG bị throttle — bỏ mất một thông báo hiếm là mất thông tin thật. Framework nên cho throttle là tuỳ chọn theo từng loại text, không phải bắt buộc toàn cục.

4b. KIỂU CHỮ SỐNG TRONG FONT ATLAS, KHÔNG SỐNG TRONG CODE. Cùng một chuỗi hiện 3.12A ở text bay và 3.12a trên HUD chỉ vì hai renderer dùng hai font khác nhau. Đừng bao giờ thêm .ToUpper() để "sửa" hiển thị: nó làm sai dữ liệu, và một khi art đúng được dùng thì nó không tạo khác biệt nhìn thấy được — chỉ để lại một lời khai sai trong code. Hệ quả nội dung: nếu game dùng bitmap font, thì bảng ký tự + atlas PNG chính là font — không thay được bằng TTF mà không đổi cả casing lẫn metrics.

  1. Cường độ phản hồi tỉ lệ với damage / maxHealth, không phải damage / currentHealth. Người chơi cần biết "đòn này mạnh cỡ nào", không phải "con này sắp chết chưa".

  2. Lưu handle coroutine để huỷ cái cũ trước khi chạy cái mới, với mọi hiệu ứng ghi đè transform.

  3. Chuyển động nền = uvRect, không phải di chuyển object. Và chia kích thước cho một hằng số để mật độ hoa văn không đổi theo pixel.

  4. Làm mượt là mặc định tốt, TRỪ khi thứ đó phải dính chặt. Thanh máu trên đầu quái phải gán cứng.

  5. Text nổi trải các chữ số ra hai bên (DistributeMovementX) + spriteGap — hiệu ứng "số vỡ ra".

  6. Toàn bộ juice của game này đo được: 1 939 component nảy, 5 biến thể, ~20 dòng code mỗi cái. Juice không đến từ một hệ thống lớn; nó đến từ nhiều component nhỏ được gắn ở rất nhiều chỗ.


5. Negative evidence & UNKNOWN

Mục Trạng thái Bằng chứng khoá được nó
Tween mỗi frame của Bounce_Custom.Coroutine_NoRepeat PX — đã chạy (3 997×), giá trị localScale từng frame CHƯA đối chiếu. Awake/OnEnable/Bounce_Start/.ctor thì đã runtime-verify bằng field-delta oracle. Oracle đọc localScale sau mỗi frame trong một chu kỳ nảy
BounceStart.Coroutine_NoRepeat P — disasm-proven, chưa quan sát chạy trong cửa sổ trace Mở/đóng một panel có BounceStart khi đang hook
SlicedFilledImage (44 method) CỐ Ý KHÔNG dựng lại tay (G128, R2). Đây là source MIT thượng nguồn — phải lấy từ upstream, không hand-reconstruct 44 method dựng mesh. Lấy SlicedFilledImage từ hiryma / Unity UI Extensions
Boss_Slider.Value_Reload / Time_Reload ĐÃ ĐÓNG. Boss_Slider.cs đã ship (class thứ 23): Value_Fill.fillAmount = Health_Now/Health_Max, Value_txt = Health_Now.InfVal_change(), Time_Reload dùng "{0}s". Cầu nối InfVal đã pass parity 14/14 in-Editor G186; [report: InfVal-parity-20260904T125826Z.xml]
DamageTextAnimation.InitDamageTextAnimation / DistributeMovementX CHƯA dựng lại — chữ ký + giá trị mặc định (400 / -400 / 0 / 1) đã biết; thân thì chưa Disasm RVA 0x2A52FC0 / 0x2A5304C
DamageTextObject UNKNOWN — kiểu tham số của InitDamageTextAnimation, chưa trích Grep class DamageTextObject trong dump.cs
CanSpawnText (RVA 0x2A507F4) ĐÃ DỰNG (G402): băm = Math.Round(x/1.5), Math.Round(y/1.5) (X/Y, làm tròn ngân hàng — không Floor, không z); §2.4
Monster.Knockback_Coroutine CHƯA dựng lại — biết nó tồn tại, nhận damageRatio, dùng _moveVelocity; đường cong/khoảng cách/thời gian UNKNOWN Disasm RVA 0x2AF7DE8
Giá trị WantSize thực tế của 1 939 Bounce_Custom UNKNOWN — dữ liệu inspector trong Main.unity, chưa trích. Chỉ biết mặc định .ctor(0,0,0), tức mọi instance phải được đặt giá trị trong scene. Trích component từ scene YAML
4 font m_FontData rỗng KHÔNG PHẢI GAP, và KHÔNG PHẢI FONT HỎNG. Chúng là bitmap font (bảng 42 ký tự + atlas PNG) — bảng và PNG chính là toàn bộ font. Thay bằng TTF sẽ đổi cả casing lẫn metrics. [SOT: DevXProject] từng ghi 4 .ttf 0 byte — file bịa. G012 REINTERPRETED; UPPERCASE_FINDING.md
Không quan sát thấy Không có DOTween nào trong các class juice đã dựng lại — mọi tween là coroutine viết tay, dù DOTween Pro trong build. Nơi DOTween thực sự được dùng là UNKNOWN. dump.cs các class Bounce*; ScriptingAssemblies.json
Không quan sát thấy Không có screen-shake, hit-stop hay time-scale nào trong các class đã khảo sát. Nếu game có, chúng nằm ở nơi chưa khảo sát. Cần grep rộng Time.timeScale trong dump.cs

6. Raw evidence

  • [reconstructed: Bounce_Custom.cs] — squash-stretch (runtime-verified)
  • [reconstructed: BounceStart.cs] — pop-in
  • [reconstructed: SpriteFill.cs] — thanh máu kênh-đỏ (runtime-verified)
  • [reconstructed: Enemy_Slider.cs], Scrolling.cs, Hatch.cs, Follow.cs}
  • [reconstructed: Enemy.cs]Slider_Reload, gọi knockback, gọi Bounce_Start
  • [disasm: Bounce_Custom.asm], BounceStart.asm, SpriteFill.asm}
  • [disasm: leaf_Scrolling.c], leaf_Hatch.c, leaf_Follow.c}
  • dump.cs:584171 / :584201DamageTextAnimationCurve / DamageTextAnimation
  • dump.cs:593885Boss_Slider
  • dump.cs:591085SlicedFilledImage (44 method)
  • dump.cs:583820 — hằng số throttle text trong Gamemanager
  • [evidence codegen: text_spawn.cs.evidence] — 5 spawner, style key, prefix, cooldown
  • [evidence codegen: UPPERCASE_FINDING.md] — bộ vẽ bitmap-font; chữ hoa là art của glyph
  • [reconstructed: Boss_Slider.cs] — class thứ 23 đã ship
  • dump.cs:594503Monster.Apply_Knockback / Knockback_Coroutine
  • [runtime trace: runtime_ranking.json], validation_samples.json, validate.js} — oracle field-delta
  • Gaps: G007a, G012 (diễn giải lại: bitmap font), G128, G141, G186 trong gap list (nội bộ)

7. FEEL RULES — bổ sung của FEEL lane (2026-09-05)

Toàn bộ mục này là disasm-proven trên libil2cpp.so.split.i64 (mỗi method có get_func(rva).start_ea == RVA), giá trị scene trích từ Main.unity (project == SOT export). Mã C# ở [reconstructed: Assembly-CSharp], bằng chứng ở [evidence codegen: Feel]. Đóng các UNKNOWN ở §5 về Bounce_Custom.WantSize thực tế và về screen-shake.

7.1 Bảng hằng số cảm giác (feel constants) — tất cả là giá trị đã ship

Hệ Hằng số Giá trị Nguồn
Squash trúng đòn (Bounce_Custom, 1 939) _upSizeTime 0.2 s (50/50 vào–ra) .ctor 0x2A695D8; scene 1 939/1 939
WantSize thực tế trong scene (1.1, 0.9, 1) ×1000, (0.9, 1.1, 1) ×691, (0.9, 1.1, 0) ×240, (0.7, 1.1, 1) ×6, còn lại 2 scene census
Nút bấm (BounceBtn, 679) WantSize / UpTime 0.9 × scale gốc / 0.1 s (648 nút), 0.95 (31 nút) .ctor 0x2A68BC0; scene
hành vi giữ co xuống trong 0.1 s rồi giữ nguyên khi còn nhấn; thả ra nở lại 0.1 s MoveNext 0x2A68BD8
Capsule HUD (Bounce, 5) WantSize / _upSizeTime 0.9 / 0.2 s (đều) .ctor 0x2A68784; scene 5/5
Pop-in (BounceStart, 441) _upSizeTime / Middle_value / WantSize 0.3 s / 0.6 / 1.1 (397); biến thể 0.2 s / 0.8 (27); 1.0 (17) scene
Giật súng (Bounce_Position, 6) WantPos / _upPosTime (−0.6, 0, 0) local / 0.2 s; nghỉ = local (0,0,0) (không đọc transform) .ctor 0x2A699F4, Pos_reload 0x2A69870; scene 6/6
SFX (Sound_manager) cooldownSeconds 0.05 s theo từng SfxType, đo bằng Time.unscaledTime .ctor 0x2ACFF74; scene
same-frame guard bật: cùng SfxType trong cùng Time.frameCount → bỏ scene blockSameFrameDuplicate 1
volume theo clip Shoot 0.3, Fusion 0.4, Button 0.5, còn lại 1.0 scene sfxEntries
pitch tham số API, mặc định 1; không có call-site nào biến tấu pitch (79 call-site đều mặc định) xrefs 0x2ACF7D4
Hit-FX (FXThrottle) MAX_PER_FRAME / MIN_INTERVAL / MIN_DIST 3 / frame; 0.05 s 1.5 đơn vị cùng slot súng (phải vi phạm CẢ HAI mới bị bỏ) CanSpawn 0x2B09320
Rung (VibrationHelper) thời lượng / biên độ 30 ms, amplitude 100 (API ≥ 26 VibrationEffect.createOneShot) 0x2AC6D0C
Dia bay về HUD (Dia_Attraction) số hạt / thời gian hạt 8 hạt mặc định, ParticleImage _duration 0.3 s, _loop 0; mỗi hạt về đích → Tick + squash capsule 0x2AD456C, scene 119325
BGM 2 track BGM_main (Relaxed_7_BlossomDrift) ↔ BGM_contents (Cute_5_MilkShake); chỉ đổi khi clip khác 0x2ACFE14 / 0x2ACFEC4

7.2 Quy tắc rút ra

  1. Hai kiểu "re-arm" coroutine cho cùng một tween. Bounce_Custom / Bounce_Position chạy while (Bouncing) { yield return null; … } trong MỘT coroutine; BounceBounceBtn làm một bước rồi tự gọi lại (StartCoroutine("Coroutine_NoRepeat") theo tênBounce, StartCoroutine(Btn_Down_Coroutine())BounceBtn). Kết quả nhìn giống nhau; kiểu tự-gọi-lại cho phép OnPointerUp chỉ đổi cờ is_Pressed và bước tiếp theo tự chuyển sang pha nhả. Framework: chọn một kiểu; kiểu vòng lặp rẻ hơn (không cấp phát mỗi frame).
  2. Nút bấm co xuống và GIỮ, không nảy. Cảm giác "đang nhấn" là trạng thái, không phải xung: 0.1 s vào, giữ ở 0.9×, 0.1 s ra khi thả. OnDisable trả scale gốc nếu đã capture (mysize_x != 0).
  3. Squash bất đẳng hướng là mặc định của toàn bộ kẻ địch: 1 000 instance dùng (1.1, 0.9, 1) — bẹp xuống; 931 dùng (0.9, 1.1, z) — vươn lên. Cùng một component, hai "chữ ký" cảm giác chỉ bằng dữ liệu.
  4. Giật súng là dịch chuyển local −0.6 trên trục X trong 0.2 s, mỗi phát bắn, và re-arm khi đang giật chỉ reset đồng hồ — bắn 5 phát/giây không bao giờ cộng dồn.
  5. SFX đi qua một cửa với 4 cổng theo đúng thứ tự: is_SFX_Mute → có clip? → cùng frame? → < 0.05 s? Cổng cooldown dùng unscaledTime nên không phụ thuộc timeScale. Pitch được đặt trên source dùng chung rồi trả lại ngay sau PlayOneShot (PlayOneShot lấy pitch tại thời điểm gọi).
  6. Volume theo clip là dữ liệu, không phải code: tiếng bắn 0.3 (phát 5 lần/giây), Fusion 0.4, nút 0.5. Tần suất càng cao, volume càng thấp.
  7. BGM mute = volume 0, không Stop. Bật lại là nghe tiếp ở đúng chỗ, không phải phát lại từ đầu. Đổi mode chỉ Play() lại khi clip thực sự khác.
  8. Throttle hit-FX theo (thời gian AND khoảng cách) trên từng slot súng + trần toàn cục/frame. Bắn dồn vào một điểm bị cắt; cùng khẩu súng trúng chỗ khác vẫn hiện.
  9. Rung chỉ cho một tình huống: kéo item vào ô ghép (fusion). Không rung khi bắn, khi trúng, khi mua. Hiếm nên có nghĩa.
  10. Dia bay về HUD dùng attractor của ParticleImage, target là capsule mới bật gần nhất (danh sách Target_list, lấy phần tử cuối); mỗi hạt về đích bắn Tick (đã được cooldown 0.05 s lọc) và squash capsule. Không target → không emit.
  11. Không có screen-shake, hit-stop, hay timeScale nào trong game (đã đóng UNKNOWN §5): Time.set_timeScale chỉ được ACTk SpeedHackDetector và Facebook OnHideUnity gọi; không có Handheld.Vibrate; không có class Camera_manager. Camera = Follow gán cứng; Eco mode tắt hẳn GameObject camera (Setting.EcoMode_Change 0x2ACEA18) và thay bằng panel tĩnh.
  12. Rebirth là một "cảnh chuyển": Gamemanager.is_gaming = false → bật object FX + SfxType.Rebirth (etfx_explosion_chargeup2) → animation của object gọi Rebirth()Rebirth_manager.Rebirth() + đóng mọi panel. Cổng: Stage_Now > 1, không thì Alert Rebirth/Rebirth_Warn.

7.3 Gap còn mở của lane này

  • Dia_Attraction.Dia_Spawn: SOT gọi ParticleImage.EmitAtWorldPosition — method có trong bản ship nhưng không có trong UI Particle Image 1.2.0 (project) lẫn 1.2.2 (bản sở hữu). Thân để trống, xem gap list (nội bộ) G1503.
  • Rebirth_manager.Rebirth / UpgradeRebirth_Start_By_Ad / By_IAP, Setting.SFX_Setting_Change / Vibration_Setting_Change / EcoMode_Change còn là stub ở lane codegen (G1505).