07-juice-game-feel.md · Cập nhật lần cuối: 2026-09-05 08:45 +07 · sha256 nguồn 00016985245107 — Juice & game feel
Nguồn framework:
docs/framework/06-juice-game-feel.mdtoàn bộ;docs/framework/01-economy-money.md§3.5.1 (chữ HOA / chữ thường của đơn vị). 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 game idle "đã tay" — quái nảy khi trúng đạn, panel bật ra có độ nảy, số sát thương vỡ tung, thanh máu chạy mượt mà không giết frame-rate — thì làm theo cách CatGunner đã làm: nhiều component nhỏ (~20 dòng mỗi cái) gắn ở rất nhiều chỗ, chứ không phải một hệ thống animation lớn. Bằng chứng đo được: 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. Trang này liệt kê từng kỹ thuật, kèm hằng số thật, và chỉ rõ cái nào rẻ, cái nào đắt.
Game gốc làm gì
| Điều game làm | Con số / hằng số | Mức bằng chứng | Nguồn |
|---|---|---|---|
Squash-stretch khi trúng đòn bằng Bounce_Custom — component đông nhất game |
1 939 instance trong scene | scene-census | doc 06 §2.1; [index: scene_census.json] |
Thời gian nảy _upSizeTime = 0.2f, mysize = Vector3.one (mặc định .ctor) |
0.2 s | disasm-proven + runtime-verified | .ctor RVA 0x2A695D8; field-delta oracle |
Awake → Size_reload() đặt HalfTime = _upSizeTime * 0.5f một lần, có cờ is_sizeReloaded |
HalfTime 0 → 0.1 | disasm-proven + runtime-verified | RVA 0x2A6942C / 0x2A69454 |
OnEnable đặt Bouncing = false, localScale = mysize (reset khi lấy từ pool) |
— | disasm-proven + runtime-verified | RVA 0x2A6947C |
Bounce_Start() đặt time = 0, thoát sớm nếu đang nảy, rồi StartCoroutine nếu activeInHierarchy |
— | disasm-proven + runtime-verified | RVA 0x2A69540 |
Đường cong: nửa đầu Lerp(mysize, WantSize, time/HalfTime), nửa sau Lerp(WantSize, mysize, …), quá hạn reset |
50/50 | 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 |
Pop-in BounceStart (441 instance): WantSize = 1.1f, _upSizeTime = 0.3f, Middle_value = 0.6f |
0.3 s, 1.1, 0.6 | disasm-proven | .ctor RVA 0x2A68EB8 |
Size_reload(): First_Time = 0.3 × 0.6 = 0.18 s, Second_Time = 0.12 s |
60 % / 40 % | disasm-proven | RVA 0x2A68DB4 |
Chuỗi tween pop-in: scale = one × 0.001 → vọt lên WantSize trong First_Time → lắng về 1.0 trong Second_Time |
điểm xuất phát 0.001 | disasm-proven | Coroutine_NoRepeat RVA 0x2A68E24, MoveNext 0x2A68EDC |
BounceStart khởi động ở OnEnable — mọi lần bật panel đều pop-in |
— | disasm-proven | RVA 0x2A68DDC |
BounceBtn (679 instance) là biến thể cho nút: OnPointerDown / OnPointerUp / Btn_Down_Coroutine |
Btn_Down_Coroutine 62×, OnPointerDown 8× |
metadata-proven + runtime-observed | runtime_ranking.json |
Họ component nảy có 5 biến thể: Bounce_Custom, BounceStart, BounceBtn, Bounce (5), Bounce_Position (6) |
— | scene-census | scene_census.json |
Thanh máu rẻ SpriteFill (1 931 instance): mã hoá tỉ lệ máu vào kênh đỏ _sprite.color = new Color(value, 1, 1, 1) |
1 931 | disasm-proven + runtime-verified | RVA 0x2A52BF4 |
SpriteFill.Value_Reload 202× (≈ số lần trúng đòn), Awake 500×, .ctor 1 931× |
— | runtime-observed | runtime_ranking.json |
Enemy.Slider_Reload(): ratio = (float)(Health_Now / Health_Max) → Slider.Fill.Value_Reload(ratio) |
— | disasm-proven | RVA 0x2AFF108 |
Boss không dùng Enemy_Slider; đi qua Boss_manager.instance.gameSlider.Value_Reload(Health_Now, Health_Max) |
— | disasm-proven | Enemy.Slider_Reload RVA 0x2AFF108 |
Boss_Slider có 2 thanh (Value_Fill, Time_Fill) + 2 text |
— | metadata-proven | dump.cs:593885 |
Thanh máu đắt SlicedFilledImage: class chạy nhiều nhất toàn game |
169 246 lời gọi / 40 s, 44 method, 93 instance | runtime-observed + scene-census | runtime_ranking.json |
SlicedFilledImage là source MIT (hiryma / Unity UI Extensions) vendor trong Assembly-CSharp, không phải package user sở hữu |
— | package-source | G128 gap list (nội bộ) |
5 đường sinh text nổi: Money_Txt_Spawn, Dia_Txt_Spawn, Dia_Devide_Txt_Spawn, Damage_Txt_Spawn, Critical_Txt_Spawn |
— | metadata-proven | dump.cs:583820 (RVA 0x2A4EF48, 0x2A4F498, 0x2A50C9C, 0x2A50594, 0x2A506AC) |
Cổng chống spam CanSpawnText(Vector3, float cooldown) + coroutine CleanupTextCooldowns |
CanSpawnText 287× |
metadata-proven + runtime-observed | RVA 0x2A507F4 |
Cooldown theo ô lưới Dictionary<Vector2Int, float> _textCooldowns, BUCKET_SIZE = 1.5 |
1.5 | metadata-proven | dump.cs:583820 |
Không có camera shake / hit-stop / zoom / timeScale trong game ship — bằng chứng phủ định |
0 class | disasm-proven (negative) | doc 06 §2.5 (G410) |
Thân CanSpawnText (G402): ô = (round(x/1.5), round(y/1.5)) — chỉ X và Y, làm tròn ngân hàng (không Floor); từ chối nếu ô đóng dấu < cooldown trước, ngược lại đóng dấu Time.time; cooldown tại nơi gọi: tiền 0.2 s, sát thương/chí mạng 0.12 s |
1.5 / 0.2 / 0.12 | disasm-proven | doc 06 §2.4 (G402) |
DAMAGE_TEXT_COOLDOWN = 0.12, CRITICAL_TEXT_COOLDOWN = 0.12, MONEY_TEXT_COOLDOWN = 0.2 |
0.12 / 0.12 / 0.2 s | metadata-proven | dump.cs:583820 |
Damage_Txt_Spawn 202× cho 207 phát đạn ⇒ throttle thật sự loại bớt |
202 / 207 | runtime-observed | runtime_ranking.json |
DamageTextAnimationCurve nướng 4 AnimationCurve (movementX/Y, alpha, scale) thành float[] |
BAKE_SAMPLES = 64 |
metadata-proven | dump.cs:584171 |
DamageTextAnimation.DistributeMovementX(firstEnd = 400, lastEnd = -400, t0 = 0, t1 = 1) trải chữ số ra hai bên |
400 / −400 | metadata-proven | RVA 0x2A5304C |
Ore rơi dia dùng literal cứng "+0.1" / "+1" |
— | disasm-proven | Ore.Die_Reload RVA 0x2AF76E4; literal 0x5E81500 / 0x5E81508 |
Knockback chỉ áp cho Enemy.Type.Monster; cường độ damageRatio = (float)(Damage / Health_Max) |
mẫu số là Health_Max | disasm-proven | Enemy.Damaged RVA 0x2AFEF9C |
Monster.Apply_Knockback → Knockback_Coroutine, giữ handle _knockbackCoroutine để huỷ chồng |
— | metadata-proven | dump.cs:594503 (RVA 0x2AF7D70 / 0x2AF7DE8) |
Scrolling (38 instance) cuộn RawImage.uvRect theo Vector2 Speed mỗi frame |
5 104× / 40 s | disasm-proven + runtime-observed | RVA 0x2AD4EB0 |
Hatch (44 instance) lát UV theo kích thước RectTransform chia Hatch_Size |
— | disasm-proven | RVA 0x2AD49A4 |
Follow (7 instance) gán cứng transform.position = Target_trans.position — không làm mượt |
— | disasm-proven | RVA 0x2AC6860 |
Cùng giá trị 3.12 × 10³ hiển thị 3.12A (text bay trong thế giới) và 3.12a (panel/HUD) |
HOA vs thường | observed | [device evidence: user_screenshot_stage5-4.png] (sha256 04ef8221…); doc 01 §3.5.1 |
4 font Damage, Damage (Cri), Dia, Money_Cri ship với m_FontData độ dài 0 |
thật sự rỗng | asset-census | [report: asset_lane.md] §5 |
Luật game feel đọc trọn từ binary (đợt 12, G410 — chủ đề ưu tiên của user)
Tám hệ nhỏ làm nên cảm giác "đã tay" của game — tất cả đều đã dựng lại và chạy trên bản học; giá trị là của game gốc:
| Hệ | Luật (disasm-proven) | Số |
|---|---|---|
Âm thanh — một cửa PlaySfx |
thứ tự cổng: is_SFX_Mute → có clip? → trùng cùng frame theo từng loại → < 0.05 s theo từng loại → volume = ghi đè ?? volume của hàng |
cooldown 0.05 s |
| Volume theo hàng | Shoot 0.3 · Fusion (ghép súng) 0.4 · Button 0.5 · còn lại 1.0; pitch KHÔNG BAO GIỜ biến tấu | 4 mức |
| Nhạc nền | mute = volume 0 (không dừng); đổi track chỉ khi clip khác; 15 clip + 2 BGM byte-identical, không mixer | 17 file |
FX trúng — FXThrottle |
một bản ghi mỗi khẩu (65); tối đa 3 FX trúng / frame; theo súng chỉ từ chối khi cả < 0.05 s và < 1.5 đơn vị so với lần trước | 3 / frame |
| Squash & stretch | kẻ địch trúng đòn 0.2 s, (1.1, 0.9, 1) rồi (0.9, 1.1, z); capsule HUD 0.9 / 0.2 s; pop-in panel 0.3 s |
0.2 s |
Nút bấm (BounceBtn, 679 nút) |
co về 0.9 trong 0.1 s rồi GIỮ khi đang nhấn, nhả mới bung | 0.9 / 0.1 s |
| Giật lùi súng | Bounce_Position: local X −0.6 mỗi phát, hồi trong 0.2 s |
−0.6 |
| Dia bay về ví | điểm hút = capsule mới bật gần nhất; tới nơi: sfx Tick + squash capsule; 8 hạt UI, 0.3 s |
8 / 0.3 s |
| Số bay | lưới 1.5 đơn vị, X/Y, làm tròn ngân hàng; 0.2 s tiền / 0.12 s sát thương (mục trên) | 1.5 |
| Rung | 30 ms, biên độ 100, chỉ Android API ≥ 26, chỉ khi thả súng vào ô ghép | 30 ms |
| KHÔNG CÓ | camera shake, zoom, hit-stop, timeScale — không class nào tồn tại; camera Follow gán cứng; eco mode tắt hẳn GameObject camera |
0 |
Điều đáng học nhất là dòng cuối: một game idle thương mại "đã tay" mà không dùng camera shake hay hit-stop. Toàn bộ cảm giác đến từ
squash 0.2 s + số bay + hạt + âm thanh có volume theo hàng + rung đúng một chỗ. Nếu framework của bạn thêm shake/hit-stop, đó là lựa chọn
của bạn — không phải "chuẩn" của thể loại. Ngược lại, hai thứ bắt buộc mà bản dựng lại từng thiếu và game câm lặng: FXThrottle trả false
⇒ không FX trúng nào; CanSpawnText trả false ⇒ không số bay nào — hai stub im lặng giết "đã tay" mà không ném gì.
Công thức / pseudocode
Nảy trúng đòn (Bounce_Custom) — L1 ANALYSIS, RVA 0x2A694AC / 0x2A69650:
// .ctor: _upSizeTime = 0.2f; mysize = Vector3.one (RVA 0x2A695D8)
// Awake -> Size_reload(): HalfTime = _upSizeTime * 0.5f (RVA 0x2A6942C)
// OnEnable: Bouncing = false; localScale = mysize (RVA 0x2A6947C)
public void Bounce_Start() // RVA 0x2A69540
{
time = 0f;
if (Bouncing) return; // đang nảy -> CHỈ reset đồng hồ
if (gameObject.activeInHierarchy) StartCoroutine(Coroutine_NoRepeat());
}
while (Bouncing)
{
yield return null;
time += Time.deltaTime;
if (time <= HalfTime)
transform.localScale = Vector3.Lerp(mysize, WantSize, time / HalfTime);
else if (time <= _upSizeTime)
transform.localScale = Vector3.Lerp(WantSize, mysize, (time - HalfTime) / HalfTime);
else { time = 0f; Bouncing = false; transform.localScale = mysize; }
}
Pop-in (BounceStart) — L1 ANALYSIS, RVA 0x2A68DB4 / 0x2A68E24:
_upSizeTime = 0.3 s, Middle_value = 0.6, WantSize = 1.1
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)
khởi động ở OnEnable
Thanh máu rẻ (SpriteFill) — L1 ANALYSIS, RVA 0x2A52BF4, runtime-verified:
public void Value_Reload(float value)
{
if (_sprite == null) _sprite = GetComponent<SpriteRenderer>(); // fallback trước Awake
if (_sprite != null) _sprite.color = new Color(value, 1f, 1f, 1f); // tỉ lệ máu vào kênh ĐỎ
}
// shader đọc color.r và cắt sprite — không mesh, không Canvas bẩn, không layout
Throttle text theo ô lưới (Gamemanager) — hằng số metadata-proven, thân CanSpawnText đã dựng (G402):
static readonly Dictionary<Vector2Int, float> _textCooldowns; // dump.cs:583820
const float BUCKET_SIZE = 1.5f;
const float DAMAGE_TEXT_COOLDOWN = 0.12f;
const float CRITICAL_TEXT_COOLDOWN = 0.12f;
const float MONEY_TEXT_COOLDOWN = 0.2f;
static bool CanSpawnText(Vector3 worldPos, float cooldown); // RVA 0x2A507F4 — G402: key = Vector2Int(Math.Round(x/1.5), Math.Round(y/1.5)); z bỏ; đóng dấu Time.time
IEnumerator CleanupTextCooldowns(); // dọn ô cũ, chống rò rỉ
Nướng đường cong (DamageTextAnimationCurve) — metadata-proven, dump.cs:584171:
public AnimationCurve movementX, movementY, alpha, scale; // designer chỉnh trong Editor
public const int BAKE_SAMPLES = 64;
public bool baked;
public float[] bakedMovementX, bakedMovementY, bakedAlpha, bakedScale;
public void Bake(); // nướng một lần
public static float EvalBaked(float[] baked, float t); // tra bảng + nội suy tuyến tính
Knockback — L1 ANALYSIS, Enemy.Damaged RVA 0x2AFEF9C:
if (_type == Type.Monster)
{
float damageRatio = (float)(Damage / Health_Max); // chia cho Health_MAX, không phải Health_Now
Monster m = GetComponent<Monster>();
if (m != null) m.Apply_Knockback(damageRatio); // huỷ _knockbackCoroutine cũ trước
}
Chuyển động nền — L1 ANALYSIS, Scrolling RVA 0x2AD4EB0, Hatch RVA 0x2AD49A4:
// Scrolling: nền cuộn vô hạn, chỉ đổi uvRect
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: mật độ hoa văn không đổi theo pixel dù RectTransform to nhỏ khác nhau
img.uvRect = new Rect(0f, 0f, rect.rect.width / Hatch_Size, rect.rect.height / Hatch_Size);
// Follow: cố tình KHÔNG lerp — thanh máu trên đầu quái phải dính chặt
private void Update() { transform.position = Target_trans.position; }
Gợi ý framework (không phải bằng chứng SOT): một interface IProgressBar.SetValue(float) với hai hiện thực (vertex-color rẻ / 9-slice đắt); kiểu HysteresisThreshold; giữ AnimationCurve cho Editor và float[N] cho runtime.
Bẫy đã gặp
- Xếp chồng nảy. Nếu
Bounce_Startkhông thoát sớm khi đang nảy, bắn liên thanh sẽ tạo nhiều coroutine tranh nhau ghilocalScale. SOT chỉ reset đồng hồ (time = 0rồireturn) — disasm-proven RVA0x2A69540. - Object từ pool mang scale dở dang.
OnEnablephải reset vềmysize(RVA0x2A6947C). Đây là hợp đồng pooling (xem 09-vfx-pooling.md) áp dụng cho juice. - Dùng một giải pháp thanh máu cho mọi chỗ.
SlicedFilledImagedựng lại tessellation 9-slice mỗi lầnfillAmountđổi (31 620GenerateFilledSprite, 4 739OnPopulateMeshtrong 40 s). Dùng nó cho 1 931 quái là tự sát về hiệu năng; SOT chỉ dùng cho 93 thanh UI. G126/G128. - Hand-reconstruct
SlicedFilledImage. Nó là source MIT thượng nguồn — per R2 phải lấy từ upstream, không dựng lại tay 44 method (G128). - Knockback chia cho
Health_Now. Quái sắp chết sẽ bị bắn văng kỳ quặc. SOT chia choHealth_Max(RVA0x2AFEF9C). - Throttle text theo đối tượng thay vì theo vùng màn hình. Vấn đề thị giác là mật độ text trong một vùng, nên SOT băm theo ô lưới
BUCKET_SIZE = 1.5. Không có coroutine dọn dẹp thì dictionary rò rỉ một entry mỗi ô người chơi từng đi qua. - Gọi
AnimationCurve.Evaluate()trên hot path. Hàng chục text × 4 curve × mỗi frame xuất hiện trong profiler; SOT nướng 64 mẫu và tra bảng. - Giả định một formatter duy nhất. Ảnh chụp save thật cho thấy
3.12A(world) và3.12a(HUD) cho cùng giá trị; đường code viết HOA là UNKNOWN-pending (doc 01 §3.5.1). Một bản restore chỉ dùngInfVal_change()sẽ hiển thị sai kiểu chữ ở text bay. - Mang 4 file
.ttf0 byte vào project.[SOT: DevXProject]từng ghi 4 font rỗng — đó là file bịa; SOT thật sự shipm_FontDatađộ dài 0 (G012). - Làm mượt thứ phải dính chặt.
Followgán cứng vị trí; bất kỳ lerp nào trên thanh máu đầu quái trông như lỗi.
Checklist khi tự làm
- [ ] Có một component squash-stretch nhỏ (
upSizeTime,wantSize: Vector3) với quy tắc "đang nảy thì chỉ reset đồng hồ". - [ ]
OnEnablecủa mọi component juice reset về trạng thái xuất xưởng (pool contract). - [ ] Bốn biến thể riêng cho bốn ngữ cảnh: trúng đòn / pop-in / chạm nút / theo vị trí — không có component "làm tất cả".
- [ ] Pop-in chia cửa sổ không đều (60/40) và xuất phát từ 0.001, không phải 0.
- [ ] Hai tầng thanh tiến độ: vertex-color cho hàng nghìn mục tiêu trong thế giới, 9-slice mesh cho vài chục thanh UI.
- [ ] Boss có thanh riêng (máu + thời gian), không đi chung đường với quái thường.
- [ ] Text nổi: throttle theo ô lưới không gian, cooldown riêng theo loại text, có coroutine dọn dẹp.
- [ ]
AnimationCurveđược nướng thànhfloat[64]với cờbaked; chỉ nướng một lần. - [ ] Chữ số bay tản ra hai bên (
DistributeMovementX) +spriteGap. - [ ] Knockback =
damage / maxHealth; lưu handle coroutine để huỷ cái cũ. - [ ] Nền chuyển động bằng
uvRect; hoa văn chia kích thước cho hằng số để mật độ không đổi. - [ ] Thứ phải dính chặt thì gán cứng, không lerp.
- [ ] Nếu có hai kiểu chữ đơn vị (HOA/thường), khai báo tường minh đường nào dùng kiểu nào và có test parity.
Điều chưa biết (UNKNOWN)
- Tween mỗi frame của
Bounce_Custom.Coroutine_NoRepeat: đã chạy (3 997×), giá trịlocalScaletừng frame CHƯA đối chiếu. Khoá bằng oracle đọclocalScalesau mỗi frame trong một chu kỳ nảy (doc 06 §5). BounceStart.Coroutine_NoRepeat: disasm-proven, chưa quan sát chạy trong cửa sổ trace. Khoá bằng cách mở/đóng panel cóBounceStartkhi đang hook.SlicedFilledImage(44 method): CỐ Ý KHÔNG dựng lại tay (G128, R2) — lấy từ upstream MIT.Boss_Slider.Value_Reload/Time_Reload: CHƯA dựng lại — chặn bởi khoảng cách version InfVal (G007a).DamageTextAnimation.InitDamageTextAnimation/DistributeMovementX: CHƯA dựng lại — chữ ký + mặc định (400 / -400 / 0 / 1) đã biết; thân thì chưa (RVA0x2A52FC0/0x2A5304C).DamageTextObject: UNKNOWN — kiểu tham số, chưa trích.- ~~
CanSpawnText~~: ĐÃ DỰNG (G402) — bămMath.Round(x/1.5), Math.Round(y/1.5)(X/Y, làm tròn ngân hàng, khôngFloor, z bỏ). Stubreturn falsecủa nó từng tắt mọi số bay. Monster.Knockback_Coroutine(RVA0x2AF7DE8): CHƯA dựng lại — đường cong / khoảng cách / thời gian UNKNOWN.- Giá trị
WantSizethực tế của 1 939Bounce_Custom: UNKNOWN — dữ liệu inspector trongMain.unity, chưa trích; mặc định.ctorlà(0,0,0)nên mọi instance phải được đặt trong scene. - Đường code viết HOA đơn vị ở text bay (
3.12A) vs thường ở HUD (3.12a): UNKNOWN-pending — hiện tượng xác nhận bằng ảnh chụp; ứng viênDamage_Txt_Spawn0x2A50594,Critical_Txt_Spawn0x2A506AC,Money_Txt_Spawn0x2A4EF48, hoặc.ToUpper()trongDamageTextAnimation. Tiêu chí nhiều khả năng là "text bay trong thế giới vs text HUD" — vẫn là suy luận (doc 01 §3.5.1; backlog T401). - Không quan sát thấy DOTween 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: UNKNOWN.
- Không quan sát thấy screen-shake, hit-stop hay time-scale trong các class đã khảo sát. Nếu có, chúng nằm ở nơi chưa khảo sát (cần grep
Time.timeScaletrongdump.cs).
Đọc thêm
docs/framework/06-juice-game-feel.md— nguồn chính, đầy đủ RVA và số lần gọidocs/framework/01-economy-money.md§3.5 — notation hiển thị và phát hiện HOA/thườngdocs/framework/03-particles-vfx.md§3.4 — hợp đồng pooling mà juice phải tuândocs/framework/02-combat-projectiles.md— nơiEnemy.DamagedgọiBounce_Startvà knockbackgap list (nội bộ)— G007a, G012, G126, G128