Tách InputFilter khỏi Handlers trong xử lý multiplatform

input_multiplatform

Cái tiêu đề nghe có vẻ ngộ ngộ, cái này là một kinh nghiệm mình thu thập được trong quá trình chạy các project trên nhiều platform (PC, Mobile, Steam). Một đặc điểm dễ thấy ngay trong xử lý multiplatform là Input, sẽ khác nhau tùy platform, như PC (keyboard, mouse), Mobile (touch, sensor), Steam (Keyboard, GamePad …), cho đến cả các thể loại của VR như Oculus (Tracking controller ..v.v.).

Và những đề bài về xử lý input cho multiplatform, chắc các bạn cũng nghe nhiều rồi, ví dụ làm hệ thống điều khiển nhân vật, bao gồm các hành động : di chuyển, nhảy, tấn công, yêu cầu cần chơi được trên các platform phổ biến như :
– PC : (bao gồm cả việc testing trên Editor hé), sử dụng keyboard, với các key A,D để di chuyển, W để nhảy và Space để tấn công
– Steam : hỗ trợ cả GameController (GamePad), có thể sử dụng 4 nút direction hoặc joytick để di chuyển, bấm nút X,Y gì đó trên GamePad để nhảy và tấn công.
– Mobile : control bằng Virtual GamePad trên màn hình (nói toẹt ra là đống BUtton trên Canvas é). Hoặc có thể switch qua dùng Accelerometer để di chuyển =)))

InputFilter và InputHandler

Mình giải thích hai khái niệm này bằng một ví dụ đơn giản hế. Vẫn cái đề bài về điều khiển nhân vật ở trên, bên dưới là một đoạn code demo ngắn về cách implement :

Về cơ bản thì có 2 loại tác vụ trong việc xử lý Input :

InputFilter

Là component (hoặc ở trên là đoạn code) đảm trách việc kiểm tra xem input của user có thõa mãn những điều kiện đặt ra hay không. Nếu thõa mãn, thì gọi thành phần Handler để thực thi một số phương thức nào đó.

Ex: như ở trên, các đoạn code if(Input.GetKey(…)) chính là để kiểm tra xem, người chơi có đang nhấn các phím di chuyển, nhảy, tấn công .. hay không. Nếu là trên Mobile thì đoạn code trên có thể khác chút, sử dụng VirtualPad.IsUserPressLeftButton() chẳng hạn, hoặc nếu ai làm với Oculus có thể sẽ là OVRInput.Get(OVRInput.Button.One) chẳng hạn.

InputHandler

Component nhận request từ InputFilter để thực thi một số phương thức nào đó. Như ở vd trên thì nó là các hàm Move(), Jump, Attack() đấy.

Tại sao phải tách riêng 2 thành phần này ?

input_handler_relation

Xét về chức năng thì thành phần Handler không cần biết Input làm cái gì, đang ở platform nào, vì nhiệm vụ chính của Handler là nhận command rồi điều khiển một số thành phần khác (Rigidbody, Transform chẳng hạn) để đáp ứng lại input, và thường thì interface của Handler khá đơn giản (như ở trên, chỉ bao gồm Move(), Jump(), Attack()).

Vì yêu cầu của Handler không nhiều, nên InputFilter làm gì không cần biết (GetKey, OVR .v.v.), chỉ cần đưa “đầu ra” gồm một số event để Handler biết mà thực thi (như ở trên là 3 event tương ứng cho 3 hàm thực thi Move, Jump, Attack).

Khi đã phân tách ra thành 2 loại “trách nhiệm” như trên, bạn hoàn toàn viết được 2 component. Nếu có thay đổi về platform (API thay đổi, hoặc yêu cầu support thêm platform), bạn chỉ cần chỉnh sửa trong InputFilter, còn Handler thì không cần thay đổi. Dưới đây là một đoạn sample implement cực đơn giản (InputFilter và Handler nằm cùng Object hé, cho đơn giản) :

