06-juice-game-feel.md · Cập nhật lần cuối: 2026-09-05 08:45 +07 · sha256 nguồn 211692812e7106 — Juice & Game Feel
Nguồn: chưng cất từ restore SOT
com.Chodun.CatGunner1.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.2f và mysize = Vector3.one là giá trị mặc định trong .ctor — đã xác minh runtime |
disasm-proven + runtime-verified | .ctor RVA 0x2A695D8; field-delta oracle |
Awake → Size_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 = false và localScale = mysize — runtime-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 activeInHierarchy — runtime-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.1 — co 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: VibrationHelper — 30 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_Time → scale = 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 null là StartCoroutine(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 và _type == BossMonster, nó gọi Boss_manager.instance.gameSlider.Value_Reload(Health_Now, Health_Max) |
disasm-proven | Enemy.Slider_Reload RVA 0x2AFF108 |
Boss_Slider có hai 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-CSharp — khô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 |
|---|---|---|
Có 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: _lastForceCheckRealtime và LastSaveRealtime = 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_Cri có m_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_Spawn và Dia_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 Update — khô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òng và một hằng số: 0.2 giây.
Điểm thiết kế đáng học:
Bounce_Startthoát sớm nếu đang nảy (if (Bouncing) return;sau khi đặttime = 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í.OnEnablereset 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.HalfTimetính một lần với cờis_sizeReloaded, không tính lại mỗi lần nảy.WantSizelàVector3, không phảifloat— 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ữ
AnimationCurvecho designer chỉnh, nướng thànhfloat[]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:
- Cooldown khác nhau theo loại, không phải một hằng số chung. Damage/crit
0.12 s; tiền0.2 s(text tiền sống lâu hơn và bay xa hơn nên dễ chồng hơn). - 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.
- Style key là chuỗi, và mỗi key trỏ tới một
DamageTextAnimationriê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
-
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. -
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).
-
Nướng
AnimationCurvethànhfloat[N]cho runtime. Giữ curve cho Editor, dùng bảng cho chạy.N = 64là con số đã ship. Cờbakedđể nướng một lần. -
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/crit0.12 s, tiền0.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.
-
Cường độ phản hồi tỉ lệ với
damage / maxHealth, không phảidamage / 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". -
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. -
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. -
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.
-
Text nổi trải các chữ số ra hai bên (
DistributeMovementX) +spriteGap— hiệu ứng "số vỡ ra". -
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 là (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 có 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ọiBounce_Start[disasm: Bounce_Custom.asm], BounceStart.asm, SpriteFill.asm}[disasm: leaf_Scrolling.c], leaf_Hatch.c, leaf_Follow.c}dump.cs:584171/:584201—DamageTextAnimationCurve/DamageTextAnimationdump.cs:593885—Boss_Sliderdump.cs:591085—SlicedFilledImage(44 method)dump.cs:583820— hằng số throttle text trongGamemanager[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 đã shipdump.cs:594503—Monster.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.WantSizethự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 và 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
- Hai kiểu "re-arm" coroutine cho cùng một tween.
Bounce_Custom/Bounce_Positionchạywhile (Bouncing) { yield return null; … }trong MỘT coroutine;BouncevàBounceBtnlàm một bước rồi tự gọi lại (StartCoroutine("Coroutine_NoRepeat")theo tên ởBounce,StartCoroutine(Btn_Down_Coroutine())ởBounceBtn). Kết quả nhìn giống nhau; kiểu tự-gọi-lại cho phépOnPointerUpchỉ đổi cờis_Pressedvà 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). - 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ả.
OnDisabletrả scale gốc nếu đã capture (mysize_x != 0). - 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.
- 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.
- 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ùngunscaledTimenên không phụ thuộctimeScale. Pitch được đặt trên source dùng chung rồi trả lại ngay sauPlayOneShot(PlayOneShot lấy pitch tại thời điểm gọi). - 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.
- 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. - 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.
- 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.
- Dia bay về HUD dùng attractor của
ParticleImage, target là capsule mới bật gần nhất (danh sáchTarget_list, lấy phần tử cuối); mỗi hạt về đích bắnTick(đã được cooldown 0.05 s lọc) và squash capsule. Không target → không emit. - Không có screen-shake, hit-stop, hay
timeScalenào trong game (đã đóng UNKNOWN §5):Time.set_timeScalechỉ được ACTk SpeedHackDetector và FacebookOnHideUnitygọi; không cóHandheld.Vibrate; không có class Camera_manager. Camera =Followgán cứng; Eco mode tắt hẳn GameObject camera (Setting.EcoMode_Change 0x2ACEA18) và thay bằng panel tĩnh. - 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ọiRebirth()→Rebirth_manager.Rebirth()+ đóng mọi panel. Cổng:Stage_Now > 1, không thì AlertRebirth/Rebirth_Warn.
7.3 Gap còn mở của lane này
Dia_Attraction.Dia_Spawn: SOT gọiParticleImage.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, xemgap list (nội bộ)G1503.Rebirth_manager.Rebirth / UpgradeRebirth_Start_By_Ad / By_IAP,Setting.SFX_Setting_Change / Vibration_Setting_Change / EcoMode_Changecòn là stub ở lane codegen (G1505).