Lần đầu đưa một game bàn phím lên điện thoại, mình thường không vướng ở vòng lặp game mà vướng ngay nút nhảy. Ngón tay che màn hình, thanh địa chỉ làm viewport thay đổi, còn một cú vuốt có thể kéo cả trang thay vì điều khiển nhân vật. Cách đỡ rối nhất là coi phím và nút cảm ứng chỉ là hai nguồn input cùng phát ra một action.

Phần dưới bắt đầu từ action, sau đó mới nối bàn phím và pointer event. Hàm đổi mã phím được test riêng trong Node; component chỉ giữ phần thật sự cần DOM.

Action trước, thiết bị sau

Đừng viết logic nhảy bên trong onKeyDown và viết một bản nữa trong onPointerDown. Hãy có left, right, jump. Phím và nút chỉ gọi các action đó.

js
const ACTIONS = {
  ArrowLeft: "left",
  KeyA: "left",
  ArrowRight: "right",
  KeyD: "right",
  ArrowUp: "jump",
  KeyW: "jump",
  Space: "jump",
};
 
export function actionFromKey(code) {
  return Object.prototype.hasOwnProperty.call(ACTIONS, code) ? ACTIONS[code] : null;
}

Hàm nhận KeyboardEvent.code, tức vị trí phím vật lý, không nhận KeyboardEvent.key. key phụ thuộc bố cục: cùng một phím có thể ra ký tự khác trên bố cục tiếng Việt. KeyA vẫn là phím vật lý ở vị trí A của bàn phím kiểu Mỹ, nên cụm vẫn nằm đúng chỗ khi người chơi quen WASD. Phím mũi tên và Space ổn định hơn vì chúng không phải ký tự. Nếu bạn hỗ trợ cả hai, map chúng về cùng action như ví dụ.

actionFromKey("a") trả về null vì "a" là ký tự, không phải code. Đó là lỗi dễ gặp khi copy một snippet dùng event.key.

Phím chỉ khi vùng chơi đang focus

Gắn listener lên phần tử game, và cho phần tử đó tabIndex={0} để focus bằng bàn phím được. preventDefault chỉ khi bạn đã nhận một action. Nếu chặn mọi phím, người dùng không còn Tab để ra khỏi game, và không cuộn được bài viết nằm quanh demo.

tsx
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
  const action = actionFromKey(event.code);
  if (!action) return;
  event.preventDefault();
  apply(action);
}

Đừng gắn listener window cho một demo nhúng trong bài viết. Nó tranh phím với trình duyệt. Game toàn màn hình mới nên cân nhắc listener rộng hơn, và vẫn phải có cách thoát bằng bàn phím.

Nút trên màn hình gọi apply với cùng action, không gọi một hàm song song. Mute và lưu điểm không thuộc lớp này: mute nối với Web Audio, còn save nối với localStorage.

Kích thước vùng chạm

WCAG 2.2, tiêu chí 2.5.8 Target Size (Minimum), mức AA, yêu cầu vùng tương tác tối thiểu 24 x 24 pixel CSS, với một số ngoại lệ. Tiêu chí 2.5.5 mức AAA nói 44 x 44. Ngón tay trên một nút di chuyển trong game dễ trượt hơn một link trong bài viết, nên vùng điều khiển nên hướng tới khoảng 44 pixel dù mục tiêu AA thấp hơn. Đây là kích thước mục tiêu, không phải một phép đo đã chạy trên một mẫu máy.

Đừng dựa vào hover để hiện nút nhảy. Trên màn hình cảm ứng, hover không phải một trạng thái người chơi giữ được. Nút chính phải nhìn thấy hoặc có vị trí cố định.

css
.playfield {
  width: 100%;
  max-width: 40rem;
  aspect-ratio: 16 / 9;
  touch-action: none;
}
 
.pad button {
  min-width: 44px;
  min-height: 44px;
  touch-action: manipulation;
}
 
.pad {
  padding-bottom: env(safe-area-inset-bottom);
}
 
@media (pointer: coarse) {
  .pad button {
    min-width: 52px;
    min-height: 52px;
  }
}

touch-action: none trên sân chơi để trình duyệt không kéo trang khi ngón tay bắt đầu trên sân. manipulation trên nút giảm độ trễ của thao tác chạm kép để zoom, và vẫn cho phép trình duyệt xử lý cuộn ngoài nút. Đừng đặt none lên cả body của một bài viết: người đọc không cuộn được xuống phần còn lại.

env(safe-area-inset-bottom) đẩy cụm nút lên khỏi thanh home của điện thoại khi trang ở chế độ có safe area. Nó không làm gì nếu trình duyệt không cung cấp inset, nên khai báo vẫn an toàn.

100vh trên mobile thường tính cả vùng bị thanh địa chỉ che. 100dvh bám khung nhìn đang thấy. Dùng dvh cho một lớp game chiếm màn hình, và thử trên điện thoại thật vì thanh địa chỉ còn đổi khi cuộn. Một demo trong bài viết không cần chiếm 100dvh; để nó trong dòng trang, có chiều cao theo aspect-ratio.

Bố cục không giả định chuột

Sân chơi rộng tối đa bằng cột nội dung, co theo chiều ngang, giữ tỉ lệ. Chữ trên nút hoặc nhãn aria-label phải còn đó khi không có icon. Icon mũi tên một mình không đủ cho trình đọc màn hình.

@media (pointer: coarse) tăng nút khi thiết bị chính là cảm ứng. Đừng dựa vào chiều rộng 768 pixel để đoán điện thoại. Một cửa sổ hẹp trên máy tính vẫn có chuột. Một máy tính bảng ngang vẫn có ngón tay.

Khoảng cách giữa các nút cũng là vùng an toàn. Hai nút 44 pixel dính nhau vẫn dễ bấm nhầm. Chừa một khoảng trống bằng một phần của chính nút.

Những lối hay gặp

Dùng keydown lặp để di chuyển mà không có keyup sẽ dính phím. Hãy giữ một set các action đang nhấn, thêm khi xuống, xóa khi lên, và đọc set đó trong vòng lặp. Bài này không gắn một game loop, nhưng mô hình action vẫn là đầu vào của loop đó.

Nghe touchstart và cả mousedown không qua pointer events dễ khiến một cú chạm tính hai lần. onPointerDown gom chuột, bút và ngón tay. Nếu bạn cần biết đó là ngón tay, đọc pointerType, đừng đăng ký hai listener cùng gọi jump.

Chặn menu ngữ cảnh và zoom của cả trang để “giống app” làm hại người đọc và người chơi dùng trình phóng đại. Chỉ chặn hành vi trên đúng vùng điều khiển.

Danh sách trước khi cho chơi trên điện thoại

  1. Mọi phím và mọi nút gọi cùng hàm action.
  2. code chứ không phải key, trừ khi bạn cố ý hỗ trợ ký tự.
  3. Vùng chơi focus được, và chỉ preventDefault khi nhận action.
  4. Nút tối thiểu quanh 44 pixel, có khoảng hở, không phụ thuộc hover.
  5. touch-action và safe area nằm trên cụm điều khiển, không khóa cuộn cả trang.

Làm năm mục này trước khi vẽ thêm skin. Một nút nhảy bấm được trên điện thoại đáng giá hơn một layer bóng đổ. Khi đã có tiếng và có save, nối chúng bằng action mute hoặc bằng lúc visibilitychange, không bằng cách trộn DOM với audio graph trong cùng một hàm.

Tài liệu