Những ứng dụng khác khi phân tách InputFilter khỏi Handler

Tham gia trong quá trình implement AI

input_handler_ai

Nếu bạn nào từng viết về AI nói chung, thì về cơ bản sẽ có 2 loại “trách nhiệm”:
Kiểm tra các điều kiện (gần hết máu chưa? gần hết đạn chưa? địch có đang đến gần không? địch có đang … quá mạnh không? .v..v..)
Thực thi các hành vi (chạy đi tìm máu, chạy đi tìm đạn, đi săn lùng kẻ địch, bỏ chạy để bảo toàn tính mạng =))) )

Và thường thì thành phần Handler của cả Human và AI là giống nhau !!. Như các bạn thấy ở trên đấy, dù có là AI, nó tính toán gì đi nữa, thì cũng sẽ gọi đến Handler để thực hiện các tác vụ về di chuyển, tấn công (Move, Attack ..), trường hợp Human thì chỉ khác là ông Handler nhận input từ devices chớ không phải được tính toán bởi AI.

NOTES : vào thực tế dự án thực tế, có thể có vài loại InputFilter như MovementInputFilter, WeaponInputFilter …. và cũng sẽ có nhiều loại Handler nữa nhé, MovementHandler, AttackHandler, ChatHandler .v.v… Các ví dụ mình chỉ viết đơn giản nhất có thể cho dễ hiểu hé.

Implement tính năng Replay

Kịch bản quen thuộc thế này : bạn đang làm game multiplayer, nhiều người đấu với nhau, sau khi bị kill hoặc là kill thằng khác, ta hay thấy các game replay lại đoạn đó “coi chơi”. Vậy giải pháp thế nào ?

Có thể ngay trong đầu vài bạn nghĩ ngay tới cách sau : cho một component riêng cho việc “record” này, trong Update sẽ lưu hết thông tin về position, rotation .v..v.. vào một List. Đến khi replay thì cũng trong Update, sẽ dùng nó để “set” ngược lại cho character. Đây là theo “kiểu” lưu state. Tất nhiên là cách này thì memory nào chịu cho nổi, và …….. làm gì có ai xài 😛 .

Thay vào đó, người ta lưu command, tức là cũng lưu vào một List, nhưng là theo kiểu : thời điểm t1 user thực hiện action Jump, thời điểm t2 user thực hiện action Attack …, và đến khi replay, chỉ có thay cái component InputFilter bằng cái … “InputRecorder” này, còn Handler thì không đổi, chỉ có giờ Handler nhận command từ Recorder chớ không phải từ InputFilter như lúc đầu. Replay cho AI hay cho Human thì cũng dựa trên nguyên tắc chung đó.

Chuyện implement : làm sao liên lạc giữa thằng InputFilter và Handler ?

Cái này là quay về chuyện : làm thế nào để Handler nhận được input event từ InputFilter ? Như cái sample code mình có viết ở trên, thì cái InputFilter với cái Handler nó nằm trong cùng object. Và “liên lạc” với nhau theo kiểu : ông Handler GetComponent ông InputFilter, rồi register mấy cái event linh tinh đó. Kiểu đó demo gọn cho dễ hình dung thôi.

Áp dụng ObserverPattern cho Handler, dùng nhiều InputFilter cho từng platforms

Vào dự án thực tế thì ……. tùy. Vd trên là đơn giản, chỉ có một Handler, nên mình đặt luôn InputFilter và Handler vào một object. Thực tế có thể có nhiều handler, ngay như trong vụ Replay chẳng hạn, ngoài player còn phải có 1 object đăng kí nhận Input để record lại. Trường hợp này có thể đặt một GameInputManager, theo singleton, interface (ý mình interface là giao diện sử dụng, tức mấy hàm public, hoặc event á) sẽ có các event (như OnUserPressAttack .v.v..), rồi, cứ thế, Handler nào cần thì register (theo kiểu ObserverPattern đấy). VÀ, sẽ có một số InputFilter, “lo” việc lấy input từ device (PCInputFilter lo việc lấy input từ keyboard, MobileInputFilter lo việc lấy input từ Canvas .v.v…), để “chuyển vào” GameInputManager, và rồi Manager sẽ invoke cho các Handler có đăng kí lắng nghe. Trình bày gọn thế chắc ai cũng hiểu rồi hé.

[UPDATE]

Chả là có bạn hỏi mình viết chi tiết thêm phần này chút, nên mình diễn giải linh ta linh tinh ra thêm vài dòng :v. ĐƯợc rồi, đây là hướng tư duy của mình, khi áp dụng trong mấy project trước, ở đây ta sẽ có 3 role chính :

InputHandler_sample

InputFilter : xét cái PCInputFilter hé, một component bình  thường, có một hàm Update(), trong đó sẽ có mấy lệnh quen thuộc như if (Input.GetKey(…)), sau đó nó invoke cho ông InputManager, theo kiểu “Ê, user bấm nút nhảy nè” , “Ok, để tao báo cho mấy thằng có lắng nghe cái input này”. Nếu là MobileInputFilter, nó sẽ là một component gắn ở Canvas có các virtual button trên đó, nó sẽ theo dõi các sự kiện OnPress, OnDrag .v.v. trên đám Button, Image gì gì đó của canvas, rồi cũng invoke lại cho InputManager.
InputManager : singleton, có một số public event như OnUserPressJump, Attack .v.v.., để các ông Listener có thể vào “đăng kí” nghe. Và một số hàm public để nhận invoke từ InputFilter.
– Listener : là những nơi muốn nhận các sự kiện Input này. Trong Start() , sẽ có các lệnh đại khái như : InputManager.instance.OnUserPressJump += HandleUserPressJump();
Code minh họa thế này :

[?] Vì sao gọi thế này là áp dụng ObserverPattern ?
Vì nó đi theo mô hình Invoker -> Dispatcher -> Listener. InputManager là Dispatcher đấy.
[?] Vì sao lại có trường hợp nhiều listener ?
Cái này thì nhiều lắm, ngay như ở trên mình có nói về việc implement tính năng Replay, cái thằng InputRecorder chính là một listener đấy. Hoặc thế này, trong game BattleField, khi mình thả drone đi do thám, lúc đó cái object Drone sẽ nhận Input để di chuyển, còn thằng người chơi chính thì đứng lại, lúc đó Drone là listener đấy…vv…

Sử dụng interface hoặc UnityEvent để truyền command cho Handlers

Về cá nhân mình thì nghĩ thế này. Handler là một component với interface đơn giản (vài hàm public như Move, Attack …), nó chỉ có việc nhận command rồi thực thi, hoạt động tương đối độc lập khỏi input, cho nên tốt nhất là không để nó bị coupling với các thành phần về input (như ở trên, Handler phải “tự” GetComponent cái InputFilter, không hay lắm). Vì thế, nên có một InputCommandTransfer component, đưa input vào Handler.

Vì các “loại” command từ Input thường không quá nhiều (Move, Attack, Jump …), nên có thể làm một interface IInputReceiver chứa các method trên. Các Handler implement interface đó, còn InputTransfer sẽ thông qua interface, hoặc UnityEvent để truyền command qua cho Handler. (Nói vậy chắc không quá khó hiểu đâu hé).

Gom góp

Chung quy lại rồi cũng là câu chuyện : phân tách, design hệ thống sao cho hợp lý, bớt coupling, dễ bảo trì, dễ mở rộng …. Vụ Input này mình thấy gặp thường xuyên, mà chưa thấy blog hay pro nào trình bày về, mạo mụi viết một bài ngắn. Các pro góp ý thoải mái, có gì hay ho, về sau mình update post tiếp.

Advertisements

2 thoughts on “Tách InputFilter khỏi Handlers trong xử lý multiplatform

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s